Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `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.
- `agent-relay activity` tails broker-wide message, delivery, lifecycle, and worker output events in a human-readable stream with filters and JSON Lines output.
- `agent-relay view <name>` streams a running agent's PTY without taking control or stopping the agent.
- `agent-relay drive <name>` attaches interactively and queues inbound relay messages until the user flushes them.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ Want more than a toy example? Start with:
- Tooling that lets existing agents communicate without rewriting their runtime
- Local or remote coordination patterns where multiple agents need shared context

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`.

Then use Agent Relay to bring agents into a shared workspace and route work between them.

## Supported agents and runtimes
Expand Down
65 changes: 63 additions & 2 deletions src/cli/commands/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,31 @@ const cloudMocks = vi.hoisted(() => ({
syncWorkflowPatch: vi.fn(),
}));

const connectProviderMock = vi.hoisted(() => vi.fn());

vi.mock('@agent-relay/cloud', () => ({
AUTH_FILE_PATH: '/tmp/cloud-auth.json',
REFRESH_WINDOW_MS: 60_000,
authorizedApiFetch: vi.fn(),
cancelWorkflow: vi.fn(),
clearStoredAuth: vi.fn(),
connectProvider: vi.fn(),
connectProvider: (...args: unknown[]) => connectProviderMock(...args),
defaultApiUrl: () => 'https://cloud.test',
ensureAuthenticated: vi.fn(),
getProviderHelpText: () =>
'anthropic (alias: claude), openai (alias: codex), google (alias: gemini), cursor, opencode, droid',
getRunLogs: vi.fn(),
getRunStatus: (...args: unknown[]) => cloudMocks.getRunStatus(...args),
listWorkflowSchedules: (...args: unknown[]) => cloudMocks.listWorkflowSchedules(...args),
normalizeProvider: (provider: string) => {
const lower = provider.toLowerCase().trim();
const aliases: Record<string, string> = {
claude: 'anthropic',
codex: 'openai',
gemini: 'google',
};
return aliases[lower] ?? lower;
},
readStoredAuth: vi.fn(),
runWorkflow: (...args: unknown[]) => cloudMocks.runWorkflow(...args),
scheduleWorkflow: (...args: unknown[]) => cloudMocks.scheduleWorkflow(...args),
Expand All @@ -39,7 +50,7 @@ beforeEach(() => {
vi.clearAllMocks();
});

function createHarness() {
function createHarness(overrides: Partial<CloudDependencies> = {}) {
const exit = vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}) as unknown as CloudDependencies['exit'];
Expand All @@ -48,6 +59,7 @@ function createHarness() {
log: vi.fn(() => undefined),
error: vi.fn(() => undefined),
exit,
...overrides,
};

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

expect(cloud).toBeDefined();
expect(cloud?.description()).toContain('workflow commands');
expect(cloud?.commands.map((command) => command.name())).toEqual([
'login',
'logout',
Expand Down Expand Up @@ -372,4 +385,52 @@ describe('registerCloudCommands', () => {
expect(deps.log).toHaveBeenCalledWith('Patches:');
expect(deps.log).toHaveBeenCalledWith(' cloud: patch pending - run still active');
});

describe('cloud connect spawn-cloud-swarm skill guidance', () => {
it('prints the external install command for claude when the skill is missing', async () => {
connectProviderMock.mockResolvedValueOnce({ success: true });

const { program, deps } = createHarness({
skillInstalled: vi.fn(() => false),
});

await program.parseAsync(['node', 'agent-relay', 'cloud', 'connect', 'claude']);

expect(deps.log).toHaveBeenCalledWith(
'Install spawn-cloud-swarm before spawning cloud swarms: npx skills add https://github.com/agentworkforce/skills --skill spawn-cloud-swarm'
);
});

it('does not print the install command when the claude skill is already present', async () => {
connectProviderMock.mockResolvedValueOnce({ success: true });

const { program, deps } = createHarness({
skillInstalled: vi.fn(() => true),
});

await program.parseAsync(['node', 'agent-relay', 'cloud', 'connect', 'claude']);

expect(deps.log).not.toHaveBeenCalledWith(expect.stringContaining('npx skills add'));
});

it('does not check the claude skill for non-claude providers', async () => {
const skillInstalled = vi.fn(() => false);
connectProviderMock.mockResolvedValueOnce({ success: true });

const { program } = createHarness({ skillInstalled });

await program.parseAsync(['node', 'agent-relay', 'cloud', 'connect', 'codex']);

expect(skillInstalled).not.toHaveBeenCalled();
});

it('describes the external skill install requirement in `cloud connect --help` text', () => {
const { program } = createHarness();
const cloud = program.commands.find((command) => command.name() === 'cloud');
const connect = cloud?.commands.find((command) => command.name() === 'connect');

expect(connect?.description()).toContain('spawn-cloud-swarm');
expect(connect?.description()).toContain('AgentWorkforce/skills');
});
});
});
21 changes: 20 additions & 1 deletion src/cli/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import {
import { defaultExit } from '../lib/exit.js';
import { errorClassName } from '../lib/telemetry-helpers.js';

const SPAWN_CLOUD_SWARM_SKILL_NAME = 'spawn-cloud-swarm';
const SPAWN_CLOUD_SWARM_SKILL_INSTALL_COMMAND =
'npx skills add https://github.com/agentworkforce/skills --skill spawn-cloud-swarm';

const CLOUD_SYNC_PATCH_EXCLUDES = [
'.agent-bin/**',
'.relayfile.acl',
Expand All @@ -51,6 +55,7 @@ export interface CloudDependencies {
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
exit: ExitFn;
skillInstalled?: (skillName: string) => boolean;
}

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

function hasClaudeSkillInstalled(skillName: string): boolean {
return fs.existsSync(path.join(os.homedir(), '.claude', 'skills', skillName, 'SKILL.md'));
}

function parseEnvAssignment(value: string, previous: Record<string, string> = {}): Record<string, string> {
const equalsIndex = value.indexOf('=');
if (equalsIndex <= 0) {
Expand Down Expand Up @@ -328,7 +337,9 @@ export function registerCloudCommands(program: Command, overrides: Partial<Cloud

cloudCommand
.command('connect')
.description('Connect a provider via interactive SSH session')
.description(
'Connect a provider via interactive SSH session. Install `spawn-cloud-swarm` from AgentWorkforce/skills before asking Claude to spawn cloud swarms.'
)
.argument('<provider>', `Provider to connect (${getProviderHelpText()})`)
.option('--api-url <url>', 'Cloud API base URL')
.option('--language <language>', 'Sandbox language/image', 'typescript')
Expand All @@ -347,6 +358,14 @@ export function registerCloudCommands(program: Command, overrides: Partial<Cloud
io: { log: deps.log, error: deps.error },
});
success = result.success;
if (success && trackedProvider === 'anthropic') {
const skillInstalled = deps.skillInstalled ?? hasClaudeSkillInstalled;
if (!skillInstalled(SPAWN_CLOUD_SWARM_SKILL_NAME)) {
deps.log(
`Install ${SPAWN_CLOUD_SWARM_SKILL_NAME} before spawning cloud swarms: ${SPAWN_CLOUD_SWARM_SKILL_INSTALL_COMMAND}`
);
}
}
Comment on lines +361 to +368
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the Claude skill probe fail-open so connect success is not downgraded to failure.

If skillInstalled(...) throws (override bug, filesystem edge case), the exception bubbles to the outer catch and turns a successful connect into a failed command.

Suggested fix
         success = result.success;
         if (success && trackedProvider === 'anthropic') {
-          const skillInstalled = deps.skillInstalled ?? hasClaudeSkillInstalled;
-          if (!skillInstalled(SPAWN_CLOUD_SWARM_SKILL_NAME)) {
-            deps.log(
-              `Install ${SPAWN_CLOUD_SWARM_SKILL_NAME} before spawning cloud swarms: ${SPAWN_CLOUD_SWARM_SKILL_INSTALL_COMMAND}`
-            );
-          }
+          try {
+            const skillInstalled = deps.skillInstalled ?? hasClaudeSkillInstalled;
+            if (!skillInstalled(SPAWN_CLOUD_SWARM_SKILL_NAME)) {
+              deps.log(
+                `Install ${SPAWN_CLOUD_SWARM_SKILL_NAME} before spawning cloud swarms: ${SPAWN_CLOUD_SWARM_SKILL_INSTALL_COMMAND}`
+              );
+            }
+          } catch (skillErr) {
+            const message = skillErr instanceof Error ? skillErr.message : String(skillErr ?? 'unknown error');
+            deps.log(`warning: unable to verify Claude skill installation (${message})`);
+          }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (success && trackedProvider === 'anthropic') {
const skillInstalled = deps.skillInstalled ?? hasClaudeSkillInstalled;
if (!skillInstalled(SPAWN_CLOUD_SWARM_SKILL_NAME)) {
deps.log(
`Install ${SPAWN_CLOUD_SWARM_SKILL_NAME} before spawning cloud swarms: ${SPAWN_CLOUD_SWARM_SKILL_INSTALL_COMMAND}`
);
}
}
if (success && trackedProvider === 'anthropic') {
try {
const skillInstalled = deps.skillInstalled ?? hasClaudeSkillInstalled;
if (!skillInstalled(SPAWN_CLOUD_SWARM_SKILL_NAME)) {
deps.log(
`Install ${SPAWN_CLOUD_SWARM_SKILL_NAME} before spawning cloud swarms: ${SPAWN_CLOUD_SWARM_SKILL_INSTALL_COMMAND}`
);
}
} catch (skillErr) {
const message = skillErr instanceof Error ? skillErr.message : String(skillErr ?? 'unknown error');
deps.log(`warning: unable to verify Claude skill installation (${message})`);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/commands/cloud.ts` around lines 361 - 368, The Claude skill probe
(skillInstalled / hasClaudeSkillInstalled) can throw and bubble up turning a
successful connect into failure; wrap the probe call inside a try/catch when
running the anthopic branch (the block that checks success && trackedProvider
=== 'anthropic') so any exception is treated as "fail-open" (assume installed or
at least do not throw) and optionally log a warning via deps.log; specifically
protect the call to skillInstalled(SPAWN_CLOUD_SWARM_SKILL_NAME) (and the
fallback hasClaudeSkillInstalled) so an exception does not propagate out of the
connect flow and only logs the error without changing the overall success state.

} catch (err) {
errorClass = errorClassName(err);
throw err;
Expand Down
64 changes: 64 additions & 0 deletions src/cli/lib/mcp-preflight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';

import {
MCP_PREFLIGHT_REMEDIATION,
REQUIRED_CLOUD_LOCAL_MOUNT_TOOLS,
runMcpPreflight,
} from './mcp-preflight.js';

const FULL_TOOLS = [
{ name: 'cloud.agent.spawn' },
{ name: 'cloud.agent.list' },
{ name: 'cloud.local-mount.ensure' },
{ name: 'cloud.local-mount.status' },
{ name: 'cloud.local-mount.stop' },
];

describe('runMcpPreflight', () => {
it('returns ok when all required cloud.local-mount.* tools are present', async () => {
const result = await runMcpPreflight({
listTools: () => FULL_TOOLS,
});

expect(result.ok).toBe(true);
expect(result.missing).toEqual([]);
expect(result.remediation).toBeUndefined();
});

it('returns missing list and verbatim remediation when cloud.local-mount.ensure is absent', async () => {
const partial = FULL_TOOLS.filter((t) => t.name !== 'cloud.local-mount.ensure');

const result = await runMcpPreflight({
listTools: () => partial,
});

expect(result.ok).toBe(false);
expect(result.missing).toEqual(['cloud.local-mount.ensure']);
expect(result.remediation).toBe(MCP_PREFLIGHT_REMEDIATION);
expect(MCP_PREFLIGHT_REMEDIATION).toBe(
'Upgrade `@relaycast/mcp` to a build that includes `cloud.local-mount.*` (see relaycast PR `feat/cloud-local-mount-tools`).'
);
});

it('reports every missing tool when the MCP omits the whole local-mount surface', async () => {
const result = await runMcpPreflight({
listTools: () => [{ name: 'cloud.agent.spawn' }, { name: 'cloud.agent.list' }],
});

expect(result.ok).toBe(false);
expect(result.missing).toEqual([
'cloud.local-mount.ensure',
'cloud.local-mount.status',
'cloud.local-mount.stop',
]);
expect(result.remediation).toBe(MCP_PREFLIGHT_REMEDIATION);
});

it('exports the canonical required-tools tuple', () => {
expect(REQUIRED_CLOUD_LOCAL_MOUNT_TOOLS).toEqual([
'cloud.local-mount.ensure',
'cloud.local-mount.status',
'cloud.local-mount.stop',
]);
});
});
40 changes: 40 additions & 0 deletions src/cli/lib/mcp-preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export const REQUIRED_CLOUD_LOCAL_MOUNT_TOOLS = [
'cloud.local-mount.ensure',
'cloud.local-mount.status',
'cloud.local-mount.stop',
] as const;

export const MCP_PREFLIGHT_REMEDIATION =
'Upgrade `@relaycast/mcp` to a build that includes `cloud.local-mount.*` (see relaycast PR `feat/cloud-local-mount-tools`).';

export interface McpToolDescriptor {
name: string;
}

export interface McpPreflightResult {
ok: boolean;
missing: string[];
remediation?: string;
}

export interface McpPreflightArgs {
listTools: () => Promise<readonly McpToolDescriptor[]> | readonly McpToolDescriptor[];
required?: readonly string[];
}

export async function runMcpPreflight(args: McpPreflightArgs): Promise<McpPreflightResult> {
const required = args.required ?? REQUIRED_CLOUD_LOCAL_MOUNT_TOOLS;
const tools = await args.listTools();
const present = new Set(tools.map((t) => t.name));
const missing = required.filter((name) => !present.has(name));

if (missing.length === 0) {
return { ok: true, missing: [] };
}

return {
ok: false,
missing,
remediation: MCP_PREFLIGHT_REMEDIATION,
};
}
Loading