Skip to content

Commit f04ddb4

Browse files
author
kjgbot
committed
feat(cloud): require external spawn swarm skill
1 parent cb8cfc0 commit f04ddb4

6 files changed

Lines changed: 190 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222

2323
### Added
2424

25+
- `agent-relay cloud connect claude` now reminds users to install `spawn-cloud-swarm` from the AgentWorkforce skills repo before repo-attached cloud swarms are used.
2526
- `agent-relay activity` tails broker-wide message, delivery, lifecycle, and worker output events in a human-readable stream with filters and JSON Lines output.
2627
- `agent-relay view <name>` streams a running agent's PTY without taking control or stopping the agent.
2728
- `agent-relay drive <name>` attaches interactively and queues inbound relay messages until the user flushes them.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ Want more than a toy example? Start with:
115115
- Tooling that lets existing agents communicate without rewriting their runtime
116116
- Local or remote coordination patterns where multiple agents need shared context
117117

118+
Bundled skills include `adding-swarm-patterns`, `using-agent-relay`, and `running-headless-orchestrator`. For repo-attached cloud swarms, install `spawn-cloud-swarm` from https://github.com/AgentWorkforce/skills with `npx skills add https://github.com/agentworkforce/skills --skill spawn-cloud-swarm`.
119+
118120
Then use Agent Relay to bring agents into a shared workspace and route work between them.
119121

120122
## Supported agents and runtimes

src/cli/commands/cloud.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,31 @@ const cloudMocks = vi.hoisted(() => ({
99
syncWorkflowPatch: vi.fn(),
1010
}));
1111

12+
const connectProviderMock = vi.hoisted(() => vi.fn());
13+
1214
vi.mock('@agent-relay/cloud', () => ({
1315
AUTH_FILE_PATH: '/tmp/cloud-auth.json',
1416
REFRESH_WINDOW_MS: 60_000,
1517
authorizedApiFetch: vi.fn(),
1618
cancelWorkflow: vi.fn(),
1719
clearStoredAuth: vi.fn(),
18-
connectProvider: vi.fn(),
20+
connectProvider: (...args: unknown[]) => connectProviderMock(...args),
1921
defaultApiUrl: () => 'https://cloud.test',
2022
ensureAuthenticated: vi.fn(),
2123
getProviderHelpText: () =>
2224
'anthropic (alias: claude), openai (alias: codex), google (alias: gemini), cursor, opencode, droid',
2325
getRunLogs: vi.fn(),
2426
getRunStatus: (...args: unknown[]) => cloudMocks.getRunStatus(...args),
2527
listWorkflowSchedules: (...args: unknown[]) => cloudMocks.listWorkflowSchedules(...args),
28+
normalizeProvider: (provider: string) => {
29+
const lower = provider.toLowerCase().trim();
30+
const aliases: Record<string, string> = {
31+
claude: 'anthropic',
32+
codex: 'openai',
33+
gemini: 'google',
34+
};
35+
return aliases[lower] ?? lower;
36+
},
2637
readStoredAuth: vi.fn(),
2738
runWorkflow: (...args: unknown[]) => cloudMocks.runWorkflow(...args),
2839
scheduleWorkflow: (...args: unknown[]) => cloudMocks.scheduleWorkflow(...args),
@@ -39,7 +50,7 @@ beforeEach(() => {
3950
vi.clearAllMocks();
4051
});
4152

42-
function createHarness() {
53+
function createHarness(overrides: Partial<CloudDependencies> = {}) {
4354
const exit = vi.fn((code: number) => {
4455
throw new Error(`exit:${code}`);
4556
}) as unknown as CloudDependencies['exit'];
@@ -48,6 +59,7 @@ function createHarness() {
4859
log: vi.fn(() => undefined),
4960
error: vi.fn(() => undefined),
5061
exit,
62+
...overrides,
5163
};
5264

5365
const program = new Command();
@@ -63,6 +75,7 @@ describe('registerCloudCommands', () => {
6375
const cloud = program.commands.find((command) => command.name() === 'cloud');
6476

6577
expect(cloud).toBeDefined();
78+
expect(cloud?.description()).toContain('workflow commands');
6679
expect(cloud?.commands.map((command) => command.name())).toEqual([
6780
'login',
6881
'logout',
@@ -372,4 +385,52 @@ describe('registerCloudCommands', () => {
372385
expect(deps.log).toHaveBeenCalledWith('Patches:');
373386
expect(deps.log).toHaveBeenCalledWith(' cloud: patch pending - run still active');
374387
});
388+
389+
describe('cloud connect spawn-cloud-swarm skill guidance', () => {
390+
it('prints the external install command for claude when the skill is missing', async () => {
391+
connectProviderMock.mockResolvedValueOnce({ success: true });
392+
393+
const { program, deps } = createHarness({
394+
skillInstalled: vi.fn(() => false),
395+
});
396+
397+
await program.parseAsync(['node', 'agent-relay', 'cloud', 'connect', 'claude']);
398+
399+
expect(deps.log).toHaveBeenCalledWith(
400+
'Install spawn-cloud-swarm before spawning cloud swarms: npx skills add https://github.com/agentworkforce/skills --skill spawn-cloud-swarm'
401+
);
402+
});
403+
404+
it('does not print the install command when the claude skill is already present', async () => {
405+
connectProviderMock.mockResolvedValueOnce({ success: true });
406+
407+
const { program, deps } = createHarness({
408+
skillInstalled: vi.fn(() => true),
409+
});
410+
411+
await program.parseAsync(['node', 'agent-relay', 'cloud', 'connect', 'claude']);
412+
413+
expect(deps.log).not.toHaveBeenCalledWith(expect.stringContaining('npx skills add'));
414+
});
415+
416+
it('does not check the claude skill for non-claude providers', async () => {
417+
const skillInstalled = vi.fn(() => false);
418+
connectProviderMock.mockResolvedValueOnce({ success: true });
419+
420+
const { program } = createHarness({ skillInstalled });
421+
422+
await program.parseAsync(['node', 'agent-relay', 'cloud', 'connect', 'codex']);
423+
424+
expect(skillInstalled).not.toHaveBeenCalled();
425+
});
426+
427+
it('describes the external skill install requirement in `cloud connect --help` text', () => {
428+
const { program } = createHarness();
429+
const cloud = program.commands.find((command) => command.name() === 'cloud');
430+
const connect = cloud?.commands.find((command) => command.name() === 'connect');
431+
432+
expect(connect?.description()).toContain('spawn-cloud-swarm');
433+
expect(connect?.description()).toContain('AgentWorkforce/skills');
434+
});
435+
});
375436
});

src/cli/commands/cloud.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ import {
3030
import { defaultExit } from '../lib/exit.js';
3131
import { errorClassName } from '../lib/telemetry-helpers.js';
3232

33+
const SPAWN_CLOUD_SWARM_SKILL_NAME = 'spawn-cloud-swarm';
34+
const SPAWN_CLOUD_SWARM_SKILL_INSTALL_COMMAND =
35+
'npx skills add https://github.com/agentworkforce/skills --skill spawn-cloud-swarm';
36+
3337
const CLOUD_SYNC_PATCH_EXCLUDES = [
3438
'.agent-bin/**',
3539
'.relayfile.acl',
@@ -51,6 +55,7 @@ export interface CloudDependencies {
5155
log: (...args: unknown[]) => void;
5256
error: (...args: unknown[]) => void;
5357
exit: ExitFn;
58+
skillInstalled?: (skillName: string) => boolean;
5459
}
5560

5661
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -87,6 +92,10 @@ function parseWorkflowFileType(value: string): WorkflowFileType {
8792
throw new InvalidArgumentError('Expected workflow type to be one of: yaml, ts, py');
8893
}
8994

95+
function hasClaudeSkillInstalled(skillName: string): boolean {
96+
return fs.existsSync(path.join(os.homedir(), '.claude', 'skills', skillName, 'SKILL.md'));
97+
}
98+
9099
function parseEnvAssignment(value: string, previous: Record<string, string> = {}): Record<string, string> {
91100
const equalsIndex = value.indexOf('=');
92101
if (equalsIndex <= 0) {
@@ -328,7 +337,9 @@ export function registerCloudCommands(program: Command, overrides: Partial<Cloud
328337

329338
cloudCommand
330339
.command('connect')
331-
.description('Connect a provider via interactive SSH session')
340+
.description(
341+
'Connect a provider via interactive SSH session. Install `spawn-cloud-swarm` from AgentWorkforce/skills before asking Claude to spawn cloud swarms.'
342+
)
332343
.argument('<provider>', `Provider to connect (${getProviderHelpText()})`)
333344
.option('--api-url <url>', 'Cloud API base URL')
334345
.option('--language <language>', 'Sandbox language/image', 'typescript')
@@ -347,6 +358,14 @@ export function registerCloudCommands(program: Command, overrides: Partial<Cloud
347358
io: { log: deps.log, error: deps.error },
348359
});
349360
success = result.success;
361+
if (success && trackedProvider === 'anthropic') {
362+
const skillInstalled = deps.skillInstalled ?? hasClaudeSkillInstalled;
363+
if (!skillInstalled(SPAWN_CLOUD_SWARM_SKILL_NAME)) {
364+
deps.log(
365+
`Install ${SPAWN_CLOUD_SWARM_SKILL_NAME} before spawning cloud swarms: ${SPAWN_CLOUD_SWARM_SKILL_INSTALL_COMMAND}`
366+
);
367+
}
368+
}
350369
} catch (err) {
351370
errorClass = errorClassName(err);
352371
throw err;

src/cli/lib/mcp-preflight.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
MCP_PREFLIGHT_REMEDIATION,
5+
REQUIRED_CLOUD_LOCAL_MOUNT_TOOLS,
6+
runMcpPreflight,
7+
} from './mcp-preflight.js';
8+
9+
const FULL_TOOLS = [
10+
{ name: 'cloud.agent.spawn' },
11+
{ name: 'cloud.agent.list' },
12+
{ name: 'cloud.local-mount.ensure' },
13+
{ name: 'cloud.local-mount.status' },
14+
{ name: 'cloud.local-mount.stop' },
15+
];
16+
17+
describe('runMcpPreflight', () => {
18+
it('returns ok when all required cloud.local-mount.* tools are present', async () => {
19+
const result = await runMcpPreflight({
20+
listTools: () => FULL_TOOLS,
21+
});
22+
23+
expect(result.ok).toBe(true);
24+
expect(result.missing).toEqual([]);
25+
expect(result.remediation).toBeUndefined();
26+
});
27+
28+
it('returns missing list and verbatim remediation when cloud.local-mount.ensure is absent', async () => {
29+
const partial = FULL_TOOLS.filter((t) => t.name !== 'cloud.local-mount.ensure');
30+
31+
const result = await runMcpPreflight({
32+
listTools: () => partial,
33+
});
34+
35+
expect(result.ok).toBe(false);
36+
expect(result.missing).toEqual(['cloud.local-mount.ensure']);
37+
expect(result.remediation).toBe(MCP_PREFLIGHT_REMEDIATION);
38+
expect(MCP_PREFLIGHT_REMEDIATION).toBe(
39+
'Upgrade `@relaycast/mcp` to a build that includes `cloud.local-mount.*` (see relaycast PR `feat/cloud-local-mount-tools`).'
40+
);
41+
});
42+
43+
it('reports every missing tool when the MCP omits the whole local-mount surface', async () => {
44+
const result = await runMcpPreflight({
45+
listTools: () => [{ name: 'cloud.agent.spawn' }, { name: 'cloud.agent.list' }],
46+
});
47+
48+
expect(result.ok).toBe(false);
49+
expect(result.missing).toEqual([
50+
'cloud.local-mount.ensure',
51+
'cloud.local-mount.status',
52+
'cloud.local-mount.stop',
53+
]);
54+
expect(result.remediation).toBe(MCP_PREFLIGHT_REMEDIATION);
55+
});
56+
57+
it('exports the canonical required-tools tuple', () => {
58+
expect(REQUIRED_CLOUD_LOCAL_MOUNT_TOOLS).toEqual([
59+
'cloud.local-mount.ensure',
60+
'cloud.local-mount.status',
61+
'cloud.local-mount.stop',
62+
]);
63+
});
64+
});

src/cli/lib/mcp-preflight.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export const REQUIRED_CLOUD_LOCAL_MOUNT_TOOLS = [
2+
'cloud.local-mount.ensure',
3+
'cloud.local-mount.status',
4+
'cloud.local-mount.stop',
5+
] as const;
6+
7+
export const MCP_PREFLIGHT_REMEDIATION =
8+
'Upgrade `@relaycast/mcp` to a build that includes `cloud.local-mount.*` (see relaycast PR `feat/cloud-local-mount-tools`).';
9+
10+
export interface McpToolDescriptor {
11+
name: string;
12+
}
13+
14+
export interface McpPreflightResult {
15+
ok: boolean;
16+
missing: string[];
17+
remediation?: string;
18+
}
19+
20+
export interface McpPreflightArgs {
21+
listTools: () => Promise<readonly McpToolDescriptor[]> | readonly McpToolDescriptor[];
22+
required?: readonly string[];
23+
}
24+
25+
export async function runMcpPreflight(args: McpPreflightArgs): Promise<McpPreflightResult> {
26+
const required = args.required ?? REQUIRED_CLOUD_LOCAL_MOUNT_TOOLS;
27+
const tools = await args.listTools();
28+
const present = new Set(tools.map((t) => t.name));
29+
const missing = required.filter((name) => !present.has(name));
30+
31+
if (missing.length === 0) {
32+
return { ok: true, missing: [] };
33+
}
34+
35+
return {
36+
ok: false,
37+
missing,
38+
remediation: MCP_PREFLIGHT_REMEDIATION,
39+
};
40+
}

0 commit comments

Comments
 (0)