diff --git a/CHANGELOG.md b/CHANGELOG.md index c91b26f17..34f1f0f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `relay init` lets two agents communicate without an account: it starts a private Relaycast server on this machine and creates a workspace. `relay init --hosted` creates a hosted workspace and prints the one command other machines run to join, and `relay init --url --key ` connects to your own server on any HTTPS port. See `docs/quickstart.md`. - `relay agent register` saves each agent's token, and `--as ` uses it on any messaging command, so agents no longer need `RELAY_AGENT_TOKEN` exported in every shell. - `relay init` is now a guided setup in a terminal: it asks where agents talk (this machine, hosted, or your own server), which agent tools to connect (Claude Code, Codex, OpenCode, Grok CLI, Pi and Devin, with the installed ones pre-checked), and whether to configure this project or every project, then adds the Relay MCP server to each tool through its own `mcp add` command or config file. `--tools`, `--scope` and `--yes` run it without prompts (the default when stdin is not a terminal), and `relay init --uninstall` removes only the entries it added. +- `relay init` installs the `messaging-agents-on-the-relay` agent skill globally for every tool it injects into (Claude Code, Codex, OpenCode, Pi, Grok, Devin), so a new session can list who is on Agent Relay and message them with no setup. It is pinned to a commit of AgentWorkforce/skills and installed with `npx skills` without prompting. A copy already there is left alone, and a failed install only prints a warning. `relay init --uninstall` removes only the copies init installed, unless they were changed since. ### Changed diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 8fb23bf73..5af2eab3e 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -16,6 +16,14 @@ import { registerAgentCommands } from './agent.js'; import { DownloadHttpError, engineAssetName } from '../lib/relaycast-engine-install.js'; import { LOCAL_ENGINE_VERSION, registerInitCommand, type InitCommandDependencies } from './init.js'; import { PROBE_VERSION, type CoreClient } from '../lib/probe-core.js'; +import { + RELAY_SKILL_NAME, + RELAY_SKILL_REF, + SKILLS_CLI_PACKAGE, + defaultRunSkillsCli, + type RelaySkillRecord, + type RunSkillsCli, +} from '../lib/relay-skill.js'; import { getProjectPaths } from '@agent-relay/config'; let home: string; @@ -109,6 +117,8 @@ function setup(overrides: Partial = {}) { throw new Error('offline'); }), probeVersion: vi.fn(() => undefined), + // Offline: the messaging skill cannot be fetched unless a test says so. + runSkillsCli: vi.fn(() => ({ status: 1, output: 'offline' })), ...overrides, }; const program = new Command().exitOverride(); @@ -1956,6 +1966,607 @@ describe('relay init message injection', () => { }); }); +/** + * The `skills` CLI as `add --global --copy` runs it: one copy per agent's + * global skills folder (Codex, OpenCode and Pi share ~/.agents/skills) and + * the skill's entry in its lock file. + */ +function fakeSkillsCli(status = 0, lockFile?: string) { + const dirs: Record = { + 'claude-code': path.join(toolHome, '.claude', 'skills'), + codex: path.join(toolHome, '.agents', 'skills'), + opencode: path.join(toolHome, '.agents', 'skills'), + pi: path.join(toolHome, '.agents', 'skills'), + grok: path.join(toolHome, '.grok', 'skills'), + devin: path.join(toolHome, '.config', 'devin', 'skills'), + }; + const lock = lockFile ?? path.join(toolHome, '.agents', '.skill-lock.json'); + const state = { status, content: '---\nname: messaging-agents-on-the-relay\n---\n' }; + const lockIn = (env: NodeJS.ProcessEnv) => + env.XDG_STATE_HOME ? path.join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json') : lock; + const run = vi.fn((args, options) => { + if (state.status !== 0) return { status: state.status, output: 'fatal: unable to access github.com' }; + args.forEach((arg, i) => { + if (args[i - 1] !== '--agent') return; + const dir = path.join(dirs[arg]!, RELAY_SKILL_NAME); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), state.content); + }); + let current = { version: 3, skills: {} as Record }; + try { + current = JSON.parse(fs.readFileSync(lockIn(options.env), 'utf8')); + } catch { + // No lock file yet. + } + current.skills[RELAY_SKILL_NAME] = { source: 'AgentWorkforce/skills', ref: RELAY_SKILL_REF }; + fs.mkdirSync(path.dirname(lockIn(options.env)), { recursive: true }); + fs.writeFileSync(lockIn(options.env), JSON.stringify(current)); + return { status: 0, output: 'Done!' }; + }); + return { + run, + lock, + dirs, + fail: (next: number) => (state.status = next), + write: (content: string) => (state.content = content), + }; +} + +/** Rewrite the recorded skill install, as an older init or a forged file would have left it. */ +function editSkillRecord(change: (record: RelaySkillRecord) => void): void { + const file = path.join(home, 'local', 'core-settings.json'); + const settings = JSON.parse(fs.readFileSync(file, 'utf8')); + change(settings.relaySkill); + fs.writeFileSync(file, JSON.stringify(settings)); +} + +/** Every file and folder under the tools' home, relative to it. */ +function toolHomeTree(): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + out.push(path.relative(toolHome, full)); + if (entry.isDirectory()) walk(full); + } + }; + walk(toolHome); + return out.sort(); +} + +describe('relay init messaging skill', () => { + it('installs the pinned skill globally for each injected tool, records it, and --uninstall removes exactly that', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + const { run, logs } = setup({ ...core.deps, runSkillsCli: skills.run }); + await run('--tools', 'claude,codex'); + + expect(skills.run).toHaveBeenCalledTimes(1); + const [args, options] = skills.run.mock.calls[0]!; + // Pinned to a commit of AgentWorkforce/skills, never a moving branch. + expect(args).toEqual([ + 'add', + `https://github.com/AgentWorkforce/skills#${RELAY_SKILL_REF}`, + '--skill', + RELAY_SKILL_NAME, + '--global', + '--copy', + '--yes', + '--agent', + 'claude-code', + '--agent', + 'codex', + ]); + expect(RELAY_SKILL_REF).toMatch(/^[0-9a-f]{40}$/); + expect(SKILLS_CLI_PACKAGE).toMatch(/^skills@\d+\.\d+\.\d+$/); + expect(options.env).toMatchObject({ HOME: toolHome, DISABLE_TELEMETRY: '1', DO_NOT_TRACK: '1' }); + expect(logs.join('\n')).toContain( + `✓ Agent skill installed ${RELAY_SKILL_NAME} for Claude Code, Codex` + ); + const record = JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8')); + expect(record.relaySkill).toMatchObject({ + copies: [ + { path: path.join(skills.dirs['claude-code']!, RELAY_SKILL_NAME), ref: RELAY_SKILL_REF }, + { path: path.join(skills.dirs.codex!, RELAY_SKILL_NAME), ref: RELAY_SKILL_REF }, + ], + locks: [{ file: skills.lock, created: true, original: null, ref: RELAY_SKILL_REF }], + }); + + // A re-run with the same pin changes nothing and fetches nothing. + const again = setup({ ...core.deps, runSkillsCli: skills.run }); + await again.run('--tools', 'claude,codex'); + expect(skills.run).toHaveBeenCalledTimes(1); + expect(again.logs.join('\n')).toContain(`installed for Claude Code, Codex`); + + const off = setup(core.deps); + await off.run('--uninstall'); + expect(off.logs.join('\n')).toContain(`removed the ${RELAY_SKILL_NAME} skill relay init installed`); + // The skill, its lock entry and the folders made for them are gone; only the core's own files remain. + expect(toolHomeTree().filter((entry) => entry.includes('skills') || entry.startsWith('.agents'))).toEqual( + [] + ); + expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); + }); + + it('installs without asking anything, even in a terminal, and prints one line', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + const script = scriptedPrompter([]); + const { run, logs } = setup({ + ...core.deps, + runSkillsCli: skills.run, + isInteractive: () => true, + prompter: () => script.prompter, + }); + await run('--tools', 'codex'); + expect(script.asked).toEqual([]); + expect(skills.run).toHaveBeenCalledTimes(1); + expect(logs.filter((line) => line.includes('Agent skill'))).toHaveLength(1); + }); + + it('leaves a copy that was there before init, and installs only where there is none', async () => { + installedProbe(); + const own = path.join(toolHome, '.claude', 'skills', RELAY_SKILL_NAME, 'SKILL.md'); + fs.mkdirSync(path.dirname(own), { recursive: true }); + fs.writeFileSync(own, 'my own copy\n'); + const lock = path.join(toolHome, '.agents', '.skill-lock.json'); + fs.mkdirSync(path.dirname(lock), { recursive: true }); + fs.writeFileSync(lock, JSON.stringify({ version: 3, skills: { 'other-skill': { ref: 'x' } } })); + const core = fakeCore(); + const skills = fakeSkillsCli(); + const { run, logs } = setup({ ...core.deps, runSkillsCli: skills.run }); + await run('--tools', 'claude,codex'); + + const args = skills.run.mock.calls[0]![0]; + expect(args.filter((_, i) => args[i - 1] === '--agent')).toEqual(['codex']); + expect(logs.join('\n')).toContain( + 'installed messaging-agents-on-the-relay for Codex; already there for Claude Code, left as is' + ); + + await setup(core.deps).run('--uninstall'); + expect(fs.readFileSync(own, 'utf8')).toBe('my own copy\n'); + expect(fs.existsSync(path.join(toolHome, '.agents', 'skills'))).toBe(false); + // The lock file was the person's: only init's entry leaves it. + expect(JSON.parse(fs.readFileSync(lock, 'utf8')).skills).toEqual({ 'other-skill': { ref: 'x' } }); + }); + + it("removes every folder its install created, even where a folder above holds the person's files", async () => { + installedProbe(); + const state = path.join(toolHome, 'xdg', 'state'); + const core = fakeCore(); + const skills = fakeSkillsCli(0, path.join(state, 'skills', '.skill-lock.json')); + const ctx = { projectRoot: project, home: toolHome, env: { PATH: '', XDG_STATE_HOME: state } }; + await setup({ ...core.deps, runSkillsCli: skills.run, toolContext: () => ctx }).run('--tools', 'codex'); + expect(fs.existsSync(skills.lock)).toBe(true); + // Another tool fills the folder init created above the lock's. + fs.mkdirSync(path.join(toolHome, 'xdg', 'cache'), { recursive: true }); + fs.writeFileSync(path.join(toolHome, 'xdg', 'cache', 'keep'), 'theirs'); + await setup({ ...core.deps, toolContext: () => ctx }).run('--uninstall'); + expect(fs.existsSync(state)).toBe(false); + expect(fs.existsSync(path.join(toolHome, '.agents'))).toBe(false); + expect(fs.readFileSync(path.join(toolHome, 'xdg', 'cache', 'keep'), 'utf8')).toBe('theirs'); + }); + + it('keeps recording what an --uninstall in another environment could not reach', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + const claudeDir = path.join(toolHome, 'claude-config'); + skills.dirs['claude-code'] = path.join(claudeDir, 'skills'); + const withDir = { projectRoot: project, home: toolHome, env: { PATH: '', CLAUDE_CONFIG_DIR: claudeDir } }; + await setup({ ...core.deps, runSkillsCli: skills.run, toolContext: () => withDir }).run( + '--tools', + 'claude' + ); + const copy = path.join(claudeDir, 'skills', RELAY_SKILL_NAME, 'SKILL.md'); + expect(fs.existsSync(copy)).toBe(true); + + // Without CLAUDE_CONFIG_DIR this environment cannot reach that copy. + const plain = setup(core.deps); + await plain.run('--uninstall'); + expect(fs.existsSync(copy)).toBe(true); + expect(plain.logs.join('\n')).toContain('Run relay init --uninstall again with the CLAUDE_CONFIG_DIR'); + const record = JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8')); + expect(record.relaySkill.copies.map((c: { path: string }) => c.path)).toEqual([path.dirname(copy)]); + + await setup({ ...core.deps, toolContext: () => withDir }).run('--uninstall'); + expect(fs.existsSync(copy)).toBe(false); + expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); + }); + + it('tracks each lock file it wrote when the state folder differs between runs', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + const under = (name: string) => { + const state = path.join(toolHome, name); + return { + lock: path.join(state, 'skills', '.skill-lock.json'), + ctx: { projectRoot: project, home: toolHome, env: { PATH: '', XDG_STATE_HOME: state } }, + }; + }; + const a = under('state-a'); + const b = under('state-b'); + await setup({ ...core.deps, runSkillsCli: skills.run, toolContext: () => a.ctx }).run('--tools', 'codex'); + await setup({ ...core.deps, toolContext: () => b.ctx }).run('--uninstall'); + expect(fs.existsSync(a.lock)).toBe(true); + + await setup({ ...core.deps, runSkillsCli: skills.run, toolContext: () => b.ctx }).run('--tools', 'codex'); + await setup({ ...core.deps, toolContext: () => b.ctx }).run('--uninstall'); + expect(fs.existsSync(b.lock)).toBe(false); + expect(fs.existsSync(a.lock)).toBe(true); + + await setup({ ...core.deps, toolContext: () => a.ctx }).run('--uninstall'); + expect(fs.existsSync(a.lock)).toBe(false); + expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); + }); + + it("takes this run's copies back out when the record cannot be saved", async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + const settings = path.join(home, 'local', 'core-settings.json'); + // A folder where the record goes: the next save fails. + const blocked: RunSkillsCli = (args, options) => { + const result = skills.run(args, options); + fs.rmSync(settings, { force: true }); + fs.mkdirSync(settings); + return result; + }; + const { run, logs } = setup({ ...core.deps, runSkillsCli: blocked }); + await expect(run('--tools', 'codex')).resolves.toBeDefined(); + expect(logs.join('\n')).toContain("so this run's copies were taken out again"); + expect(fs.existsSync(path.join(toolHome, '.agents'))).toBe(false); + }); + + it('names what it could not take back out when the record cannot be saved', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + const settings = path.join(home, 'local', 'core-settings.json'); + const copy = path.join(skills.dirs.codex!, RELAY_SKILL_NAME); + const rmSync = fs.rmSync; + const spy = vi.spyOn(fs, 'rmSync').mockImplementation((target, options) => { + if (target === copy) throw new Error('EACCES'); + rmSync(target, options); + }); + const blocked: RunSkillsCli = (args, options) => { + const result = skills.run(args, options); + rmSync(settings, { force: true }); + fs.mkdirSync(settings); + return result; + }; + const { run, logs } = setup({ ...core.deps, runSkillsCli: blocked }); + await run('--tools', 'codex'); + spy.mockRestore(); + expect(logs.join('\n')).toContain(`could not be taken out again, so remove them by hand: ${copy}`); + expect(logs.join('\n')).not.toContain("this run's copies were taken out again"); + }); + + it('replaces a shared lock file in one step, keeping its mode and leaving no temporary file', async () => { + installedProbe(); + const lock = path.join(toolHome, '.agents', '.skill-lock.json'); + fs.mkdirSync(path.dirname(lock), { recursive: true }); + fs.writeFileSync(lock, JSON.stringify({ version: 3, skills: { other: { ref: 'x' } } }), { mode: 0o640 }); + fs.chmodSync(lock, 0o640); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + await setup(core.deps).run('--uninstall'); + expect(fs.statSync(lock).mode & 0o777).toBe(0o640); + expect(fs.readdirSync(path.dirname(lock))).toEqual(['.skill-lock.json']); + }); + + it('says no copies were added when the install failed and the record cannot be saved', async () => { + installedProbe(); + const core = fakeCore(); + const settings = path.join(home, 'local', 'core-settings.json'); + const blocked: RunSkillsCli = () => { + fs.rmSync(settings, { force: true }); + fs.mkdirSync(settings); + return { status: 1, output: 'offline' }; + }; + const { run, logs } = setup({ ...core.deps, runSkillsCli: blocked }); + await run('--tools', 'codex'); + expect(logs.join('\n')).toContain('and this run added no copies to take out'); + expect(logs.join('\n')).not.toContain("this run's copies were taken out again"); + }); + + it('keeps a link to the lock file a link, and restores the entry in its target', async () => { + installedProbe(); + const target = path.join(toolHome, 'dotfiles', 'skill-lock.json'); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify({ version: 3, skills: { other: { ref: 'x' } } })); + const lock = path.join(toolHome, '.agents', '.skill-lock.json'); + fs.mkdirSync(path.dirname(lock), { recursive: true }); + fs.symlinkSync(target, lock); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + await setup(core.deps).run('--uninstall'); + expect(fs.lstatSync(lock).isSymbolicLink()).toBe(true); + expect(JSON.parse(fs.readFileSync(target, 'utf8')).skills).toEqual({ other: { ref: 'x' } }); + }); + + it('keeps the lock file mode under a restrictive umask', async () => { + installedProbe(); + const lock = path.join(toolHome, '.agents', '.skill-lock.json'); + fs.mkdirSync(path.dirname(lock), { recursive: true }); + fs.writeFileSync(lock, JSON.stringify({ version: 3, skills: { other: { ref: 'x' } } })); + fs.chmodSync(lock, 0o640); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + const previous = process.umask(0o077); + try { + await setup(core.deps).run('--uninstall'); + } finally { + process.umask(previous); + } + expect(fs.statSync(lock).mode & 0o777).toBe(0o640); + }); + + it('keeps the copies a parallel relay init recorded while it ran', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + const elsewhere = { + path: path.join(toolHome, '.grok', 'skills', RELAY_SKILL_NAME), + ref: 'r', + treeSha256: 't', + }; + const parallel: RunSkillsCli = (args, options) => { + editSkillRecord((record) => record.copies.push(elsewhere)); + return skills.run(args, options); + }; + await setup({ ...core.deps, runSkillsCli: parallel }).run('--tools', 'claude'); + const saved = JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8')); + expect(saved.relaySkill.copies.map((copy: { path: string }) => copy.path)).toEqual([ + path.join(skills.dirs.codex!, RELAY_SKILL_NAME), + elsewhere.path, + path.join(skills.dirs['claude-code']!, RELAY_SKILL_NAME), + ]); + }); + + it('puts a refreshed copy back when the record cannot be saved', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + skills.write('old\n'); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + editSkillRecord((record) => { + record.copies[0]!.ref = 'old'; + }); + skills.write('new\n'); + const settings = path.join(home, 'local', 'core-settings.json'); + const blocked: RunSkillsCli = (args, options) => { + const result = skills.run(args, options); + fs.rmSync(settings, { force: true }); + fs.mkdirSync(settings); + return result; + }; + const { run, logs } = setup({ ...core.deps, runSkillsCli: blocked }); + await run('--tools', 'codex'); + const copy = path.join(skills.dirs.codex!, RELAY_SKILL_NAME, 'SKILL.md'); + expect(fs.readFileSync(copy, 'utf8')).toBe('old\n'); + expect(logs.join('\n')).toContain("so this run's copies were taken out again"); + }); + + it('keeps recording a copy or lock it could not read on --uninstall', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + const copy = path.join(skills.dirs.codex!, RELAY_SKILL_NAME); + const denied = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + const { readdirSync, readFileSync } = fs; + const dirs = vi.spyOn(fs, 'readdirSync').mockImplementation((( + target: fs.PathLike, + ...rest: unknown[] + ) => { + if (target === copy) throw denied; + return (readdirSync as (...args: unknown[]) => unknown)(target, ...rest); + }) as typeof fs.readdirSync); + const files = vi.spyOn(fs, 'readFileSync').mockImplementation((( + target: fs.PathOrFileDescriptor, + ...rest: unknown[] + ) => { + if (target === skills.lock) throw denied; + return (readFileSync as (...args: unknown[]) => unknown)(target, ...rest); + }) as typeof fs.readFileSync); + const { run, logs } = setup(core.deps); + await run('--uninstall'); + dirs.mockRestore(); + files.mockRestore(); + expect(logs.join('\n')).toContain(`Could not remove ${copy}, ${skills.lock}`); + await setup(core.deps).run('--uninstall'); + expect(fs.existsSync(copy)).toBe(false); + expect(fs.existsSync(skills.lock)).toBe(false); + }); + + it('keeps a saved refresh when its backup cannot be deleted', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + skills.write('old\n'); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + editSkillRecord((record) => { + record.copies[0]!.ref = 'old'; + }); + skills.write('new\n'); + const rmSync = fs.rmSync; + const spy = vi.spyOn(fs, 'rmSync').mockImplementation((target, options) => { + if (String(target).includes('relay-skill-')) throw new Error('EBUSY'); + rmSync(target, options); + }); + const { run, logs } = setup({ ...core.deps, runSkillsCli: skills.run }); + await expect(run('--tools', 'codex')).resolves.toBeDefined(); + spy.mockRestore(); + const copy = path.join(skills.dirs.codex!, RELAY_SKILL_NAME, 'SKILL.md'); + expect(fs.readFileSync(copy, 'utf8')).toBe('new\n'); + expect(logs.join('\n')).toContain(`installed ${RELAY_SKILL_NAME} for Codex`); + const saved = JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8')); + expect(saved.relaySkill.copies[0].ref).toBe(RELAY_SKILL_REF); + }); + + it('removes the other copies and keeps recording one it could not remove', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'claude,codex'); + const stuck = path.join(skills.dirs['claude-code']!, RELAY_SKILL_NAME); + const other = path.join(skills.dirs.codex!, RELAY_SKILL_NAME); + const rmSync = fs.rmSync; + const spy = vi.spyOn(fs, 'rmSync').mockImplementation((target, options) => { + if (target === stuck) throw new Error('EACCES'); + rmSync(target, options); + }); + const { run, logs } = setup(core.deps); + await expect(run('--uninstall')).resolves.toBeDefined(); + spy.mockRestore(); + expect(fs.existsSync(stuck)).toBe(true); + expect(fs.existsSync(other)).toBe(false); + expect(logs.join('\n')).toContain(`Could not remove ${stuck}`); + await setup(core.deps).run('--uninstall'); + expect(fs.existsSync(stuck)).toBe(false); + }); + + it('keeps a copy with a file added after init on --uninstall', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + const notes = path.join(skills.dirs.codex!, RELAY_SKILL_NAME, 'notes.md'); + fs.writeFileSync(notes, 'mine\n'); + await setup(core.deps).run('--uninstall'); + expect(fs.readFileSync(notes, 'utf8')).toBe('mine\n'); + }); + + it('retries a refresh that failed, and refreshes each copy by its own ref', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'claude,codex'); + // An older pin installed both copies. + editSkillRecord((record) => { + for (const copy of record.copies) copy.ref = 'old'; + }); + + skills.fail(1); + const offline = setup({ ...core.deps, runSkillsCli: skills.run }); + await offline.run('--tools', 'claude,codex'); + expect(offline.logs.join('\n')).toContain( + 'could not install messaging-agents-on-the-relay for Claude Code, Codex' + ); + skills.fail(0); + // Only Codex is set up this time: Claude's copy keeps its own old ref. + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + const refs = () => + JSON.parse( + fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8') + ).relaySkill.copies.map((copy: { ref: string }) => copy.ref); + expect(refs()).toEqual(['old', RELAY_SKILL_REF]); + skills.run.mockClear(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'claude,codex'); + const args = skills.run.mock.calls[0]![0]; + expect(args.filter((_, i) => args[i - 1] === '--agent')).toEqual(['claude-code']); + expect(refs()).toEqual([RELAY_SKILL_REF, RELAY_SKILL_REF]); + }); + + it("puts back the person's own lock entry that the install replaced", async () => { + installedProbe(); + const lock = path.join(toolHome, '.agents', '.skill-lock.json'); + fs.mkdirSync(path.dirname(lock), { recursive: true }); + const own = { source: 'AgentWorkforce/skills', ref: 'their-ref' }; + fs.writeFileSync(lock, JSON.stringify({ version: 3, skills: { [RELAY_SKILL_NAME]: own } })); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + expect(JSON.parse(fs.readFileSync(lock, 'utf8')).skills[RELAY_SKILL_NAME].ref).toBe(RELAY_SKILL_REF); + await setup(core.deps).run('--uninstall'); + expect(JSON.parse(fs.readFileSync(lock, 'utf8')).skills[RELAY_SKILL_NAME]).toEqual(own); + }); + + it('never removes a folder outside the skills folders, whatever the record says', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + // A forged record names an unrelated folder with the same contents. + const unrelated = path.join(toolHome, 'work', RELAY_SKILL_NAME); + fs.cpSync(path.join(skills.dirs.codex!, RELAY_SKILL_NAME), unrelated, { recursive: true }); + editSkillRecord((record) => { + record.copies.push({ ...record.copies[0]!, path: unrelated }); + record.createdDirs.push(path.join(toolHome, 'work')); + }); + await setup(core.deps).run('--uninstall'); + expect(fs.existsSync(path.join(unrelated, 'SKILL.md'))).toBe(true); + }); + + it('finishes init with a warning when the skills state folder cannot be read', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + const blocked = path.join(toolHome, 'state-is-a-file'); + fs.writeFileSync(blocked, ''); + const ctx = { projectRoot: project, home: toolHome, env: { PATH: '', XDG_STATE_HOME: blocked } }; + const { run, logs } = setup({ ...core.deps, runSkillsCli: skills.run, toolContext: () => ctx }); + await expect(run('--tools', 'codex')).resolves.toBeDefined(); + expect(logs.join('\n')).toMatch(/! Agent skill +could not install/); + }); + + it('refuses to hand the skills CLI an argument with shell characters', () => { + const result = defaultRunSkillsCli(['add', 'x & calc'], { env: {}, cwd: toolHome }); + expect(result).toEqual({ status: null, output: 'refused an argument with shell characters: x & calc' }); + }); + + it('keeps a copy changed after init on --uninstall', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + const copy = path.join(skills.dirs.codex!, RELAY_SKILL_NAME, 'SKILL.md'); + fs.writeFileSync(copy, 'edited\n'); + const { run, logs } = setup(core.deps); + await run('--uninstall'); + expect(fs.readFileSync(copy, 'utf8')).toBe('edited\n'); + expect(logs.join('\n')).toContain('changed since'); + }); + + it('warns and finishes init when the skill cannot be installed, recording nothing', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(1); + const { run, logs } = setup({ ...core.deps, runSkillsCli: skills.run }); + await expect(run('--tools', 'codex')).resolves.toBeDefined(); + const output = logs.join('\n'); + expect(output).toContain( + '! Agent skill could not install messaging-agents-on-the-relay for Codex (fatal: unable to access github.com)' + ); + expect(output).toContain('Each session joins Relay on its own'); + const record = JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8')); + expect(record.relaySkill).toBeUndefined(); + }); + + it('installs nothing for tools on the Relay MCP server, which has its own tools', async () => { + const skills = fakeSkillsCli(); + await setup({ runSkillsCli: skills.run }).run('--tools', 'claude'); + expect(skills.run).not.toHaveBeenCalled(); + }); + + it('keeps the skill on --uninstall --scope project, like injection', async () => { + installedProbe(); + const core = fakeCore(); + const skills = fakeSkillsCli(); + await setup({ ...core.deps, runSkillsCli: skills.run }).run('--tools', 'codex'); + await setup(core.deps).run('--uninstall', '--scope', 'project'); + expect(fs.existsSync(path.join(skills.dirs.codex!, RELAY_SKILL_NAME, 'SKILL.md'))).toBe(true); + }); +}); + describe('relay init outside a project', () => { /** A user home whose .agentworkforce/relay is the relay home, as `~` is. */ function homeFolder(): { userHome: string; relayHome: string } { @@ -2131,6 +2742,17 @@ describe('relay init in an empty folder under the home folder', () => { expect(projectFilesInHome()).toEqual([]); }); + it('installs no skill when it refuses the home folder', async () => { + fs.mkdirSync(path.join(userHome, '.git')); + const skills = vi.fn(() => ({ status: 0, output: '' })); + const { run } = setup({ + runSkillsCli: skills, + toolContext: () => ({ projectRoot: getProjectPaths().projectRoot, home: userHome, env: { PATH: '' } }), + }); + await expect(run('--tools', 'codex')).rejects.toEqual(new ExitSignal(1)); + expect(skills).not.toHaveBeenCalled(); + }); + it('refuses ~ even when the relay home lives elsewhere', async () => { vi.stubEnv('AGENT_RELAY_HOME', path.join(os.tmpdir(), 'relay-init-elsewhere')); fs.rmSync(path.join(userHome, '.agentworkforce'), { recursive: true }); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index da8c7bd85..1a9b68b56 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -87,6 +87,13 @@ import { type OpenCodeState, type PiState, } from '../lib/init-leftovers.js'; +import { + applySkillChange, + defaultRunSkillsCli, + installRelaySkill, + uninstallRelaySkill, + type RunSkillsCli, +} from '../lib/relay-skill.js'; import { persistWorkspaceSession } from '../lib/workspace-session.js'; import { readWorkspaceStore } from '../lib/workspace-store.js'; import { assertProjectRoot, getProjectPaths, isHomeProjectRoot } from '@agent-relay/config'; @@ -144,6 +151,8 @@ export interface InitCommandDependencies extends SdkCommandDeps { downloadProbe: DownloadFile; /** The version an installed `agent-relay-probe` reports for `--version`. */ probeVersion: (binary: string) => string | undefined; + /** Runs the pinned `skills` CLI that installs the Agent Relay messaging skill. */ + runSkillsCli: RunSkillsCli; } interface LocalServerState { @@ -316,6 +325,7 @@ export function withInitDefaults(overrides: Partial = { startCore: startCoreProcess, downloadProbe: fetchDownload, probeVersion: readProbeVersion, + runSkillsCli: defaultRunSkillsCli, ...overrides, }; } @@ -1148,6 +1158,7 @@ async function setupInjection( ? (current.openCode ?? files.openCode) : current.openCode, pi: touched.includes('pi_extension') ? (current.pi ?? files.pi) : current.pi, + relaySkill: current.relaySkill, }); }); try { @@ -1467,6 +1478,7 @@ function tidyLeftovers(record: DeliveryRecord): DeliveryRecord { ...(claudeFiles ? { claudeFiles } : {}), ...(openCode ? { openCode } : {}), ...(pi ? { pi } : {}), + ...(record.relaySkill ? { relaySkill: record.relaySkill } : {}), }; } @@ -1480,7 +1492,7 @@ function updateDeliveryRecord(change: (current: DeliveryRecord) => DeliveryRecor /** Save what init turned on; no record at all once nothing is. */ function writeDeliveryRecord(record: DeliveryRecord): void { - if (record.turnedOn.length === 0) { + if (record.turnedOn.length === 0 && !record.relaySkill) { fs.rmSync(deliveryRecordPath(), { force: true }); return; } @@ -1490,9 +1502,56 @@ function writeDeliveryRecord(record: DeliveryRecord): void { ...(record.claudeFiles ? { claudeFiles: record.claudeFiles } : {}), ...(record.openCode ? { openCode: record.openCode } : {}), ...(record.pi ? { pi: record.pi } : {}), + ...(record.relaySkill ? { relaySkill: record.relaySkill } : {}), }); } +/** + * Install the Agent Relay messaging skill for the tools the core injects + * into, with no prompt, and record what landed. Never fails init. + * @returns The one line to print, or undefined when no tool is injected + */ +function installSkill( + deps: InitCommandDependencies, + injected: ToolId[], + ctx: ToolContext +): string | undefined { + if (injected.length === 0) return undefined; + const result = installRelaySkill( + injected, + ctx, + readDeliveryRecord(deliveryRecordPath()).relaySkill, + deps.runSkillsCli + ); + try { + updateDeliveryRecord(({ relaySkill, ...rest }) => { + const next = applySkillChange(relaySkill, result.change); + return next ? { ...rest, relaySkill: next } : rest; + }); + } catch (error) { + // The skill is optional, but what --uninstall would not know about is not left behind. + const { restored, failed } = result.settle?.(false) ?? { restored: [], failed: [] }; + const left = uninstallRelaySkill(result.undo, ctx)?.remaining; + const stuck = [ + ...failed, + ...(left?.copies.map((copy) => copy.path) ?? []), + ...(left?.locks.map((lock) => lock.file) ?? []), + ]; + const outcome = + stuck.length > 0 + ? `and these could not be taken out again, so remove them by hand: ${stuck.join(', ')}` + : result.undo?.copies.length || restored.length > 0 + ? "so this run's copies were taken out again" + : 'and this run added no copies to take out'; + return ( + `! Agent skill not installed: ${deliveryRecordPath()} could not be saved ` + + `(${(error as Error).message}), ${outcome}` + ); + } + result.settle?.(true); + return result.line; +} + /** * The recorded settings to turn off when `tools` leave injection: theirs, * and automatic activation once no tool init injects into is left. @@ -1695,7 +1754,18 @@ async function uninstall(deps: InitCommandDependencies, o: InitOptions): Promise const removedCopy = o.scope !== 'project' && o.tools === undefined ? removeProbeCopy(ctx) : undefined; if (removedCopy) deps.log(`✓ Agent Relay core removed the copy a core relay init started left at ${removedCopy}`); - if (shown.length === 0 && turnedOff.length === 0 && !injectionKept && !removedCopy) { + // The skill is installed per user, like injection, and removed with it. + const skillRecord = readDeliveryRecord(deliveryRecordPath()).relaySkill; + const skill = + o.scope !== 'project' && o.tools === undefined ? uninstallRelaySkill(skillRecord, ctx) : undefined; + if (skill) { + deps.log(skill.line); + // Paths this environment could not reach stay recorded for a later --uninstall. + updateDeliveryRecord(({ relaySkill: _removed, ...rest }) => + skill.remaining ? { ...rest, relaySkill: skill.remaining } : rest + ); + } + if (shown.length === 0 && turnedOff.length === 0 && !injectionKept && !removedCopy && !skillRecord) { deps.log('relay init had not added Relay to any agent tool here; nothing to remove.'); return; } @@ -1786,6 +1856,8 @@ async function runPlain(deps: InitCommandDependencies, o: InitOptions): Promise< deps.log(''); deps.log('Injecting messages into your agent sessions:'); for (const line of injectionLines(plan, ctx)) deps.log(` ${line}`); + const skillLine = installSkill(deps, plan.injected, ctx); + if (skillLine) deps.log(` ${skillLine}`); } else if (plan.unavailable) { deps.log(''); deps.log(unavailableLine(plan.unavailable)); @@ -2005,7 +2077,8 @@ async function runGuided(deps: InitCommandDependencies, o: InitOptions): Promise ); if (plan.injection) { injecting.stop('Message injection is set up.'); - p.note(injectionLines(plan, ctx).join('\n'), 'Message injection'); + const skillLine = installSkill(deps, plan.injected, ctx); + p.note([...injectionLines(plan, ctx), ...(skillLine ? [skillLine] : [])].join('\n'), 'Message injection'); } else if (plan.unavailable) { injecting.stop('Message injection is not available.'); p.warn(unavailableLine(plan.unavailable)); diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts index 15d6b8c75..7e012165b 100644 --- a/packages/cli/src/cli/lib/probe-core.ts +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -23,6 +23,7 @@ import path from 'node:path'; import type { ToolId } from './agent-tools-mcp.js'; import type { ClaudeFileState, OpenCodeState, PiState } from './init-leftovers.js'; import type { EnginePlatform } from './relaycast-engine-install.js'; +import { relaySkillRecordOf, type RelaySkillRecord } from './relay-skill.js'; /** The core `relay init` installs. Pinned so every machine runs the same core. */ export const PROBE_VERSION = '15.0.5'; @@ -590,6 +591,8 @@ export interface DeliveryRecord { openCode?: OpenCodeState; /** The Pi extension's place as init found it (`tidyPiExtension`). */ pi?: PiState; + /** The agent skill init installed (`relay-skill.ts`), removed by `--uninstall`. */ + relaySkill?: RelaySkillRecord; } const strings = (value: unknown): string[] => @@ -668,12 +671,14 @@ export function readDeliveryRecord(file: string): DeliveryRecord { const claudeFiles = claudeFileStatesOf(parsed?.claudeFiles); const openCode = openCodeStateOf(parsed?.openCode); const pi = piStateOf(parsed?.pi); + const relaySkill = relaySkillRecordOf(parsed?.relaySkill); return { turnedOn: strings(parsed?.turnedOn), claudeInbound: originals(parsed?.claudeInbound), ...(claudeFiles ? { claudeFiles } : {}), ...(openCode ? { openCode } : {}), ...(pi ? { pi } : {}), + ...(relaySkill ? { relaySkill } : {}), }; } catch { return { turnedOn: [], claudeInbound: {} }; diff --git a/packages/cli/src/cli/lib/relay-skill.ts b/packages/cli/src/cli/lib/relay-skill.ts new file mode 100644 index 000000000..c75cb148b --- /dev/null +++ b/packages/cli/src/cli/lib/relay-skill.ts @@ -0,0 +1,622 @@ +/** + * The agent skill `relay init` installs so every session it injects into + * knows how to find who is on Agent Relay and message them: + * `messaging-agents-on-the-relay` from AgentWorkforce/skills, installed for + * each tool globally with the `skills` CLI (`npx skills add`). + * + * Both the skill and the CLI are pinned, so a change to the skills repo + * cannot change what init installs. Init records each copy it put in place + * in its record (`core-settings.json`), and `relay init --uninstall` removes + * exactly those, and only while every file in them is unchanged. A copy that was already + * there before init, or that the person changed, is never touched. + */ + +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + TOOL_IDS, + findToolTarget, + writeKeepingMode, + type ToolContext, + type ToolId, +} from './agent-tools-mcp.js'; + +/** The skill's name in AgentWorkforce/skills. */ +export const RELAY_SKILL_NAME = 'messaging-agents-on-the-relay'; +/** The skills repo commit init installs the skill from. */ +export const RELAY_SKILL_REF = '88fdbefc92f6ca405b4d5243ef30d4e7b7b2203d'; +/** The pinned source, as `skills add` takes it (`#`). */ +export const RELAY_SKILL_SOURCE = `https://github.com/AgentWorkforce/skills#${RELAY_SKILL_REF}`; +/** The `skills` CLI version init runs through npx. */ +export const SKILLS_CLI_PACKAGE = 'skills@1.7.4'; + +/** The `skills` CLI's agent name for each tool init sets up. */ +const SKILLS_AGENT: Record = { + claude: 'claude-code', + codex: 'codex', + opencode: 'opencode', + grok: 'grok', + pi: 'pi', + devin: 'devin', +}; + +/** One copy of the skill init put in place. */ +export interface InstalledSkillCopy { + /** The skill's folder. */ + path: string; + /** The skills repo commit this copy was installed from. */ + ref: string; + /** sha256 over every file in the folder as installed: --uninstall removes it only while it matches. */ + treeSha256: string; +} + +/** The skill's entry in the `skills` CLI's lock file, as init found and left it. */ +export interface SkillLockRecord { + file: string; + /** Init created the lock file. */ + created: boolean; + /** The entry before init first wrote it, or null when there was none: what --uninstall puts back. */ + original: unknown; + /** The ref init last wrote there: --uninstall touches the entry only while it still names it. */ + ref: string; +} + +/** What init installed of the skill, kept in `core-settings.json` for `--uninstall`. */ +export interface RelaySkillRecord { + copies: InstalledSkillCopy[]; + /** Folders init's install created, removed again once empty. */ + createdDirs: string[]; + /** The `skills` CLI's lock entries init wrote, one per lock file. */ + locks: SkillLockRecord[]; +} + +/** Runs the `skills` CLI with `args`; replaced in tests. */ +export type RunSkillsCli = ( + args: string[], + options: { env: NodeJS.ProcessEnv; cwd: string } +) => { status: number | null; output: string }; + +/** The only characters an argument to the `skills` CLI may hold: no shell syntax on any platform. */ +const PLAIN_ARGUMENT = /^[A-Za-z0-9@:/._#=+-]+$/; + +/** + * The real `npx skills@ …`: no prompts, no telemetry, its output + * kept for a failure's reason. Windows starts npx (a .cmd) only through a + * shell, so every argument must be a plain word (`PLAIN_ARGUMENT`); any + * other is refused before anything runs. + * @param args - The `skills` CLI's arguments + * @param options - Its environment and working folder + * @returns Its exit status and combined output + */ +export const defaultRunSkillsCli: RunSkillsCli = (args, { env, cwd }) => { + const words = ['-y', SKILLS_CLI_PACKAGE, ...args]; + const unsafe = words.find((word) => !PLAIN_ARGUMENT.test(word)); + if (unsafe !== undefined) { + return { status: null, output: `refused an argument with shell characters: ${unsafe}` }; + } + const options = { + cwd, + env, + encoding: 'utf8' as const, + stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'], + timeout: 180_000, + }; + const result = + process.platform === 'win32' + ? spawnSync(`npx ${words.join(' ')}`, { ...options, shell: true }) + : spawnSync('npx', words, options); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}${result.error ? result.error.message : ''}`; + return { status: result.status, output }; +}; + +/** + * Where a tool loads global skills, as the `skills` CLI installs them: + * Claude Code `~/.claude/skills` (or `$CLAUDE_CONFIG_DIR/skills`); Codex, + * OpenCode and Pi the shared `~/.agents/skills`; Grok `~/.grok/skills`; + * Devin `$XDG_CONFIG_HOME/devin/skills`. + * @param id - The tool + * @param ctx - Home and environment the tool resolves against + * @returns The tool's global skills folder + */ +export function skillsDirFor(id: ToolId, ctx: ToolContext): string { + const env = ctx.env; + switch (id) { + case 'claude': + return path.join(env.CLAUDE_CONFIG_DIR?.trim() || path.join(ctx.home, '.claude'), 'skills'); + case 'grok': + return path.join(env.GROK_HOME?.trim() || path.join(ctx.home, '.grok'), 'skills'); + case 'devin': + return path.join(env.XDG_CONFIG_HOME?.trim() || path.join(ctx.home, '.config'), 'devin', 'skills'); + case 'codex': + case 'opencode': + case 'pi': + return path.join(ctx.home, '.agents', 'skills'); + } +} + +/** The skill's own folder for `id`. */ +function skillDir(id: ToolId, ctx: ToolContext): string { + return path.join(skillsDirFor(id, ctx), RELAY_SKILL_NAME); +} + +/** The `skills` CLI's lock file (`$XDG_STATE_HOME/skills/.skill-lock.json`, else `~/.agents/.skill-lock.json`). */ +function lockFile(ctx: ToolContext): string { + const state = ctx.env.XDG_STATE_HOME?.trim(); + return state + ? path.join(state, 'skills', '.skill-lock.json') + : path.join(ctx.home, '.agents', '.skill-lock.json'); +} + +type SkillLock = { skills?: Record } & Record; + +function readLock(file: string): SkillLock { + try { + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +/** + * sha256 over every entry under `dir` (its relative path, then a file's + * contents or a link's target), or undefined when `dir` is not a real + * folder or cannot be read. Any added, removed or changed file changes it. + */ +function treeHash(dir: string): string | undefined { + const hash = createHash('sha256'); + const walk = (current: string, relative: string): void => { + const entries = fs.readdirSync(current, { withFileTypes: true }); + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of entries) { + const full = path.join(current, entry.name); + const name = path.posix.join(relative, entry.name); + if (entry.isDirectory()) { + hash.update(`d\0${name}\0`); + walk(full, name); + } else if (entry.isSymbolicLink()) { + hash.update(`l\0${name}\0${fs.readlinkSync(full)}\0`); + } else { + hash.update(`f\0${name}\0`).update(fs.readFileSync(full)).update('\0'); + } + } + }; + try { + if (!fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return undefined; + walk(dir, ''); + return hash.digest('hex'); + } catch { + return undefined; + } +} + +function exists(file: string): boolean { + return fs.lstatSync(file, { throwIfNoEntry: false }) !== undefined; +} + +/** Whether `file` is gone, a real folder, something else, or cannot be told. */ +function pathState(file: string): 'missing' | 'folder' | 'other' | 'unreadable' { + try { + const stat = fs.lstatSync(file, { throwIfNoEntry: false }); + if (!stat) return 'missing'; + return stat.isDirectory() ? 'folder' : 'other'; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOTDIR' ? 'missing' : 'unreadable'; + } +} + +/** Every missing folder on the way to `file`, deepest first; empty when its folder exists. */ +function missingDirs(file: string): string[] { + const missing: string[] = []; + for (let dir = path.dirname(file); dir !== path.dirname(dir) && !exists(dir); dir = path.dirname(dir)) { + missing.push(dir); + } + return missing; +} + +/** Removes the folders init created, deepest first, each only while it is empty. */ +function removeEmptyDirs(dirs: string[]): void { + for (const dir of [...dirs].sort((a, b) => b.length - a.length)) { + try { + fs.rmdirSync(dir); + } catch { + // Not empty, already gone, or unreadable: left as it is. + } + } +} + +/** + * Read the recorded install. + * @param value - The `relaySkill` value of `core-settings.json` + * @returns The record, or undefined when the value is not one + */ +export function relaySkillRecordOf(value: unknown): RelaySkillRecord | undefined { + if (!value || typeof value !== 'object') return undefined; + const record = value as Record; + const copies = Array.isArray(record.copies) + ? record.copies.filter( + (copy): copy is InstalledSkillCopy => + typeof copy?.path === 'string' && + typeof copy?.ref === 'string' && + typeof copy?.treeSha256 === 'string' + ) + : []; + const createdDirs = Array.isArray(record.createdDirs) + ? record.createdDirs.filter((dir): dir is string => typeof dir === 'string') + : []; + const locks = (Array.isArray(record.locks) ? (record.locks as Partial[]) : []).flatMap( + (raw) => + typeof raw?.file === 'string' && typeof raw?.ref === 'string' + ? [{ file: raw.file, created: raw.created === true, original: raw.original ?? null, ref: raw.ref }] + : [] + ); + if (copies.length === 0 && locks.length === 0) return undefined; + return { copies, createdDirs, locks }; +} + +/** What one install changed, applied to the record as it is when saved (`applySkillChange`). */ +export interface SkillChange { + copies: InstalledSkillCopy[]; + createdDirs: string[]; + lock?: SkillLockRecord; +} + +/** One line about the skill, and what the run changed. */ +export interface SkillInstallResult { + line: string; + change?: SkillChange; + /** Only what this run newly created: what to take back out if the change cannot be saved. */ + undo?: RelaySkillRecord; + /** + * Called once the change is saved or not: drops the backups of refreshed + * copies, first putting them back when `saved` is false. + * @returns The folders put back, and those that could not be + */ + settle?: (saved: boolean) => { restored: string[]; failed: string[] }; +} + +/** + * Apply one install's change to the record as it is now, so parallel runs + * keep each other's copies. Copies this run did not write stay as recorded. + * @param current - The record just before saving + * @param change - What this run changed + * @returns The record to save, or undefined when there is nothing to record + */ +export function applySkillChange( + current: RelaySkillRecord | undefined, + change: SkillChange | undefined +): RelaySkillRecord | undefined { + if (!change) return current; + const written = new Set(change.copies.map((copy) => copy.path)); + const copies = [...(current?.copies ?? []).filter((copy) => !written.has(copy.path)), ...change.copies]; + const locks = mergeLocks(current?.locks ?? [], change.lock); + if (copies.length === 0 && locks.length === 0) return undefined; + return { + copies, + createdDirs: [...new Set([...(current?.createdDirs ?? []), ...change.createdDirs])], + locks, + }; +} + +/** + * Install the skill globally for `tools`, where it is not there yet. A copy + * init installed earlier is refreshed when it is from another pinned ref + * and unchanged; any other copy already there is the person's and is left + * as it is. Never throws: a failure is the warning line, and only what did + * land is recorded as current, so a failed refresh is tried again next time. + * @param tools - The tools whose sessions the core injects into + * @param ctx - Home and environment the tools resolve against + * @param recorded - What an earlier init installed + * @param run - Runs the `skills` CLI + * @returns The line to print and the record to keep + */ +export function installRelaySkill( + tools: ToolId[], + ctx: ToolContext, + recorded: RelaySkillRecord | undefined, + run: RunSkillsCli +): SkillInstallResult { + try { + return install(tools, ctx, recorded, run); + } catch (error) { + return { line: failureLine(tools.map(toolLabel).join(', '), (error as Error).message) }; + } +} + +/** The folders to install into (absent, or init's own unchanged copy from another ref) and those left as they are. */ +function plan( + dirs: string[], + recorded: RelaySkillRecord | undefined +): { before: Map; kept: string[] } { + const ours = new Map((recorded?.copies ?? []).map((copy) => [copy.path, copy])); + const before = new Map(); + const kept: string[] = []; + for (const dir of dirs) { + const copy = ours.get(dir); + if (!exists(dir)) { + before.set(dir, undefined); + continue; + } + const now = treeHash(dir); + if (!copy || now !== copy.treeSha256) kept.push(dir); + else if (copy.ref !== RELAY_SKILL_REF) before.set(dir, now); + } + return { before, kept }; +} + +/** The lock entry this run wrote, as `--uninstall` would need it; undefined when it wrote none. */ +function lockWritten(file: string, existed: boolean, entryBefore: unknown): SkillLockRecord | undefined { + const before = (entryBefore as { ref?: string } | undefined)?.ref; + if (readLock(file).skills?.[RELAY_SKILL_NAME]?.ref !== RELAY_SKILL_REF || before === RELAY_SKILL_REF) { + return undefined; + } + return { file, created: !existed, original: entryBefore ?? null, ref: RELAY_SKILL_REF }; +} + +/** The recorded lock entries after `written`: an earlier entry for its file keeps what init first found. */ +function mergeLocks(recorded: SkillLockRecord[], written: SkillLockRecord | undefined): SkillLockRecord[] { + if (!written) return recorded; + const earlier = recorded.find((entry) => entry.file === written.file); + return [ + ...recorded.filter((entry) => entry.file !== written.file), + earlier ? { ...earlier, ref: written.ref } : written, + ]; +} + +function install( + tools: ToolId[], + ctx: ToolContext, + recorded: RelaySkillRecord | undefined, + run: RunSkillsCli +): SkillInstallResult { + const byPath = new Map(); + for (const id of tools) byPath.set(skillDir(id, ctx), [...(byPath.get(skillDir(id, ctx)) ?? []), id]); + const { before, kept } = plan([...byPath.keys()], recorded); + const targets = [...before.keys()]; + const label = (dirs: string[]) => dirs.map((dir) => byPath.get(dir)!.map(toolLabel).join(', ')).join(', '); + if (targets.length === 0) { + const state = kept.length > 0 ? 'already there' : 'installed'; + return { + line: `✓ Agent skill ${RELAY_SKILL_NAME} ${state} for ${label([...byPath.keys()])}`, + }; + } + + const lock = lockFile(ctx); + const lockExisted = exists(lock); + const entryBefore = readLock(lock).skills?.[RELAY_SKILL_NAME]; + const createdDirs = [...targets.map((dir) => path.join(dir, 'SKILL.md')), lock].flatMap(missingDirs); + const agents = targets.flatMap((dir) => byPath.get(dir)!.flatMap((id) => ['--agent', SKILLS_AGENT[id]])); + // A copy is not changed unless it can be put back: a failure here is the warning line. + const backups = backupCopies(targets.filter((dir) => before.get(dir) !== undefined)); + let result: ReturnType; + try { + result = run( + ['add', RELAY_SKILL_SOURCE, '--skill', RELAY_SKILL_NAME, '--global', '--copy', '--yes', ...agents], + // HOME is the tools' home, so the skill lands where they load it. + { env: { ...ctx.env, HOME: ctx.home, DISABLE_TELEMETRY: '1', DO_NOT_TRACK: '1' }, cwd: ctx.home } + ); + } catch (error) { + result = { status: null, output: (error as Error).message }; + } + + // A copy is current only if this run put it there: new, or written by a + // run that succeeded. An old copy a failed refresh left keeps its old ref. + const after = new Map(targets.map((dir) => [dir, treeHash(dir)])); + const landed = targets.filter((dir) => { + const hash = after.get(dir); + return ( + hash !== undefined && (before.get(dir) === undefined || result.status === 0 || hash !== before.get(dir)) + ); + }); + const copies = landed.map((dir) => ({ path: dir, ref: RELAY_SKILL_REF, treeSha256: after.get(dir)! })); + const written = lockWritten(lock, lockExisted, entryBefore); + const made = createdDirs.filter(exists); + const change: SkillChange = { copies, createdDirs: made, ...(written ? { lock: written } : {}) }; + const undo: RelaySkillRecord = { + copies: copies.filter((copy) => before.get(copy.path) === undefined), + createdDirs: made, + locks: written ? [written] : [], + }; + const settle = (saved: boolean) => settleBackups(backups, saved ? [] : landed); + const missed = targets.filter((dir) => !landed.includes(dir)); + if (missed.length > 0) { + const reason = + result.status === 0 ? 'it did not land' : lastLine(result.output) || `exit ${result.status}`; + return { line: failureLine(label(missed), reason), change, undo, settle }; + } + const keptNote = kept.length > 0 ? `; already there for ${label(kept)}, left as is` : ''; + return { + line: `✓ Agent skill installed ${RELAY_SKILL_NAME} for ${label(landed)}${keptNote}`, + change, + undo, + settle, + }; +} + +/** Copies of `dirs`, by folder, in a private temporary folder; empty when there are none. */ +function backupCopies(dirs: string[]): Map { + const backups = new Map(); + if (dirs.length === 0) return backups; + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-skill-')); + dirs.forEach((dir, index) => { + const backup = path.join(root, String(index)); + fs.cpSync(dir, backup, { recursive: true, verbatimSymlinks: true }); + backups.set(dir, backup); + }); + return backups; +} + +/** Put back the `restore` folders that have a backup, then drop the backups. */ +function settleBackups( + backups: Map, + restore: string[] +): { restored: string[]; failed: string[] } { + const restored: string[] = []; + const failed: string[] = []; + for (const dir of restore) { + const backup = backups.get(dir); + if (!backup) continue; + try { + fs.rmSync(dir, { recursive: true, force: true }); + fs.cpSync(backup, dir, { recursive: true, verbatimSymlinks: true }); + restored.push(dir); + } catch { + failed.push(dir); + } + } + const [first] = backups.values(); + try { + if (first) fs.rmSync(path.dirname(first), { recursive: true, force: true }); + } catch { + // Best effort: a leftover temporary folder changes nothing about the install. + } + return { restored, failed }; +} + +function failureLine(tools: string, reason: string): string { + return ( + `! Agent skill could not install ${RELAY_SKILL_NAME} for ${tools} (${reason}). ` + + `Messaging still works; to add it: npx ${SKILLS_CLI_PACKAGE} add ${RELAY_SKILL_SOURCE} --skill ${RELAY_SKILL_NAME} --global` + ); +} + +/** What `--uninstall` did with the skill, and what is still recorded. */ +export interface SkillUninstallResult { + line: string; + /** + * What it could not remove, kept for a later `--uninstall`: paths this + * environment resolves elsewhere (another `CLAUDE_CONFIG_DIR`, `GROK_HOME`, + * `XDG_CONFIG_HOME` or `XDG_STATE_HOME`) and paths that failed. Undefined + * when nothing is left. + */ + remaining: RelaySkillRecord | undefined; +} + +/** + * Remove what init installed: each recorded copy whose every file is as + * installed, the lock entries it wrote (putting back what they replaced), + * and the folders it created once they are empty. Only the skill's own + * folder in a tool's skills folder and the `skills` CLI's lock file, as this + * environment resolves them, are ever touched, whatever the record says. + * Never throws: what it could not reach or remove stays recorded, and the + * line says so. + * @param recorded - What init installed + * @param ctx - Home and environment the tools resolve against + * @returns The line to print and the record to keep, or undefined when nothing was recorded + */ +export function uninstallRelaySkill( + recorded: RelaySkillRecord | undefined, + ctx: ToolContext +): SkillUninstallResult | undefined { + if (!recorded) return undefined; + const allowed = new Set(TOOL_IDS.map((id) => skillDir(id, ctx))); + const lock = lockFile(ctx); + const removed: string[] = []; + const changed: string[] = []; + const failed: InstalledSkillCopy[] = []; + const elsewhere = recorded.copies.filter((copy) => !allowed.has(copy.path)); + for (const copy of recorded.copies) { + if (!allowed.has(copy.path)) continue; + const state = pathState(copy.path); + if (state === 'missing') continue; + if (state === 'unreadable') { + failed.push(copy); + continue; + } + const hash = treeHash(copy.path); + if (state === 'folder' && hash === undefined) { + failed.push(copy); + continue; + } + if (hash !== copy.treeSha256) { + changed.push(copy.path); + continue; + } + try { + fs.rmSync(copy.path, { recursive: true, force: true }); + removed.push(copy.path); + } catch { + failed.push(copy); + } + } + const locksElsewhere = recorded.locks.filter((entry) => entry.file !== lock); + const locksFailed = recorded.locks.filter((entry) => entry.file === lock && !restoreLock(entry)); + const roots = [...allowed, lock]; + removeEmptyDirs( + recorded.createdDirs.filter((dir) => roots.some((root) => root.startsWith(dir + path.sep))) + ); + const left = [...elsewhere, ...failed]; + const leftLocks = [...locksElsewhere, ...locksFailed]; + const remaining: RelaySkillRecord | undefined = + left.length > 0 || leftLocks.length > 0 + ? { + copies: left, + createdDirs: recorded.createdDirs.filter((dir) => fs.existsSync(dir)), + locks: leftLocks, + } + : undefined; + const notes = [ + ...(removed.length > 0 ? [` (${removed.join(', ')})`] : []), + ...(changed.length > 0 ? [`; kept ${changed.join(', ')}, changed since`] : []), + ]; + const away = [...elsewhere.map((copy) => copy.path), ...locksElsewhere.map((entry) => entry.file)]; + if (away.length > 0) { + notes.push( + `. Not removed, because this environment resolves the tools' folders elsewhere: ${away.join(', ')}. ` + + 'Run relay init --uninstall again with the CLAUDE_CONFIG_DIR, GROK_HOME, XDG_CONFIG_HOME and ' + + 'XDG_STATE_HOME that relay init ran with to remove them' + ); + } + const stuck = [...failed.map((copy) => copy.path), ...locksFailed.map((entry) => entry.file)]; + if (stuck.length > 0) + notes.push(`. Could not remove ${stuck.join(', ')}; run relay init --uninstall again`); + return { + line: `${remaining ? '!' : '✓'} Agent skill removed the ${RELAY_SKILL_NAME} skill relay init installed${notes.join('')}`, + remaining, + }; +} + +/** + * Put the lock entry back as init found it, while it still names the ref init + * wrote. The file is replaced in one step, keeping its mode, and a link to it + * stays a link. + * @returns false when the lock file could not be changed + */ +function restoreLock(record: SkillLockRecord): boolean { + let lock: SkillLock; + try { + lock = JSON.parse(fs.readFileSync(record.file, 'utf8')) as SkillLock; + } catch (error) { + // Gone is done; an unreadable or damaged file stays recorded for a later --uninstall. + return (error as NodeJS.ErrnoException).code === 'ENOENT'; + } + if (!lock?.skills || lock.skills[RELAY_SKILL_NAME]?.ref !== record.ref) return true; + if (record.original) lock.skills[RELAY_SKILL_NAME] = record.original as { ref?: string }; + else delete lock.skills[RELAY_SKILL_NAME]; + try { + if (record.created && Object.keys(lock.skills).length === 0) fs.rmSync(record.file, { force: true }); + else writeKeepingMode(fs.realpathSync(record.file), JSON.stringify(lock, null, 2)); + return true; + } catch { + return false; + } +} + +function toolLabel(id: ToolId): string { + return findToolTarget(id)?.label ?? id; +} + +/** The last non-empty line of the CLI's output, without terminal styling, for a failure's reason. */ +function lastLine(output: string): string { + const lines = output + // eslint-disable-next-line no-control-regex + .replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '') + .split('\n') + .map((line) => line.replace(/^[\s│◇■└┌]+/, '').trim()) + .filter(Boolean); + return (lines.at(-1) ?? '').slice(0, 200); +}