diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ac1ede9..ed8b66ae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `install.sh` no longer reports an unwritable `TMPDIR` as a failed verification or falls back to npm when it cannot create a temp file. - `install.sh` now also installs the short `relay` command next to `agent-relay` (same CLI), so `relay init` works right after a curl install. An existing `relay` from another tool is left alone with a warning, and `agent-relay uninstall` removes `relay` only when the installer wrote it. - `relay init` no longer switches the machine-wide active workspace away from one already in use: it saves the new workspace for the current project, keeps the existing active one, and prints `relay workspace switch ` to make it the default. +- `relay init` asks nothing by default, in a terminal too: this machine (or `--hosted`, or `--url`/`--key`), every installed agent tool, global scope, then a summary of what it chose and the flag that changes each. `relay init --interactive` (`-i`) asks each step as before; `--yes` is still accepted and changes nothing. +- `relay init --key ` joins the hosted workspace that key belongs to, under the name the server gives it (`--url` still selects your own server; a key bound to another server is refused). `relay init --hosted` names a new workspace after you and the folder unless you pass `--name`, and prints `npx -y agent-relay@next init --key ''` for teammates (`agent-relay@` from a stable release), which joins and sets up message injection in one step (the `relay workspace join` it printed only saved the workspace). Joining reports the hosted workspace instead of "your server", and `relay workspace join` now points at `relay init`. - `relay init` no longer writes npx's temporary cache path into agent tool configs. Project configs (`.mcp.json` and the like) run `agent-relay mcp` by name, or `npx -y agent-relay@ mcp` when Relay is not installed, so they work on teammates' machines. Re-running `relay init` updates entries it wrote earlier. - `relay init --scope project` now tells the truth about Codex: Codex loads `.codex/config.toml` only when that exact project is trusted in `~/.codex/config.toml` (a trusted parent folder does not count, and `codex exec` never asks). In a terminal, `relay init` offers to record that trust; with `--yes` it prints the lines to add. - The project workspace key file (`.agentworkforce/relay/workspace-key.json`) is no longer picked up by `git add -A`: any command that saves it adds `.agentworkforce/relay/` to `.git/info/exclude` (worktrees included), never to `.gitignore`. diff --git a/docs/quickstart.md b/docs/quickstart.md index 798e4f686..45544fbad 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -27,7 +27,22 @@ cd my-project relay init ``` -In a terminal it asks four questions, the same way `npx skills add` does: +It asks nothing. It starts a private server on this machine, connects every +agent tool installed here, at global scope, and ends with what it chose and +the flag that changes each choice: + +``` +Chosen without asking: + Server: this machine (--hosted for agents on other machines, or --url and --key for your own server) + Tools: claude, codex (the ones installed here; --tools to choose) + Scope: global (--scope project for this project only) +To choose each step yourself, run: relay init --interactive +``` + +### Choosing each step (`--interactive`) + +`relay init --interactive` (or `-i`) asks four questions in a terminal, the +same way `npx skills add` does: ``` ┌ relay init @@ -92,9 +107,9 @@ Claude Code asks you to approve a project `.mcp.json` server the first time it opens the folder. Pi reads `.pi/mcp.json` only once you trust the project, which it asks about on first run. Codex reads `.codex/config.toml` only when that exact project (its git root) is trusted in `~/.codex/config.toml`; a trusted -parent folder does not count, and `codex exec` never asks. In a terminal -`relay init` offers to record that trust for you; with `--yes` it prints the -lines to add instead. +parent folder does not count, and `codex exec` never asks. With +`--interactive`, `relay init` offers to record that trust for you; otherwise +it prints the lines to add. In a git repository, `relay init` keeps `.agentworkforce/relay/` (which holds the project's workspace key) out of `git status` through `.git/info/exclude`; @@ -109,15 +124,14 @@ active on this machine (for example one a fleet node uses), `relay init` leaves it active and says so; run `relay workspace switch ` to make the new one the default everywhere. -### Without prompts +### Flags -`relay init` asks nothing when stdin is not a terminal, or with `--yes`. It then -uses this machine, the agent tools it detects, and project scope, unless flags -say otherwise: +Flags change the defaults; `relay init` still asks nothing (and behaves the +same in CI or a pipe). `--yes` is accepted and changes nothing: ```bash -relay init --tools claude,codex --scope project --yes -relay init --hosted --tools all --scope global --yes +relay init --tools claude,codex --scope project +relay init --hosted --tools all relay init --url https://relay.example.com:8443 --key --tools none ``` @@ -152,15 +166,20 @@ agent can still choose its own name with the `register_agent` tool, and ## Across machines (hosted) -On the first machine, choose _Hosted_ in `relay init` (or run -`relay init --hosted`). It creates a hosted workspace and prints one command. -Run that command on each other machine, then `relay init` there to connect its -agent tools: +On the first machine, run `relay init --hosted`. It creates a hosted +workspace, named after you and the folder unless you pass `--name`, and prints +one command. Run that command in the project folder on each other machine; it +needs only Node.js. It joins the workspace under the name it has there and +connects that machine's agent tools, the same way `relay init` did on the +first one: ```bash -relay workspace join +npx -y agent-relay@next init --key '' ``` +With Relay installed, `relay init --key ''` does the same. In Windows +Command Prompt, leave out the quotes around the key. + Treat the key like a password: anyone with it can join the workspace. The hosted service has a free tier, enough to try agents across machines. diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index ec7732756..41099192f 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -158,20 +158,106 @@ describe('relay init', () => { expect(boundOrigin('rk_live_bad')).toBeUndefined(); }); - it('requires --url and --key together', async () => { + it('requires --key with --url', async () => { const { run, errors } = setup(); await expect(run('--url', 'https://relay.corp.test')).rejects.toEqual(new ExitSignal(1)); - expect(errors.join('\n')).toContain('--url and --key together'); + expect(errors.join('\n')).toContain('Pass --key with --url'); }); it('creates a hosted workspace and prints the one command another machine runs to join', async () => { - const { run, logs, createWorkspace } = setup(); + const { run, logs, createWorkspace } = setup({ cliVersion: '15.0.0-next.6' }); await run('--hosted', '--name', 'team'); expect(createWorkspace).toHaveBeenCalledWith('team', 'https://cast.agentrelay.com'); - expect(logs.join('\n')).toContain('relay workspace join team rk_live_team_remote'); + const output = logs.join('\n'); + // The teammate's command is init itself, through npx so Relay need not + // be installed there: it joins and sets up injection in one step. + expect(output).toContain('treat this key like a password'); + expect(output).toContain(" npx -y agent-relay@next init --key 'rk_live_team_remote'"); + expect(output).not.toContain('relay workspace join'); + expect(output).not.toContain('--name'); expect(boundOrigin('rk_live_team_remote')).toBeUndefined(); }); + it.each([ + ['15.0.0-next.6', 'agent-relay@next'], + ['15.1.0', 'agent-relay@15.1.0'], + [undefined, 'agent-relay@next'], + ])('names the npx package for CLI version %s', async (cliVersion, spec) => { + const { run, logs } = setup({ cliVersion }); + await run('--hosted', '--name', 'team'); + expect(logs.join('\n')).toContain(`npx -y ${spec} init --key 'rk_live_team_remote'`); + }); + + it('names a hosted workspace after the person and the folder, without asking', async () => { + const { run, logs, createWorkspace } = setup(); + await run('--hosted'); + let username = ''; + try { + username = os.userInfo().username; + } catch { + // No passwd entry: init names it after the folder alone. + } + const user = username + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + const folder = path + .basename(project) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + const name = [user, folder].filter(Boolean).join('-').slice(0, 40); + expect(createWorkspace).toHaveBeenCalledWith(name, 'https://cast.agentrelay.com'); + const output = logs.join('\n'); + expect(output).toContain(`Created hosted workspace "${name}".`); + expect(output).toContain('pass --name to choose another'); + // A second one here gets a name of its own instead of replacing it. + await run('--hosted'); + expect(createWorkspace).toHaveBeenLastCalledWith(`${name}-2`, 'https://cast.agentrelay.com'); + }); + + it('joins a hosted workspace with --key alone, named as the server names it', async () => { + const { run, logs, createWorkspace, deps } = setup({ + fetch: vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.pathname === '/v1/workspace') { + return jsonResponse(200, { ok: true, data: { id: 'ws_team', name: 'team' } }); + } + return jsonResponse(404); + }) as never, + }); + await run('--key', 'rk_live_team_remote'); + expect(createWorkspace).not.toHaveBeenCalled(); + expect(vi.mocked(deps.fetch!).mock.calls[0]?.[0]?.toString()).toBe( + 'https://cast.agentrelay.com/v1/workspace' + ); + expect(readWorkspaceStore().workspaces.team?.key).toBe('rk_live_team_remote'); + const output = logs.join('\n'); + expect(output).toContain('Joined hosted workspace "team" (https://cast.agentrelay.com).'); + expect(output).toContain('only ever sent to the hosted service'); + expect(output).not.toContain('your server'); + // A teammate does not get told to invite others with a create-only hint. + expect(output).not.toContain('On another machine'); + }); + + it('keeps an explicit --name, and the --url form, when joining the hosted service', async () => { + const { run, logs } = setup(); + await run('--url', 'https://cast.agentrelay.com', '--key', 'rk_live_team_remote', '--name', 'mine'); + expect(readWorkspaceStore().workspaces.mine?.key).toBe('rk_live_team_remote'); + expect(logs.join('\n')).toContain('Joined hosted workspace "mine" (https://cast.agentrelay.com).'); + }); + + it('never sends a key bound to a self-hosted server to the hosted service', async () => { + const first = setup(); + await first.run('--url', 'https://relay.corp.test:8443', '--key', 'rk_live_corp', '--name', 'corp'); + const fetch = vi.fn(async () => jsonResponse(200, { ok: true, data: {} })); + const { run, errors } = setup({ fetch: fetch as never }); + await expect(run('--key', 'rk_live_corp')).rejects.toEqual(new ExitSignal(1)); + expect(errors.join('\n')).toContain('belongs to https://relay.corp.test:8443'); + expect(fetch).not.toHaveBeenCalled(); + expect(boundOrigin('rk_live_corp')).toBe('https://relay.corp.test:8443'); + }); + it('keeps the saved local workspace when checking it fails for a reason other than a rejected key', async () => { const first = setup(); await first.run(); @@ -355,7 +441,7 @@ describe('relay init and the machine-wide active workspace', () => { writeWorkspaceStore(HOSTED); const script = scriptedPrompter(['local', []]); const { run } = setup({ isInteractive: () => true, prompter: () => script.prompter }); - await run(); + await run('--interactive'); expect(readWorkspaceStore().active).toBe('ghsub-live'); expect(projectKey()).toBe('rk_live_local_local'); expect(script.shown.join('\n')).toMatch(keptLine); @@ -434,11 +520,42 @@ function scriptedPrompter(answers: unknown[]) { const mcpJson = () => JSON.parse(fs.readFileSync(path.join(project, '.mcp.json'), 'utf8')); describe('relay init agent tools (non-interactive)', () => { - it('connects the detected tools at project scope by default', async () => { + // The default asks nothing, even in a terminal: this machine, every + // installed tool, global scope, then what it chose and how to change it. + it('asks nothing by default, even in a terminal, and says what it chose', async () => { + fs.mkdirSync(path.join(toolHome, '.claude')); + const prompter = vi.fn(); + const { run, logs } = setup({ + isInteractive: () => true, + prompter: prompter as never, + resolveLaunch: (_ctx: unknown, scope: string) => ({ + command: '/opt/relay/bin/agent-relay', + args: ['mcp'], + reason: scope, + }), + } as never); + await run(); + expect(prompter).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); + const output = logs.join('\n'); + expect(output).toContain('Chosen without asking:'); + expect(output).toMatch(/Server:\s+this machine \(--hosted for agents on other machines/); + expect(output).toMatch(/Tools:\s+claude \(the ones installed here; --tools to choose\)/); + expect(output).toMatch(/Scope:\s+global \(--scope project for this project only\)/); + expect(output).toContain('relay init --interactive'); + }); + + it('asks only with --interactive, and only in a terminal', async () => { + const { run, errors } = setup(); + await expect(run('--interactive')).rejects.toEqual(new ExitSignal(1)); + expect(errors.join('\n')).toContain('--interactive needs a terminal'); + }); + + it('connects the detected tools at --scope project', async () => { fs.mkdirSync(path.join(toolHome, '.claude')); fs.mkdirSync(path.join(toolHome, '.codex')); const { run, logs } = setup(); - await run(); + await run('--scope', 'project'); expect(mcpJson().mcpServers['agent-relay']).toMatchObject({ command: '/opt/relay/bin/agent-relay', args: ['mcp'], @@ -491,11 +608,11 @@ describe('relay init agent tools (non-interactive)', () => { it('replaces an entry it wrote with an npx cache path on re-run', async () => { const npxCache = '/home/me/.npm/_npx/88f578dfe12e5edc/node_modules/.bin/agent-relay'; const first = setup({ resolveLaunch: () => ({ command: npxCache, args: ['mcp'], reason: 'old' }) }); - await first.run('--tools', 'claude,codex'); + await first.run('--tools', 'claude,codex', '--scope', 'project'); const { run, logs } = setup({ resolveLaunch: () => ({ command: 'agent-relay', args: ['mcp'], reason: 'portable' }), }); - await run('--tools', 'claude,codex'); + await run('--tools', 'claude,codex', '--scope', 'project'); expect(logs.join('\n')).toMatch(/Claude Code\s+updated/); expect(mcpJson().mcpServers['agent-relay'].command).toBe('agent-relay'); const codex = fs.readFileSync(path.join(project, '.codex', 'config.toml'), 'utf8'); @@ -513,7 +630,7 @@ describe('relay init agent tools (non-interactive)', () => { return; // git is not installed here. } const { run } = setup(); - await run('--tools', 'claude'); + await run('--tools', 'claude', '--scope', 'project'); expect(fs.existsSync(path.join(project, '.agentworkforce', 'relay', 'workspace-key.json'))).toBe(true); expect(fs.readFileSync(path.join(project, '.git', 'info', 'exclude'), 'utf8')).toContain( '/.agentworkforce/relay/' @@ -557,10 +674,10 @@ describe('relay init agent tools (non-interactive)', () => { it('re-running updates its entry instead of duplicating it', async () => { const first = setup(); - await first.run('--tools', 'claude'); + await first.run('--tools', 'claude', '--scope', 'project'); const before = fs.readFileSync(path.join(project, '.mcp.json'), 'utf8'); const { run, logs } = setup(); - await run('--tools', 'claude'); + await run('--tools', 'claude', '--scope', 'project'); expect(fs.readFileSync(path.join(project, '.mcp.json'), 'utf8')).toBe(before); expect(logs.join('\n')).toContain('already set up'); }); @@ -578,7 +695,7 @@ describe('relay init agent tools (non-interactive)', () => { it('never prompts with --yes, even in a terminal', async () => { const prompter = vi.fn(); const { run } = setup({ isInteractive: () => true, prompter: prompter as never }); - await run('--yes', '--tools', 'claude'); + await run('--yes', '--tools', 'claude', '--scope', 'project'); expect(prompter).not.toHaveBeenCalled(); expect(mcpJson().mcpServers).toHaveProperty('agent-relay'); }); @@ -642,7 +759,7 @@ describe('relay init guided flow', () => { true, ]); const { run, createWorkspace } = setup({ isInteractive: () => true, prompter: () => script.prompter }); - await run('--delivery', 'mcp'); + await run('--interactive', '--delivery', 'mcp'); expect(script.asked).toEqual([ 'Where should your agents talk?', @@ -675,7 +792,7 @@ describe('relay init guided flow', () => { it('leaves Codex trust alone when the person declines, and says what is left to do', async () => { const script = scriptedPrompter([true, false]); const { run } = setup({ isInteractive: () => true, prompter: () => script.prompter }); - await run('--hosted', '--tools', 'codex', '--scope', 'project'); + await run('--interactive', '--hosted', '--tools', 'codex', '--scope', 'project'); expect(script.asked).toHaveLength(2); expect(fs.existsSync(path.join(toolHome, '.codex', 'config.toml'))).toBe(false); expect(script.shown.join('\n')).toContain('not trusted yet'); @@ -684,14 +801,14 @@ describe('relay init guided flow', () => { it('does not ask about Codex trust at global scope', async () => { const script = scriptedPrompter([true]); const { run } = setup({ isInteractive: () => true, prompter: () => script.prompter }); - await run('--hosted', '--tools', 'codex', '--scope', 'global'); + await run('--interactive', '--hosted', '--tools', 'codex', '--scope', 'global'); expect(script.asked).toEqual(['Proceed with installation?']); }); it('only asks what the flags did not answer', async () => { const script = scriptedPrompter([true]); const { run } = setup({ isInteractive: () => true, prompter: () => script.prompter }); - await run('--hosted', '--tools', 'grok', '--scope', 'global'); + await run('--interactive', '--hosted', '--tools', 'grok', '--scope', 'global'); expect(script.asked).toEqual(['Proceed with installation?']); expect(fs.readFileSync(path.join(toolHome, '.grok', 'config.toml'), 'utf8')).toContain('enabled = true'); }); @@ -699,7 +816,7 @@ describe('relay init guided flow', () => { it('stops without installing when the person cancels', async () => { const script = scriptedPrompter(['local', ['claude'], 'project', CANCELLED]); const { run } = setup({ isInteractive: () => true, prompter: () => script.prompter }); - await run('--delivery', 'mcp'); + await run('--interactive', '--delivery', 'mcp'); expect(script.shown.join('\n')).toContain('cancel Setup cancelled'); expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); }); @@ -707,7 +824,7 @@ describe('relay init guided flow', () => { it('shows the CLI fallback when no agent is chosen', async () => { const script = scriptedPrompter(['local', []]); const { run } = setup({ isInteractive: () => true, prompter: () => script.prompter }); - await run(); + await run('--interactive'); expect(script.shown.join('\n')).toContain('relay agent register alice'); expect(script.asked).not.toContain('Installation scope'); }); @@ -887,6 +1004,8 @@ function fakeCore( settings?: Record; workspaceName?: string; workspaceId?: string; + /** The Relaycast server the core's workspace is on. */ + baseUrl?: string; /** Settings the core refuses to turn on. */ refuse?: string[]; /** Claude settings files the core's direct delivery writes, as `set_direct_delivery` does. */ @@ -917,7 +1036,7 @@ function fakeCore( const calls: Array<[string, unknown]> = []; const workspace = { state: 'found', - base_url: 'http://127.0.0.1:8787', + base_url: options.baseUrl ?? 'http://127.0.0.1:8787', workspace_name: options.workspaceName ?? 'local', workspace_id: options.workspaceId ?? 'ws_1', verification: 'verified', @@ -1104,12 +1223,12 @@ describe('relay init message injection', () => { it('removes the MCP entry an earlier relay init added, so each session has one identity', async () => { const mcp = setup(); - await mcp.run('--tools', 'claude', '--delivery', 'mcp'); + await mcp.run('--tools', 'claude', '--delivery', 'mcp', '--scope', 'project'); expect(mcpJson().mcpServers).toHaveProperty('agent-relay'); installedProbe(); const { run, logs } = setup(fakeCore().deps); - await run('--tools', 'claude'); + await run('--tools', 'claude', '--scope', 'project'); expect(fs.existsSync(path.join(project, '.mcp.json'))).toBe(false); expect(logs.join('\n')).toMatch(/Claude Code\s+removed the earlier Relay MCP entry from \.mcp\.json/); }); @@ -1203,7 +1322,7 @@ describe('relay init message injection', () => { }; }), }); - await expect(run('--tools', 'claude,grok')).resolves.toBeDefined(); + await expect(run('--tools', 'claude,grok', '--scope', 'project')).resolves.toBeDefined(); const output = logs.join('\n'); expect(output).toMatch(/Grok CLI\s+not injected: Grok is not installed\./); expect(fs.readFileSync(path.join(project, '.grok', 'config.toml'), 'utf8')).toContain('agent-relay'); @@ -1440,7 +1559,7 @@ describe('relay init message injection', () => { // A directory where the record goes: writing it fails. fs.mkdirSync(path.join(home, 'local', 'core-settings.json'), { recursive: true }); const { run, logs } = setup(core.deps); - await run('--tools', 'codex'); + await run('--tools', 'codex', '--scope', 'project'); expect(core.settings).toMatchObject({ codex_stop_hook: false, auto_activate: false }); expect(logs.join('\n')).toContain( 'Message injection is not available (the Agent Relay core could not be set up' @@ -1481,7 +1600,7 @@ describe('relay init message injection', () => { fs.mkdirSync(path.dirname(claudeSettings), { recursive: true }); fs.writeFileSync(claudeSettings, JSON.stringify({ theme: 'dark' })); const { run, logs } = setup(core.deps); - await run('--tools', 'claude'); + await run('--tools', 'claude', '--scope', 'project'); expect(core.settings).toMatchObject({ direct_delivery: false, claude_session_hook: false, @@ -1496,7 +1615,7 @@ describe('relay init message injection', () => { it('turns off what it injected before adding the MCP server for --delivery mcp', async () => { installedProbe(); const core = fakeCore({ running: true }); - await setup(core.deps).run('--tools', 'claude,codex'); + await setup(core.deps).run('--tools', 'claude,codex', '--scope', 'project'); expect(core.settings).toMatchObject({ auto_activate: true, claude_session_hook: true, @@ -1504,7 +1623,7 @@ describe('relay init message injection', () => { }); const toMcp = setup(core.deps); - await toMcp.run('--tools', 'claude', '--delivery', 'mcp'); + await toMcp.run('--tools', 'claude', '--delivery', 'mcp', '--scope', 'project'); // Claude leaves injection; Codex keeps it, and so automatic activation stays. expect(core.settings).toMatchObject({ auto_activate: true, @@ -1520,7 +1639,7 @@ describe('relay init message injection', () => { turnedOn: ['codex_stop_hook', 'auto_activate'], }); - await setup(core.deps).run('--tools', 'codex', '--delivery', 'mcp'); + await setup(core.deps).run('--tools', 'codex', '--delivery', 'mcp', '--scope', 'project'); expect(core.settings).toMatchObject({ auto_activate: false, codex_stop_hook: false }); expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); }); @@ -1604,6 +1723,34 @@ describe('relay init message injection', () => { expect(unknown.logs.join('\n')).toContain('Could not confirm that the Agent Relay core serves "c"'); }); + // Review on #2028: a joined hosted workspace's name is the person's, so a + // core serving another hosted workspace of the same name is not taken + // for it when neither side names a workspace id. + it('does not confirm a joined hosted workspace by its name alone', async () => { + installedProbe(); + const core = fakeCore({ + running: true, + baseUrl: 'https://cast.agentrelay.com', + workspaceName: 'team', + workspaceId: '', + }); + core.settings.auto_activate = true; + const { run, logs } = setup(core.deps); + await run( + '--url', + 'https://cast.agentrelay.com', + '--key', + 'rk_live_other_team', + '--name', + 'team', + '--tools', + 'codex' + ); + const output = logs.join('\n'); + expect(output).toContain('Joined hosted workspace "team"'); + expect(output).toContain('Could not confirm that the Agent Relay core serves "team"'); + }); + it('restores crossSessionInbound on every Claude login the core changed', async () => { installedProbe(); const own = path.join(toolHome, '.claude', 'settings.json'); @@ -1732,7 +1879,7 @@ describe('relay init message injection', () => { throw new Error('spawn EACCES'); }), }); - await run('--tools', 'claude'); + await run('--tools', 'claude', '--scope', 'project'); expect(logs.join('\n')).toContain('the Agent Relay core could not be started (spawn EACCES)'); expect(mcpJson().mcpServers).toHaveProperty('agent-relay'); }); @@ -1740,12 +1887,12 @@ describe('relay init message injection', () => { it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( 'says so when an earlier MCP entry could not be removed', async () => { - await setup().run('--tools', 'claude', '--delivery', 'mcp'); + await setup().run('--tools', 'claude', '--delivery', 'mcp', '--scope', 'project'); installedProbe(); fs.chmodSync(project, 0o555); try { const { run, logs } = setup(fakeCore().deps); - await run('--tools', 'claude'); + await run('--tools', 'claude', '--scope', 'project'); expect(logs.join('\n')).toMatch( /Claude Code\s+could not remove the earlier Relay MCP entry from \.mcp\.json[^]*second identity/ ); @@ -1757,7 +1904,7 @@ describe('relay init message injection', () => { it('falls back to the MCP server when the core cannot be had', async () => { const { run, logs } = setup(); - await run('--tools', 'claude'); + await run('--tools', 'claude', '--scope', 'project'); expect(logs.join('\n')).toContain('Message injection is not available (offline)'); expect(mcpJson().mcpServers).toHaveProperty('agent-relay'); }); @@ -1799,7 +1946,7 @@ describe('relay init message injection', () => { const core = fakeCore(); const script = scriptedPrompter([['claude'], true]); const { run } = setup({ ...core.deps, isInteractive: () => true, prompter: () => script.prompter }); - await run('--hosted'); + await run('--interactive', '--hosted'); expect(script.asked).toEqual(['Which agents should get Relay?', 'Proceed with installation?']); const shown = script.shown.join('\n'); expect(shown).toContain("Claude Code Claude Code's session inbox"); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index e636df625..fbd20eade 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -73,7 +73,7 @@ import { type DeliveryRecord, type DeliveryResult, } from '../lib/probe-core.js'; -import { shellQuote } from '../lib/agent-registration.js'; +import { shellQuote, shellWord } from '../lib/agent-registration.js'; import { claudeFileStates, openCodeConfigDir, @@ -428,7 +428,7 @@ async function verifyWorkspace( deps: InitCommandDependencies, origin: string, key: string -): Promise { +): Promise { let response: Response; try { response = await deps.fetch(new URL('/v1/workspace', origin), { @@ -448,17 +448,28 @@ async function verifyWorkspace( throw new WorkspaceKeyRejectedError(`${origin} rejected the workspace key.`); } if (!response.ok) throw new Error(`${origin} answered ${response.status} when checking the workspace.`); - return workspaceIdIn(response); + return workspaceInfoIn(response); +} + +/** What `GET /v1/workspace` says about a key's workspace, when it says. */ +interface WorkspaceInfo { + id?: string; + name?: string; } -/** The `id` of a `GET /v1/workspace` answer (`{ok, data: {id}}`), read leniently. */ -async function workspaceIdIn(response: Response): Promise { +/** The `id` and `name` of a `GET /v1/workspace` answer (`{ok, data: {id, name}}`), read leniently. */ +async function workspaceInfoIn(response: Response): Promise { + const text = (value: unknown): string | undefined => + typeof value === 'string' && value.trim() ? value.trim() : undefined; try { - const body = (await response.json()) as { data?: { id?: unknown }; id?: unknown } | null; - const id = body?.data?.id ?? body?.id; - return typeof id === 'string' && id.trim() ? id.trim() : undefined; + const body = (await response.json()) as { + data?: { id?: unknown; name?: unknown }; + id?: unknown; + name?: unknown; + } | null; + return { id: text(body?.data?.id ?? body?.id), name: text(body?.data?.name ?? body?.name) }; } catch { - return undefined; + return {}; } } @@ -468,7 +479,10 @@ async function workspaceIdOf( origin: string, key: string ): Promise { - return verifyWorkspace(deps, origin, key).catch(() => undefined); + return verifyWorkspace(deps, origin, key).then( + (workspace) => workspace.id, + () => undefined + ); } function parsePort(value: string): number { @@ -517,16 +531,47 @@ interface ServerSetup { keptActive?: string; } -/** A word as the shell reads it: quoted unless it is plainly safe to paste. */ -function shellWord(value: string): string { - return /^[A-Za-z0-9._@%+=:,/-]+$/.test(value) ? value : shellQuote(value); -} - /** The command that makes `name` the machine-wide active workspace. */ function switchCommand(name: string): string { return `relay workspace switch ${shellWord(name)}`; } +/** + * The npm package spec a teammate's `npx` runs: the `next` dist-tag for a + * `-next` prerelease (or a build that is not a published version), else + * this exact version. + */ +function teammatePackage(version: string | undefined): string { + const published = version?.trim(); + if (!published || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(published) || published.includes('-next')) { + return 'agent-relay@next'; + } + return `agent-relay@${published}`; +} + +/** + * The one command a teammate runs in their project to join a hosted + * workspace, Relay installed or not: it saves the workspace (named as the + * server names it), starts the Agent Relay core and turns on message + * injection for their agent tools. `relay workspace join` only saves it. + */ +function joinCommand(key: string, version: string | undefined): string { + return `npx -y ${teammatePackage(version)} init --key ${shellQuote(key)}`; +} + +/** + * A free name for a workspace joined by key: `name`, unless a saved + * workspace under it holds another key, then `name-2` and on. + */ +function freeWorkspaceName(name: string, key: string): string { + const saved = readWorkspaceStore().workspaces; + const free = (candidate: string) => !saved[candidate] || saved[candidate]?.key === key; + if (free(name)) return name; + for (let n = 2; ; n += 1) { + if (free(`${name}-${n}`)) return `${name}-${n}`; + } +} + /** * Save the workspace and bind it to this project. It becomes the machine-wide * active workspace only when no other one is: a machine already serving a @@ -566,20 +611,83 @@ function globalScopeNote(setup: ServerSetup, scope: InstallScope, installed: Too ); } +/** A workspace name part: lowercase letters, digits and single dashes. */ +function nameSlug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * The name `relay init --hosted` gives a new workspace without `--name`: + * the person and the project folder (`alice-api`), with a numeric suffix + * when a saved workspace already has that name, so a second hosted + * workspace never replaces the first in the store. + */ +function defaultHostedName(projectRoot: string): string { + let user = ''; + try { + user = os.userInfo().username; + } catch { + // No passwd entry (some containers): the folder alone. + } + const base = + [nameSlug(user), nameSlug(path.basename(projectRoot))].filter(Boolean).join('-').slice(0, 40) || 'relay'; + const taken = new Set(Object.keys(readWorkspaceStore().workspaces)); + if (!taken.has(base)) return base; + for (let n = 2; ; n += 1) { + if (!taken.has(`${base}-${n}`)) return `${base}-${n}`; + } +} + /** * Connect to (or start) the server and save its workspace for this project. * This is the original `relay init`; the agent-tool setup comes after it. */ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): Promise { if (choice.mode === 'own') { - const { url, key } = choice; - if (!url || !key) throw new Error('Pass --url and --key together for your own server.'); + const { key } = choice; + if (!key) throw new Error('Pass --key with --url for your own server.'); + // `--key` alone joins the hosted service. A key already bound to a + // self-hosted server is never sent there: only an explicit --url moves it. + const url = choice.url ?? HOSTED_ORIGIN; + const bound = choice.url ? undefined : boundOrigin(key); + if (bound && new URL(bound).origin !== HOSTED_ORIGIN) { + throw new Error( + `This workspace key belongs to ${new URL(bound).origin}, so relay init will not send it to the hosted ` + + `service. To use it there, run: relay init --url ${shellWord(new URL(bound).origin)} --key ` + ); + } const origin = configuredOrigin(url, '--url'); if (!origin) throw new Error('--url is empty.'); - const workspaceId = await verifyWorkspace(deps, origin, key); + const workspace = await verifyWorkspace(deps, origin, key); + const workspaceId = workspace.id; guardCredentialOrigin(key, url); - const name = choice.name ?? new URL(origin).hostname; + const hosted = origin === HOSTED_ORIGIN; + // Named as the person says, else as the server names the workspace. + const name = + choice.name ?? + freeWorkspaceName( + workspace.name ?? + (hosted ? defaultHostedName(deps.toolContext().projectRoot) : new URL(origin).hostname), + key + ); const { lines, ...saved } = saveWorkspace(name, key); + // A teammate joining a hosted workspace by key: the hosted service, not + // a server of their own. It stays an `own` setup: init did not create + // the workspace, so its name never stands in for a workspace id when + // checking which workspace the core serves (`sameWorkspace`). + if (hosted) { + return { + ...saved, + workspaceId, + mode: 'own', + origin, + headline: `Joined hosted workspace "${name}" (${origin}).`, + details: ['Your key is only ever sent to the hosted service.', ...lines], + }; + } return { ...saved, workspaceId, @@ -591,7 +699,7 @@ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): } if (choice.mode === 'hosted') { - const name = choice.name ?? `relay-${os.hostname().split('.')[0]}`; + const name = choice.name ?? defaultHostedName(deps.toolContext().projectRoot); const relay = await deps.createWorkspace(name, HOSTED_ORIGIN); if (!relay.workspaceKey) throw new Error('The hosted service did not return a workspace key.'); const { lines, ...saved } = saveWorkspace(name, relay.workspaceKey); @@ -602,9 +710,11 @@ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): origin: HOSTED_ORIGIN, headline: `Created hosted workspace "${name}".`, details: [ + ...(choice.name ? [] : [`Named after you and this folder; pass --name to choose another.`]), ...lines, - 'On another machine, join it with (treat this key like a password):', - ` relay workspace join ${name} ${relay.workspaceKey}`, + 'On another machine, run this in the project to join it and connect its agent tools ' + + '(treat this key like a password):', + ` ${joinCommand(relay.workspaceKey, deps.cliVersion)}`, ], }; } @@ -619,11 +729,14 @@ async function setupServer(deps: InitCommandDependencies, choice: ServerChoice): // Only a key the server refuses is replaced. A timeout or server // error is reported instead, so a hiccup never swaps the workspace // (and its agents and messages) for a new one. - workspaceId = await verifyWorkspace(deps, origin, workspaceKey).catch((error: unknown) => { - if (!(error instanceof WorkspaceKeyRejectedError)) throw error; - workspaceKey = undefined; - return undefined; - }); + workspaceId = await verifyWorkspace(deps, origin, workspaceKey).then( + (workspace) => workspace.id, + (error: unknown) => { + if (!(error instanceof WorkspaceKeyRejectedError)) throw error; + workspaceKey = undefined; + return undefined; + } + ); } if (!workspaceKey) { assertOriginBindingsWritable(origin); @@ -1483,15 +1596,17 @@ interface InitOptions { tools?: ToolId[]; scope?: InstallScope; delivery?: Delivery; + /** Accepted for older commands; asking nothing is already the default. */ yes?: boolean; + interactive?: boolean; uninstall?: boolean; } function serverChoiceFromFlags(o: InitOptions): ServerChoice | undefined { - if (Boolean(o.url) !== Boolean(o.key)) - throw new Error('Pass --url and --key together for your own server.'); + if (o.url && !o.key) throw new Error('Pass --key with --url for your own server.'); if (o.url && o.hosted) throw new Error('Choose either --hosted or --url/--key.'); - if (o.url) return { mode: 'own', url: o.url, key: o.key, name: o.name, port: o.port }; + // A key joins its workspace: on --url's server, else on the hosted service. + if (o.key) return { mode: 'own', url: o.url, key: o.key, name: o.name, port: o.port }; if (o.hosted) return { mode: 'hosted', name: o.name, port: o.port }; return undefined; } @@ -1616,7 +1731,7 @@ async function runPlain(deps: InitCommandDependencies, o: InitOptions): Promise< const ctx = deps.toolContext(); const tools = o.tools ?? detectTools(ctx).map((tool) => tool.id); - const scope = o.scope ?? 'project'; + const scope = o.scope ?? 'global'; const plan = await planDelivery(deps, tools, o.delivery ?? 'inject', setup, ctx); if (plan.injection) { deps.log(''); @@ -1656,6 +1771,29 @@ async function runPlain(deps: InitCommandDependencies, o: InitOptions): Promise< if (plan.injected.length > 0) for (const line of injectionNextStep(plan.injected)) deps.log(` ${line}`); for (const line of installed.length > 0 ? nextStep(installed, scope, ctx) : []) deps.log(` ${line}`); for (const line of CLI_FALLBACK) deps.log(` ${line}`); + deps.log(''); + for (const line of choicesSummary(o, setup, tools, scope)) deps.log(line); +} + +/** + * What a `relay init` that asked nothing chose, each with the flag that + * changes it, so the defaults are never a surprise. + */ +function choicesSummary(o: InitOptions, setup: ServerSetup, tools: ToolId[], scope: InstallScope): string[] { + const where = + setup.mode === 'local' + ? 'this machine (--hosted for agents on other machines, or --url and --key for your own server)' + : setup.mode === 'hosted' && !o.url + ? `hosted workspace "${setup.workspace}"${o.name ? '' : ' (--name to name it)'}` + : `workspace "${setup.workspace}" on ${setup.origin}`; + const toolList = tools.length > 0 ? tools.join(', ') : 'none'; + return [ + 'Chosen without asking:', + ` Server: ${where}`, + ` Tools: ${toolList}${o.tools ? '' : ' (the ones installed here; --tools to choose)'}`, + ` Scope: ${scope}${o.scope ? '' : ' (--scope project for this project only)'}`, + 'To choose each step yourself, run: relay init --interactive', + ]; } /** @@ -1803,10 +1941,9 @@ async function runGuided(deps: InitCommandDependencies, o: InitOptions): Promise ]; p.note(summary.join('\n'), 'Installation Summary'); if (delivery === 'mcp' && launch.warning) p.warn(launch.warning); - if (!o.yes) { - const proceed = await p.confirm({ message: 'Proceed with installation?', initialValue: true }); - if (proceed === CANCELLED || !proceed) return cancelled(); - } + // `--interactive` asks to the end; `--yes` changes nothing here either. + const proceed = await p.confirm({ message: 'Proceed with installation?', initialValue: true }); + if (proceed === CANCELLED || !proceed) return cancelled(); const injecting = p.spinner(); if (delivery === 'inject') injecting.start('Setting up message injection'); @@ -1863,8 +2000,10 @@ function parseDelivery(value: string): Delivery { * machine by default, the hosted service, or your own server), then connect * your agent tools. By default the Agent Relay core injects messages into * their running sessions; `--delivery mcp` (or a machine the core cannot run - * on) uses the Relay MCP server instead. In a terminal it asks; with `--yes` - * or without a TTY it uses flags and defaults. + * on) uses the Relay MCP server instead. It asks nothing: flags, then + * defaults (this machine, every installed tool, global scope), and a summary + * of what it chose. `--interactive` asks each step in a terminal; `--yes` is + * accepted and changes nothing. * * @param program - The commander program to add the command to. * @param overrides - Dependencies to replace, typically in tests. @@ -1877,14 +2016,15 @@ export function registerInitCommand( program .command('init') .description( - 'Set up Relay and connect your agent tools: a private server on this machine (default), the hosted ' + - 'service (--hosted), or your own server (--url and --key); then the Agent Relay core injects messages ' + - 'into running Claude Code, Codex, OpenCode, Grok, Pi and Devin sessions' + 'Set up Relay and connect your agent tools without asking: a private server on this machine (default), ' + + 'the hosted service (--hosted), or your own server (--url and --key), every installed agent tool, and ' + + 'the Agent Relay core injecting messages into running Claude Code, Codex, OpenCode, Grok, Pi and Devin ' + + 'sessions. --interactive asks each step instead' ) .option('--hosted', 'Use the hosted service so agents on other machines can join') - .option('--url ', 'Your own Relaycast server') - .option('--key ', 'Workspace key for your own server') - .option('--name ', 'Workspace name to save this as') + .option('--url ', 'Your own Relaycast server (with --key)') + .option('--key ', 'Join the workspace this key belongs to: hosted, or on --url') + .option('--name ', 'Workspace name to save this as (default: the one its server names)') .option('--port ', 'Port for the local server', parsePort, DEFAULT_LOCAL_PORT) .option( '--tools ', @@ -1899,10 +2039,14 @@ export function registerInitCommand( ) .option( '--scope ', - 'Where the Relay MCP server goes: project or global (injection is always per user)', + 'Where the Relay MCP server goes: global (default) or project (injection is always per user)', parseScope ) - .option('-y, --yes', 'Do not ask; use flags and defaults') + .option( + '-i, --interactive', + 'Ask where agents talk, which agent tools to connect and where the MCP server goes' + ) + .option('-y, --yes', 'Accepted for older scripts: relay init asks nothing unless --interactive') .option( '--uninstall', 'Remove the Relay MCP entries relay init added (both scopes unless --scope) and turn off the injection it turned on' @@ -1912,7 +2056,14 @@ export function registerInitCommand( await runSdk(deps, async () => { if (o.stop) return stopLocalServer(deps); if (o.uninstall) return uninstall(deps, o); - if (!o.yes && deps.isInteractive()) return runGuided(deps, o); + if (o.interactive) { + if (!deps.isInteractive()) { + throw new Error( + 'relay init --interactive needs a terminal; run relay init without it to use the defaults.' + ); + } + return runGuided(deps, o); + } return runPlain(deps, o); }); }); diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index ddad0b885..b1163f67e 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -1,5 +1,10 @@ import { Command } from 'commander'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { bindOrigin } from '../lib/relaycast-origin-binding.js'; vi.mock('@agent-relay/cloud', () => ({ readWorkspaceStore: vi.fn(() => ({ workspaces: {} })), @@ -255,6 +260,39 @@ describe('registerWorkspaceCommands', () => { expect(switchWorkspace).not.toHaveBeenCalled(); }); + it('workspace join points at relay init for injection without echoing the key', async () => { + vi.stubEnv('RELAY_BASE_URL', ''); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'join', 'shared', 'rk_live_shared']); + + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('relay init --key --name shared'); + expect(output).not.toContain('rk_live_shared'); + vi.unstubAllEnvs(); + }); + + // Review on #2028: a key already bound to a self-hosted server is never + // pointed at the hosted service, and a name zsh would expand is quoted. + it('workspace join names the server the key is bound to', async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-join-home-')); + vi.stubEnv('AGENT_RELAY_HOME', home); + try { + bindOrigin('rk_live_corp', 'https://relay.corp.test:8443'); + vi.stubEnv('RELAY_BASE_URL', ''); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'join', '=corp', 'rk_live_corp']); + + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain("relay init --url https://relay.corp.test:8443 --key --name '=corp'"); + expect(output).not.toContain('cast.agentrelay.com'); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it('workspace switch reports an enrolled fleet node dropped by the move', async () => { vi.mocked(readWorkspaceStore).mockReturnValue({ active: 'default', diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index 380076c4a..5555acbc6 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -2,12 +2,14 @@ import type { Command } from 'commander'; import { InvalidArgumentError } from 'commander'; import { resolveActiveWorkspace } from '@agent-relay/cloud'; +import { shellWord } from '../lib/agent-registration.js'; import { maskSecret } from '../lib/redact.js'; import { printJson, runSdk, withSdkDefaults, type SdkCommandDeps } from '../lib/sdk-command.js'; import { readWorkspaceStore, setWorkspaceKey } from '../lib/workspace-store.js'; import { assertOriginBindingsWritable, configuredBaseUrl, + boundOrigin, guardCredentialOrigin, } from '../lib/relaycast-origin-binding.js'; import { @@ -45,6 +47,24 @@ function bindKeyToConfiguredServer(key: string): void { } } +const HOSTED_ORIGIN = 'https://cast.agentrelay.com'; + +/** + * The `--url` a joined key needs in `relay init`: the configured server, + * else the server the key is already bound to; none for the hosted service, + * which `--key` alone joins. A self-hosted key is never pointed at it. + */ +function joinServerFlag(key: string): string { + const configured = process.env.RELAY_BASE_URL?.trim() || boundOrigin(key); + if (!configured) return ''; + try { + if (new URL(configured).origin === HOSTED_ORIGIN) return ''; + } catch { + // Not a URL: shown as given, for the person to correct. + } + return `--url ${shellWord(configured)} `; +} + function parsePositiveInteger(value: string): number { const parsed = Number.parseInt(value, 10); if (!Number.isInteger(parsed) || parsed <= 0) { @@ -212,8 +232,15 @@ export function registerWorkspaceCommands( .action(async (name: string, key: string) => { await runSdk(deps, async () => { bindKeyToConfiguredServer(key); + // Read before anything changes, so a bindings file that cannot be + // read stops the join instead of interrupting it. + const serverFlag = joinServerFlag(key); const result = persistWorkspaceSession({ name, workspaceKey: key }); deps.log(`Joined and switched to workspace "${name}".`); + deps.log( + 'This only saves the workspace. To connect agent tools and have messages injected into their ' + + `sessions, run in the project: relay init ${serverFlag}--key --name ${shellWord(name)}` + ); reportClearedEnrollment(result, deps); }); }); diff --git a/packages/cli/src/cli/lib/agent-registration.ts b/packages/cli/src/cli/lib/agent-registration.ts index f8883b9d5..4ab719140 100644 --- a/packages/cli/src/cli/lib/agent-registration.ts +++ b/packages/cli/src/cli/lib/agent-registration.ts @@ -21,6 +21,16 @@ export function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } +/** + * A word as the shell reads it: quoted unless it is plainly safe to paste. + * A leading `=` is quoted too, since zsh expands `=cmd` to a path. + * @param value - The word to print in a copy-pasteable command. + * @returns The word, single-quoted when it holds anything else. + */ +export function shellWord(value: string): string { + return /^[A-Za-z0-9._@%+:,/-][A-Za-z0-9._@%+=:,/-]*$/.test(value) ? value : shellQuote(value); +} + /** * Bound an arbitrary async operation so a hung upstream call cannot hang the * caller forever. `buildTimeoutError` receives the normalized timeout so