From 1508800e30b8aa80625074abcc4badd4b555f964 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:52:44 +0200 Subject: [PATCH 01/27] test: an operator runs simlock setup for real and the skill lands where the tool reads it (#620) --- e2e/helpers/cli.ts | 2 ++ e2e/setup.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/instructions/index.ts | 4 ++++ 3 files changed, 43 insertions(+) create mode 100644 e2e/setup.test.ts diff --git a/e2e/helpers/cli.ts b/e2e/helpers/cli.ts index e460792a3..24f254a04 100644 --- a/e2e/helpers/cli.ts +++ b/e2e/helpers/cli.ts @@ -10,6 +10,7 @@ export interface CliOptions { readonly input?: string; readonly timeout?: number; readonly entry?: string; + readonly cwd?: string; } export interface CliError { @@ -39,6 +40,7 @@ export function cli( return new Promise((resolve, reject) => { const child = spawn(process.execPath, [options.entry ?? CLI_ENTRY, ...args], { env: { ...env, ...options.env }, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), }); let stdout = ""; let stderr = ""; diff --git a/e2e/setup.test.ts b/e2e/setup.test.ts new file mode 100644 index 000000000..09a4ac23c --- /dev/null +++ b/e2e/setup.test.ts @@ -0,0 +1,37 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { renderSkill } from "../src/instructions/index.js"; +import { withDaemon, type TestEnv } from "./helpers/index.js"; + +async function operatorDirectories(env: TestEnv): Promise<{ home: string; project: string }> { + const home = join(env.home, "operator-home"); + const project = join(env.home, "project"); + await mkdir(home); + await mkdir(project); + return { home, project }; +} + +describe("simlock setup", () => { + it("writes the skill where the tool reads it, for real, and starts no daemon", async () => { + const env = await withDaemon({ mode: "auto" }); + const { home, project } = await operatorDirectories(env); + + const result = await env.cli(["setup", "--tool", "claude-code"], { + cwd: project, + env: { HOME: home }, + }); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(await readFile(join(home, ".claude/skills/simlock/SKILL.md"), "utf8")).toBe( + renderSkill(), + ); + // why: `mode: "auto"` leaves the daemon unstarted; any daemon-touching command would have + // auto-started one and left its socket here. + expect(existsSync(env.socketPath)).toBe(false); + }); +}); diff --git a/src/instructions/index.ts b/src/instructions/index.ts index 222aa43f4..171e848f9 100644 --- a/src/instructions/index.ts +++ b/src/instructions/index.ts @@ -93,3 +93,7 @@ export function renderInstructions(format: "text" | "json"): string { ? JSON.stringify({ instructions: AGENT_INSTRUCTIONS }) : AGENT_INSTRUCTIONS; } + +export function renderSkill(): string { + return ""; +} From 104f20375f471305c723714865d0238984fe9d3f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:55:05 +0200 Subject: [PATCH 02/27] feat(cli): simlock setup writes the skill for a named tool (#620) --- src/cli/commands/setup.ts | 15 +++++++++++ src/cli/environment.ts | 2 ++ src/cli/index.test.ts | 16 ++++++++++++ src/cli/index.ts | 4 +++ src/cli/lazy-loading.test.ts | 1 + src/cli/lazy-parent-watch.test.ts | 1 + src/cli/tree.ts | 1 + src/cli/types.ts | 3 +++ src/instructions/index.ts | 12 ++++++--- src/instructions/setup.ts | 42 +++++++++++++++++++++++++++++++ src/instructions/skill.ts | 6 +++++ src/mcp/main.test.ts | 1 + 12 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 src/cli/commands/setup.ts create mode 100644 src/instructions/setup.ts create mode 100644 src/instructions/skill.ts diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts new file mode 100644 index 000000000..1894f759c --- /dev/null +++ b/src/cli/commands/setup.ts @@ -0,0 +1,15 @@ +import { setupAgentTools, type AgentTool } from "../../instructions/index.js"; +import { commandArgs } from "../args.js"; +import type { CommandContext } from "../tree.js"; + +async function run(argv: readonly string[], { environment }: CommandContext): Promise { + const values = commandArgs(argv, { tool: { type: "string" } }); + await setupAgentTools({ + filesystem: environment.filesystem, + homeDirectory: environment.homeDirectory, + tool: values.tool as AgentTool, + }); + return 0; +} + +export { run }; diff --git a/src/cli/environment.ts b/src/cli/environment.ts index c92250378..e67aaba6f 100644 --- a/src/cli/environment.ts +++ b/src/cli/environment.ts @@ -283,6 +283,8 @@ export function buildCliEnvironment( }, }), requesterId, + homeDirectory: ports.homeDirectory, + filesystem, connectAdmin: (resolveCredential) => autoLaunch.connect(resolveCredential), connectExistingAdmin: (resolveCredential) => connect(ipc, resolveCredential), daemonEndpointPresent: async () => { diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 1724ab67d..947c089a0 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1221,6 +1221,7 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { ipc: ipcTransport, launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", }); const exitCode = await runCli( ["lease", "--platform", "ios", "--device", "iPhone 17 Pro", "--detach"], @@ -1253,6 +1254,7 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { ipc: new MemoryIpcTransport(), launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", }); const exitCode = await runCli(["daemon", "start"], output.environmentWith()); expect(exitCode).toBe(1); @@ -1283,6 +1285,7 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { ipc: ipcTransport, launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", }); const exitCode = await runCli( ["lease", "--platform", "ios", "--device", "iPhone 17 Pro", "--detach"], @@ -1325,6 +1328,7 @@ describe("CLI: auto-launch's startup deadline (#663)", () => { }); }), daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", }; return { clock, ports }; } @@ -1441,6 +1445,7 @@ describe("CLI: auto-launch's failed start (#672)", () => { ipc: new MemoryIpcTransport(), launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", }); let settled = false; const exitCode = runCli(["daemon", "start", "--json"], output.environmentWith()).then( @@ -1487,6 +1492,7 @@ describe("CLI: auto-launch's daemon that exits while starting (#760)", () => { ipc: new MemoryIpcTransport(), launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1526,6 +1532,7 @@ describe("CLI: auto-launch's daemon that exits while starting (#760)", () => { ipc: new MemoryIpcTransport(), launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1586,6 +1593,7 @@ describe("CLI: auto-launch's daemon that lost the socket to another daemon (#760 ipc: ipcTransport, launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1628,6 +1636,7 @@ describe("CLI: auto-launch's handshake cut short by the launched daemon (#684)", ipc: dyingDaemon, launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", }); let settled = false; const exitCode = runCli( @@ -7880,6 +7889,8 @@ function outputCapture(ports?: CliEnvironmentPorts, env: NodeJS.ProcessEnv = {}) configPath: "/simlock/config.json", daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, requesterId: "test-requester", + homeDirectory: "/home/operator", + filesystem: new MemoryFilesystem(), connectAdmin: async () => fakeClient(), connectExistingAdmin: async () => fakeClient(), daemonEndpointPresent: async () => false, @@ -8155,6 +8166,7 @@ function realCliEnvironmentPorts( ipc: new MemoryIpcTransport(), launcher: new FakeDaemonLauncher(), daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", }; } @@ -9302,6 +9314,7 @@ describe("CLI: daemon install (#830)", () => { ipc: ipcTransport, launcher: new FakeDaemonLauncher(), daemonHome: { role: options.role, dataDirectory, gatewayPresent: false }, + homeDirectory: "/home/operator", service: { manager: serviceManager, platform: options.linux === true ? "linux" : "darwin", @@ -9715,6 +9728,7 @@ describe("CLI: a start goes through the service (#830)", () => { spawner, }), daemonHome: { role: "worker", dataDirectory: root, gatewayPresent: false }, + homeDirectory: "/home/operator", }); const exitCode = await runCli(testCase.argv, output.environmentWith()); @@ -9772,6 +9786,7 @@ describe("CLI: a start through a service that fails (#830)", () => { spawner: new FakeDaemonLauncher(), }), daemonHome: { role: "worker", dataDirectory: directory, gatewayPresent: false }, + homeDirectory: "/home/operator", }); let settled = false; const exitCode = runCli(["daemon", "start", "--json"], output.environmentWith()).then( @@ -9861,6 +9876,7 @@ describe("CLI: a service manager call fails, or the platform has none (#830)", ( spawner: new FakeDaemonLauncher(() => undefined, { exit: { code: 1, signal: null } }), }), daemonHome: { role: "worker", dataDirectory: root, gatewayPresent: false }, + homeDirectory: "/home/operator", service: { manager, platform: options.platform ?? "darwin", diff --git a/src/cli/index.ts b/src/cli/index.ts index 82980b437..28fb1dbf9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,6 +35,8 @@ Commands: adb Run adb against Simlock's adb server mcp Start the stdio MCP server instructions [--json] Print the rules an agent must follow, for its prompt + setup [--user | --project] [--tool ] [--mcp | --no-mcp] [--json] + Install the Simlock skill into Claude Code and Codex Run 'simlock --help' for command usage. Pass --token anywhere on the command line to connect as admin @@ -140,6 +142,7 @@ async function buildDefaultEnvironment(flag: DaemonFlag): Promise process.stderr.write(`${line}\n`), }), daemonHome, + homeDirectory: homedir(), service: { manager: serviceManager, platform: process.platform, @@ -165,6 +168,7 @@ const GATEWAY_EXEMPT_PATHS: readonly (readonly string[])[] = [ ["config", "set"], ["mcp"], ["instructions"], + ["setup"], ]; const GATEWAY_ALSO_HERE_HINT = diff --git a/src/cli/lazy-loading.test.ts b/src/cli/lazy-loading.test.ts index 1cde21ad1..5d8e64a6d 100644 --- a/src/cli/lazy-loading.test.ts +++ b/src/cli/lazy-loading.test.ts @@ -59,6 +59,7 @@ async function environment(): Promise { const ports: CliEnvironmentPorts = { clock: new FakeClock(0), daemonHome: { role: "worker", dataDirectory: home, gatewayPresent: false }, + homeDirectory: "/home/operator", filesystem: new MemoryFilesystem(), ipc: new MemoryIpcTransport(), launcher: new FakeDaemonLauncher(), diff --git a/src/cli/lazy-parent-watch.test.ts b/src/cli/lazy-parent-watch.test.ts index 9302ca596..a22243f96 100644 --- a/src/cli/lazy-parent-watch.test.ts +++ b/src/cli/lazy-parent-watch.test.ts @@ -42,6 +42,7 @@ describe("the default parent watch", () => { const environment = buildCliEnvironment( { clock: new FakeClock(0), + homeDirectory: "/home/operator", daemonHome: { role: "worker", dataDirectory: mkdtempSync(join(tmpdir(), "simlock-cli-lazy-watch-")), diff --git a/src/cli/tree.ts b/src/cli/tree.ts index d0b7e4866..0a9e33c52 100644 --- a/src/cli/tree.ts +++ b/src/cli/tree.ts @@ -130,4 +130,5 @@ export const COMMANDS: readonly CommandNode[] = [ { name: "adb", load: () => import("./commands/passthrough.js") }, { name: "mcp", load: () => import("./commands/mcp.js") }, { name: "instructions", load: () => import("./commands/instructions.js") }, + { name: "setup", load: () => import("./commands/setup.js") }, ]; diff --git a/src/cli/types.ts b/src/cli/types.ts index 1eafd1622..2d5b3b476 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -54,6 +54,8 @@ export interface CliEnvironment { readonly configPath: string; readonly daemonHome: DaemonHome; readonly requesterId: string; + readonly homeDirectory: string; + readonly filesystem: Filesystem; readonly connectAdmin: ( resolveCredential: () => Promise, ) => Promise; @@ -91,6 +93,7 @@ export interface CliEnvironmentPorts { readonly ipc: IpcConnector; readonly launcher: DaemonLauncher; readonly daemonHome: DaemonHome; + readonly homeDirectory: string; readonly service?: ServiceSetup; readonly parentWatch?: ParentWatch; readonly signals?: Signals; diff --git a/src/instructions/index.ts b/src/instructions/index.ts index 171e848f9..5cfc99f0b 100644 --- a/src/instructions/index.ts +++ b/src/instructions/index.ts @@ -94,6 +94,12 @@ export function renderInstructions(format: "text" | "json"): string { : AGENT_INSTRUCTIONS; } -export function renderSkill(): string { - return ""; -} +export { renderSkill } from "./skill.js"; +export { setupAgentTools } from "./setup.js"; +export type { + AgentTool, + SetupAgentToolsOptions, + SetupReport, + SetupScope, + SetupToolReport, +} from "./setup.js"; diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts new file mode 100644 index 000000000..aef06c49e --- /dev/null +++ b/src/instructions/setup.ts @@ -0,0 +1,42 @@ +import { join } from "node:path"; + +import type { Filesystem } from "../ports/index.js"; +import { renderSkill } from "./skill.js"; + +const AGENT_TOOLS = { + "claude-code": { presence: ".claude", skills: ".claude/skills" }, + codex: { presence: ".codex", skills: ".agents/skills" }, +} as const; + +export type AgentTool = keyof typeof AGENT_TOOLS; +export type SetupScope = "user" | "project"; + +const SIMLOCK_DIRECTORY = "simlock"; +const SKILL_FILE = "SKILL.md"; + +export interface SetupToolReport { + readonly tool: AgentTool; + readonly skill: { + readonly status: "wrote" | "replaced" | "skipped"; + readonly path: string; + }; +} + +export interface SetupReport { + readonly scope: SetupScope; + readonly tools: readonly SetupToolReport[]; +} + +export interface SetupAgentToolsOptions { + readonly filesystem: Filesystem; + readonly homeDirectory: string; + readonly tool: AgentTool; +} + +export async function setupAgentTools(options: SetupAgentToolsOptions): Promise { + const { filesystem, tool } = options; + const directory = join(options.homeDirectory, AGENT_TOOLS[tool].skills, SIMLOCK_DIRECTORY); + await filesystem.mkdirp(directory); + await filesystem.writeFileAtomic(join(directory, SKILL_FILE), renderSkill()); + return { scope: "user", tools: [{ tool, skill: { status: "wrote", path: directory } }] }; +} diff --git a/src/instructions/skill.ts b/src/instructions/skill.ts new file mode 100644 index 000000000..781f2e062 --- /dev/null +++ b/src/instructions/skill.ts @@ -0,0 +1,6 @@ +const SKILL_DESCRIPTION = + "Use before touching an iOS simulator, Android emulator or test phone, or running simctl, adb, emulator, xcodebuild for a simulator, or agent-device."; + +export function renderSkill(): string { + return `---\nname: simlock\ndescription: ${SKILL_DESCRIPTION}\n---\n\nRun \`simlock instructions\` and follow them.\n`; +} diff --git a/src/mcp/main.test.ts b/src/mcp/main.test.ts index b835ad5e1..93f18a0fe 100644 --- a/src/mcp/main.test.ts +++ b/src/mcp/main.test.ts @@ -327,6 +327,7 @@ describe("MCP stdio lifecycle", () => { { clock: new FakeClock(0), daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", filesystem: new MemoryFilesystem(), ipc: new MemoryIpcTransport(), launcher: new FakeDaemonLauncher(), From 3c79d371e08cb8b197f52f58dd9f6d9b4f172f05 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:55:47 +0200 Subject: [PATCH 03/27] test: setup writes the skill where each tool reads it for the scope (#620) --- src/cli/commands/setup.ts | 2 ++ src/instructions/setup.test.ts | 66 ++++++++++++++++++++++++++++++++++ src/instructions/setup.ts | 2 ++ 3 files changed, 70 insertions(+) create mode 100644 src/instructions/setup.test.ts diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 1894f759c..f56768287 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -7,6 +7,8 @@ async function run(argv: readonly string[], { environment }: CommandContext): Pr await setupAgentTools({ filesystem: environment.filesystem, homeDirectory: environment.homeDirectory, + workingDirectory: "", + scope: "user", tool: values.tool as AgentTool, }); return 0; diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts new file mode 100644 index 000000000..053d55e23 --- /dev/null +++ b/src/instructions/setup.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { MemoryFilesystem } from "../ports/index.js"; +import { renderSkill, setupAgentTools, type SetupAgentToolsOptions } from "./index.js"; + +const HOME = "/home/operator"; +const CWD = "/work/project"; + +function setup( + filesystem: MemoryFilesystem, + options: Partial> = {}, +): ReturnType { + return setupAgentTools({ + filesystem, + homeDirectory: HOME, + workingDirectory: CWD, + scope: "user", + tool: "claude-code", + ...options, + }); +} + +async function tree(filesystem: MemoryFilesystem, root: string): Promise { + if (!(await filesystem.exists(root))) return []; + const paths: string[] = []; + for (const name of await filesystem.readdir(root)) { + const path = `${root}/${name}`; + paths.push(path); + if ((await filesystem.lstat(path)).kind === "directory") + paths.push(...(await tree(filesystem, path))); + } + return paths.sort(); +} + +async function withBases(): Promise { + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp(HOME); + await filesystem.mkdirp(CWD); + return filesystem; +} + +describe("setupAgentTools", () => { + it("writes the skill where each tool reads it for the scope: home for the user scope, the working directory for the project scope", async () => { + for (const [scope, base, other] of [ + ["user", HOME, CWD], + ["project", CWD, HOME], + ] as const) { + const filesystem = await withBases(); + for (const [tool, skills] of [ + ["claude-code", ".claude/skills"], + ["codex", ".agents/skills"], + ] as const) { + const report = await setup(filesystem, { scope, tool }); + + expect(report.scope).toBe(scope); + expect(report.tools).toEqual([ + { tool, skill: { status: "wrote", path: `${base}/${skills}/simlock` } }, + ]); + expect(await filesystem.readFile(`${base}/${skills}/simlock/SKILL.md`), tool).toBe( + renderSkill(), + ); + } + expect(await tree(filesystem, other), scope).toEqual([]); + } + }); +}); diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index aef06c49e..beedadf65 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -30,6 +30,8 @@ export interface SetupReport { export interface SetupAgentToolsOptions { readonly filesystem: Filesystem; readonly homeDirectory: string; + readonly workingDirectory: string; + readonly scope: SetupScope; readonly tool: AgentTool; } From 3398afe065416313d3e47ffaab47d00b495ee682 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:56:13 +0200 Subject: [PATCH 04/27] feat(instructions): setup writes under home or the working directory by scope (#620) --- src/cli/commands/setup.ts | 6 +++--- src/cli/environment.ts | 1 + src/cli/index.test.ts | 15 +++++++++++++++ src/cli/index.ts | 1 + src/cli/lazy-loading.test.ts | 1 + src/cli/lazy-parent-watch.test.ts | 1 + src/cli/types.ts | 2 ++ src/instructions/setup.ts | 6 ++++-- src/mcp/main.test.ts | 1 + 9 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index f56768287..5f8cb4933 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -3,12 +3,12 @@ import { commandArgs } from "../args.js"; import type { CommandContext } from "../tree.js"; async function run(argv: readonly string[], { environment }: CommandContext): Promise { - const values = commandArgs(argv, { tool: { type: "string" } }); + const values = commandArgs(argv, { project: { type: "boolean" }, tool: { type: "string" } }); await setupAgentTools({ filesystem: environment.filesystem, homeDirectory: environment.homeDirectory, - workingDirectory: "", - scope: "user", + workingDirectory: environment.workingDirectory, + scope: values.project === true ? "project" : "user", tool: values.tool as AgentTool, }); return 0; diff --git a/src/cli/environment.ts b/src/cli/environment.ts index e67aaba6f..ed11bb234 100644 --- a/src/cli/environment.ts +++ b/src/cli/environment.ts @@ -284,6 +284,7 @@ export function buildCliEnvironment( }), requesterId, homeDirectory: ports.homeDirectory, + workingDirectory: ports.workingDirectory, filesystem, connectAdmin: (resolveCredential) => autoLaunch.connect(resolveCredential), connectExistingAdmin: (resolveCredential) => connect(ipc, resolveCredential), diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 947c089a0..6d261d134 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1222,6 +1222,7 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); const exitCode = await runCli( ["lease", "--platform", "ios", "--device", "iPhone 17 Pro", "--detach"], @@ -1255,6 +1256,7 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); const exitCode = await runCli(["daemon", "start"], output.environmentWith()); expect(exitCode).toBe(1); @@ -1286,6 +1288,7 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); const exitCode = await runCli( ["lease", "--platform", "ios", "--device", "iPhone 17 Pro", "--detach"], @@ -1329,6 +1332,7 @@ describe("CLI: auto-launch's startup deadline (#663)", () => { }), daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }; return { clock, ports }; } @@ -1446,6 +1450,7 @@ describe("CLI: auto-launch's failed start (#672)", () => { launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); let settled = false; const exitCode = runCli(["daemon", "start", "--json"], output.environmentWith()).then( @@ -1493,6 +1498,7 @@ describe("CLI: auto-launch's daemon that exits while starting (#760)", () => { launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1533,6 +1539,7 @@ describe("CLI: auto-launch's daemon that exits while starting (#760)", () => { launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1594,6 +1601,7 @@ describe("CLI: auto-launch's daemon that lost the socket to another daemon (#760 launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1637,6 +1645,7 @@ describe("CLI: auto-launch's handshake cut short by the launched daemon (#684)", launcher, daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); let settled = false; const exitCode = runCli( @@ -7890,6 +7899,7 @@ function outputCapture(ports?: CliEnvironmentPorts, env: NodeJS.ProcessEnv = {}) daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, requesterId: "test-requester", homeDirectory: "/home/operator", + workingDirectory: "/work/project", filesystem: new MemoryFilesystem(), connectAdmin: async () => fakeClient(), connectExistingAdmin: async () => fakeClient(), @@ -8167,6 +8177,7 @@ function realCliEnvironmentPorts( launcher: new FakeDaemonLauncher(), daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }; } @@ -9315,6 +9326,7 @@ describe("CLI: daemon install (#830)", () => { launcher: new FakeDaemonLauncher(), daemonHome: { role: options.role, dataDirectory, gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", service: { manager: serviceManager, platform: options.linux === true ? "linux" : "darwin", @@ -9729,6 +9741,7 @@ describe("CLI: a start goes through the service (#830)", () => { }), daemonHome: { role: "worker", dataDirectory: root, gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); const exitCode = await runCli(testCase.argv, output.environmentWith()); @@ -9787,6 +9800,7 @@ describe("CLI: a start through a service that fails (#830)", () => { }), daemonHome: { role: "worker", dataDirectory: directory, gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); let settled = false; const exitCode = runCli(["daemon", "start", "--json"], output.environmentWith()).then( @@ -9877,6 +9891,7 @@ describe("CLI: a service manager call fails, or the platform has none (#830)", ( }), daemonHome: { role: "worker", dataDirectory: root, gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", service: { manager, platform: options.platform ?? "darwin", diff --git a/src/cli/index.ts b/src/cli/index.ts index 28fb1dbf9..7ad8c07d6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -143,6 +143,7 @@ async function buildDefaultEnvironment(flag: DaemonFlag): Promise { clock: new FakeClock(0), daemonHome: { role: "worker", dataDirectory: home, gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", filesystem: new MemoryFilesystem(), ipc: new MemoryIpcTransport(), launcher: new FakeDaemonLauncher(), diff --git a/src/cli/lazy-parent-watch.test.ts b/src/cli/lazy-parent-watch.test.ts index a22243f96..445605f10 100644 --- a/src/cli/lazy-parent-watch.test.ts +++ b/src/cli/lazy-parent-watch.test.ts @@ -43,6 +43,7 @@ describe("the default parent watch", () => { { clock: new FakeClock(0), homeDirectory: "/home/operator", + workingDirectory: "/work/project", daemonHome: { role: "worker", dataDirectory: mkdtempSync(join(tmpdir(), "simlock-cli-lazy-watch-")), diff --git a/src/cli/types.ts b/src/cli/types.ts index 2d5b3b476..29a450f56 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -55,6 +55,7 @@ export interface CliEnvironment { readonly daemonHome: DaemonHome; readonly requesterId: string; readonly homeDirectory: string; + readonly workingDirectory: string; readonly filesystem: Filesystem; readonly connectAdmin: ( resolveCredential: () => Promise, @@ -94,6 +95,7 @@ export interface CliEnvironmentPorts { readonly launcher: DaemonLauncher; readonly daemonHome: DaemonHome; readonly homeDirectory: string; + readonly workingDirectory: string; readonly service?: ServiceSetup; readonly parentWatch?: ParentWatch; readonly signals?: Signals; diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index beedadf65..c954b50d5 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -37,8 +37,10 @@ export interface SetupAgentToolsOptions { export async function setupAgentTools(options: SetupAgentToolsOptions): Promise { const { filesystem, tool } = options; - const directory = join(options.homeDirectory, AGENT_TOOLS[tool].skills, SIMLOCK_DIRECTORY); + const { scope } = options; + const base = scope === "user" ? options.homeDirectory : options.workingDirectory; + const directory = join(base, AGENT_TOOLS[tool].skills, SIMLOCK_DIRECTORY); await filesystem.mkdirp(directory); await filesystem.writeFileAtomic(join(directory, SKILL_FILE), renderSkill()); - return { scope: "user", tools: [{ tool, skill: { status: "wrote", path: directory } }] }; + return { scope, tools: [{ tool, skill: { status: "wrote", path: directory } }] }; } diff --git a/src/mcp/main.test.ts b/src/mcp/main.test.ts index 93f18a0fe..c036fa056 100644 --- a/src/mcp/main.test.ts +++ b/src/mcp/main.test.ts @@ -328,6 +328,7 @@ describe("MCP stdio lifecycle", () => { clock: new FakeClock(0), daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", + workingDirectory: "/work/project", filesystem: new MemoryFilesystem(), ipc: new MemoryIpcTransport(), launcher: new FakeDaemonLauncher(), From ebbe42454321ff6761197baec87b0f6175c664b6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:56:37 +0200 Subject: [PATCH 05/27] test: setup picks the tools set up here, and installs a named tool anyway (#620) --- src/instructions/setup.test.ts | 38 +++++++++++++++++++++++++++++++++- src/instructions/setup.ts | 5 +++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts index 053d55e23..9e2336d02 100644 --- a/src/instructions/setup.test.ts +++ b/src/instructions/setup.test.ts @@ -15,7 +15,6 @@ function setup( homeDirectory: HOME, workingDirectory: CWD, scope: "user", - tool: "claude-code", ...options, }); } @@ -63,4 +62,41 @@ describe("setupAgentTools", () => { expect(await tree(filesystem, other), scope).toEqual([]); } }); + + it("with no tool named, installs the tools set up here and skips the others; a named tool is installed anyway", async () => { + const claudeOnly = await withBases(); + await claudeOnly.mkdirp(`${HOME}/.claude`); + + expect((await setup(claudeOnly)).tools).toEqual([ + { tool: "claude-code", skill: { status: "wrote", path: `${HOME}/.claude/skills/simlock` } }, + { tool: "codex", skill: { status: "skipped", path: `${HOME}/.agents/skills/simlock` } }, + ]); + expect(await claudeOnly.exists(`${HOME}/.claude/skills/simlock/SKILL.md`)).toBe(true); + expect(await claudeOnly.exists(`${HOME}/.agents`)).toBe(false); + + const codexFile = await withBases(); + await codexFile.mkdirp(`${HOME}/.claude`); + await codexFile.writeFileAtomic(`${HOME}/.codex`, "not a directory"); + const afterFile = await setup(codexFile); + expect(afterFile.tools.map(({ skill }) => skill.status)).toEqual(["wrote", "skipped"]); + expect(await codexFile.readFile(`${HOME}/.codex`)).toBe("not a directory"); + + const customHome = await withBases(); + await customHome.mkdirp("/custom/codex-home"); + const viaCodexHome = await setup(customHome, { codexHome: "/custom/codex-home" }); + expect(viaCodexHome.tools.map(({ skill }) => skill.status)).toEqual(["skipped", "wrote"]); + expect(await customHome.exists(`${HOME}/.agents/skills/simlock/SKILL.md`)).toBe(true); + + const bare = await withBases(); + const named = await setup(bare, { tool: "codex" }); + expect(named.tools).toEqual([ + { tool: "codex", skill: { status: "wrote", path: `${HOME}/.agents/skills/simlock` } }, + ]); + expect(await tree(bare, HOME)).toEqual([ + `${HOME}/.agents`, + `${HOME}/.agents/skills`, + `${HOME}/.agents/skills/simlock`, + `${HOME}/.agents/skills/simlock/SKILL.md`, + ]); + }); }); diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index c954b50d5..4142b99b5 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -32,11 +32,12 @@ export interface SetupAgentToolsOptions { readonly homeDirectory: string; readonly workingDirectory: string; readonly scope: SetupScope; - readonly tool: AgentTool; + readonly codexHome?: string | undefined; + readonly tool?: AgentTool | undefined; } export async function setupAgentTools(options: SetupAgentToolsOptions): Promise { - const { filesystem, tool } = options; + const { filesystem, tool = "claude-code" } = options; const { scope } = options; const base = scope === "user" ? options.homeDirectory : options.workingDirectory; const directory = join(base, AGENT_TOOLS[tool].skills, SIMLOCK_DIRECTORY); From 6a69feb5e3b5e99843a2cf7c793792407f032100 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:57:23 +0200 Subject: [PATCH 06/27] feat(instructions): setup installs the tools set up here (#620) --- src/cli/commands/setup.ts | 3 ++- src/cli/environment.ts | 1 + src/cli/index.ts | 1 + src/cli/types.ts | 2 ++ src/instructions/setup.ts | 35 +++++++++++++++++++++++++++++------ 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 5f8cb4933..cefafd7cc 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -8,8 +8,9 @@ async function run(argv: readonly string[], { environment }: CommandContext): Pr filesystem: environment.filesystem, homeDirectory: environment.homeDirectory, workingDirectory: environment.workingDirectory, + codexHome: environment.codexHome, scope: values.project === true ? "project" : "user", - tool: values.tool as AgentTool, + tool: values.tool as AgentTool | undefined, }); return 0; } diff --git a/src/cli/environment.ts b/src/cli/environment.ts index ed11bb234..42fc01f9c 100644 --- a/src/cli/environment.ts +++ b/src/cli/environment.ts @@ -285,6 +285,7 @@ export function buildCliEnvironment( requesterId, homeDirectory: ports.homeDirectory, workingDirectory: ports.workingDirectory, + codexHome: ports.codexHome, filesystem, connectAdmin: (resolveCredential) => autoLaunch.connect(resolveCredential), connectExistingAdmin: (resolveCredential) => connect(ipc, resolveCredential), diff --git a/src/cli/index.ts b/src/cli/index.ts index 7ad8c07d6..4487be932 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -144,6 +144,7 @@ async function buildDefaultEnvironment(flag: DaemonFlag): Promise Promise, @@ -96,6 +97,7 @@ export interface CliEnvironmentPorts { readonly daemonHome: DaemonHome; readonly homeDirectory: string; readonly workingDirectory: string; + readonly codexHome?: string | undefined; readonly service?: ServiceSetup; readonly parentWatch?: ParentWatch; readonly signals?: Signals; diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index 4142b99b5..2f3fd260d 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -36,12 +36,35 @@ export interface SetupAgentToolsOptions { readonly tool?: AgentTool | undefined; } +export const AGENT_TOOL_IDS = Object.keys(AGENT_TOOLS) as readonly AgentTool[]; + export async function setupAgentTools(options: SetupAgentToolsOptions): Promise { - const { filesystem, tool = "claude-code" } = options; - const { scope } = options; + const { filesystem, scope, tool } = options; const base = scope === "user" ? options.homeDirectory : options.workingDirectory; - const directory = join(base, AGENT_TOOLS[tool].skills, SIMLOCK_DIRECTORY); - await filesystem.mkdirp(directory); - await filesystem.writeFileAtomic(join(directory, SKILL_FILE), renderSkill()); - return { scope, tools: [{ tool, skill: { status: "wrote", path: directory } }] }; + const tools = tool === undefined ? AGENT_TOOL_IDS : [tool]; + + const report: SetupToolReport[] = []; + for (const id of tools) { + const layout = AGENT_TOOLS[id]; + const path = join(base, layout.skills, SIMLOCK_DIRECTORY); + const presence = + id === "codex" && scope === "user" && options.codexHome !== undefined + ? options.codexHome + : join(base, layout.presence); + const install = tool !== undefined || (await isDirectory(filesystem, presence)); + report.push({ tool: id, skill: { status: install ? "wrote" : "skipped", path } }); + } + + const skill = renderSkill(); + for (const { skill: entry } of report) { + if (entry.status === "skipped") continue; + await filesystem.mkdirp(entry.path); + await filesystem.writeFileAtomic(join(entry.path, SKILL_FILE), skill); + } + return { scope, tools: report }; +} + +async function isDirectory(filesystem: Filesystem, path: string): Promise { + if (!(await filesystem.exists(path))) return false; + return (await filesystem.stat(path)).kind === "directory"; } From e7d7a01ae2a63c1a8fe52f9aa637ad2172514af9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:58:11 +0200 Subject: [PATCH 07/27] test: setup connects MCP through the tool's own command (#620) --- src/cli/commands/setup.ts | 1 + src/instructions/index.ts | 4 +- src/instructions/setup.test.ts | 194 ++++++++++++++++++++++++++++++++- src/instructions/setup.ts | 30 ++++- 4 files changed, 225 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index cefafd7cc..09e367719 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -9,6 +9,7 @@ async function run(argv: readonly string[], { environment }: CommandContext): Pr homeDirectory: environment.homeDirectory, workingDirectory: environment.workingDirectory, codexHome: environment.codexHome, + runTool: async () => ({ outcome: "not-found" }), scope: values.project === true ? "project" : "user", tool: values.tool as AgentTool | undefined, }); diff --git a/src/instructions/index.ts b/src/instructions/index.ts index 5cfc99f0b..730b216ab 100644 --- a/src/instructions/index.ts +++ b/src/instructions/index.ts @@ -95,11 +95,13 @@ export function renderInstructions(format: "text" | "json"): string { } export { renderSkill } from "./skill.js"; -export { setupAgentTools } from "./setup.js"; +export { SetupMcpFailedError, setupAgentTools, toolRunner } from "./setup.js"; export type { AgentTool, SetupAgentToolsOptions, SetupReport, SetupScope, SetupToolReport, + ToolOutcome, + RunTool, } from "./setup.js"; diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts index 9e2336d02..6276535bc 100644 --- a/src/instructions/setup.test.ts +++ b/src/instructions/setup.test.ts @@ -1,11 +1,38 @@ import { describe, expect, it } from "vitest"; -import { MemoryFilesystem } from "../ports/index.js"; -import { renderSkill, setupAgentTools, type SetupAgentToolsOptions } from "./index.js"; +import { MemoryFilesystem, ProcessSpawnError, type ProcessRunner } from "../ports/index.js"; +import { + renderSkill, + SetupMcpFailedError, + setupAgentTools, + toolRunner, + type SetupAgentToolsOptions, + type ToolOutcome, +} from "./index.js"; const HOME = "/home/operator"; const CWD = "/work/project"; +interface ToolCall { + readonly bin: string; + readonly args: readonly string[]; + readonly cwd: string; +} + +function fakeTools(outcome: ToolOutcome = { outcome: "exited", code: 0, stderr: "" }): { + readonly calls: ToolCall[]; + readonly runTool: SetupAgentToolsOptions["runTool"]; +} { + const calls: ToolCall[] = []; + return { + calls, + runTool: async (bin, args, { cwd }) => { + calls.push({ bin, args, cwd }); + return outcome; + }, + }; +} + function setup( filesystem: MemoryFilesystem, options: Partial> = {}, @@ -15,6 +42,7 @@ function setup( homeDirectory: HOME, workingDirectory: CWD, scope: "user", + runTool: fakeTools().runTool, ...options, }); } @@ -99,4 +127,166 @@ describe("setupAgentTools", () => { `${HOME}/.agents/skills/simlock/SKILL.md`, ]); }); + + it("connects MCP through the tool's own command, reports each outcome, and bounds the command at 30 s", async () => { + const claudeAdd = (scope: string): readonly string[] => [ + "mcp", + "add", + "-s", + scope, + "simlock", + "--", + "simlock", + "mcp", + ]; + const codexAdd = ["mcp", "add", "simlock", "--", "simlock", "mcp"]; + + for (const [scope, tool, bin, args] of [ + ["user", "claude-code", "claude", claudeAdd("user")], + ["project", "claude-code", "claude", claudeAdd("project")], + ["user", "codex", "codex", codexAdd], + ] as const) { + const tools = fakeTools(); + const report = await setup(await withBases(), { + scope, + tool, + mcp: true, + runTool: tools.runTool, + }); + expect(tools.calls, `${scope} ${tool}`).toEqual([{ bin, args, cwd: CWD }]); + expect(report.tools[0]?.mcp, `${scope} ${tool}`).toEqual({ status: "connected" }); + } + + const long = `${"x".repeat(250)}\nsecond line`; + for (const [tool, outcome, mcp] of [ + [ + "claude-code", + { + outcome: "exited", + code: 1, + stderr: "MCP server simlock already exists in user config\n", + }, + { status: "already" }, + ], + [ + "codex", + { + outcome: "exited", + code: 1, + stderr: "MCP server simlock already exists in user config\n", + }, + { status: "failed", reason: "MCP server simlock already exists in user config" }, + ], + [ + "claude-code", + { outcome: "exited", code: 1, stderr: "boom: no config\nmore detail\n" }, + { status: "failed", reason: "boom: no config" }, + ], + [ + "claude-code", + { outcome: "exited", code: 1, stderr: long }, + { status: "failed", reason: "x".repeat(200) }, + ], + [ + "claude-code", + { outcome: "timed-out" }, + { status: "failed", reason: "timed out after 30 s" }, + ], + ["claude-code", { outcome: "not-found" }, { status: "skipped", reason: "claude not found" }], + ["codex", { outcome: "not-found" }, { status: "skipped", reason: "codex not found" }], + ] as const) { + const run = setup(await withBases(), { + tool, + mcp: true, + runTool: fakeTools(outcome).runTool, + }); + const label = `${tool} ${JSON.stringify(outcome)}`; + if (mcp.status === "failed") { + const error = (await run.catch((caught: unknown) => caught)) as SetupMcpFailedError; + expect(error, label).toBeInstanceOf(SetupMcpFailedError); + expect(error.message, label).toBe(`MCP not connected for ${tool}: ${mcp.reason}`); + expect(error.report.tools[0]?.mcp, label).toEqual(mcp); + expect(error.report.tools[0]?.skill.status, label).toBe("wrote"); + } else { + expect((await run).tools[0]?.mcp, label).toEqual(mcp); + } + } + + const both = await withBases(); + await both.mkdirp(`${HOME}/.claude`); + await both.mkdirp(`${HOME}/.codex`); + const failing = fakeTools({ outcome: "exited", code: 1, stderr: "nope" }); + const error = (await setup(both, { mcp: true, runTool: failing.runTool }).catch( + (caught: unknown) => caught, + )) as SetupMcpFailedError; + expect(error.message).toBe( + "MCP not connected for claude-code: nope; MCP not connected for codex: nope", + ); + + const projectCodex = fakeTools(); + const skipped = await setup(await withBases(), { + scope: "project", + tool: "codex", + mcp: true, + runTool: projectCodex.runTool, + }); + expect(skipped.tools[0]?.mcp).toEqual({ + status: "skipped", + reason: "codex mcp add has no project scope", + }); + + const unrequested = fakeTools(); + const notAsked = await setup(await withBases(), { + tool: "claude-code", + mcp: false, + runTool: unrequested.runTool, + }); + expect(notAsked.tools[0]).not.toHaveProperty("mcp"); + const notInstalled = await withBases(); + await notInstalled.mkdirp(`${HOME}/.claude`); + const skippedSkill = await setup(notInstalled, { mcp: true, runTool: unrequested.runTool }); + expect(skippedSkill.tools[1]).toEqual({ + tool: "codex", + skill: { status: "skipped", path: `${HOME}/.agents/skills/simlock` }, + }); + expect(unrequested.calls.map(({ bin }) => bin)).toEqual(["claude"]); + expect(projectCodex.calls).toEqual([]); + + const seen: { command: string; args: readonly string[]; options: unknown }[] = []; + const runner = ( + behave: () => Promise<{ code: number | null; stderr: string }>, + ): ProcessRunner => + ({ + run: async (command: string, args: readonly string[], options: unknown) => { + seen.push({ command, args, options }); + return { stdout: "", ...(await behave()) }; + }, + spawn: () => { + throw new Error("unused"); + }, + spawnStreaming: () => { + throw new Error("unused"); + }, + }) as ProcessRunner; + + const missing = toolRunner( + runner(async () => { + throw new ProcessSpawnError("claude", ["mcp"]); + }), + ); + expect(await missing("claude", ["mcp"], { cwd: CWD })).toEqual({ outcome: "not-found" }); + const killed = toolRunner(runner(async () => ({ code: null, stderr: "" }))); + expect(await killed("claude", [], { cwd: CWD })).toEqual({ outcome: "timed-out" }); + const exited = toolRunner(runner(async () => ({ code: 1, stderr: "bad" }))); + expect(await exited("claude", ["a"], { cwd: "/somewhere" })).toEqual({ + outcome: "exited", + code: 1, + stderr: "bad", + }); + expect(seen.at(-1)).toEqual({ + command: "claude", + args: ["a"], + options: { timeoutMs: 30_000, cwd: "/somewhere" }, + }); + }); }); diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index 2f3fd260d..55da1a48d 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; -import type { Filesystem } from "../ports/index.js"; +import type { Filesystem, ProcessRunner } from "../ports/index.js"; import { renderSkill } from "./skill.js"; const AGENT_TOOLS = { @@ -16,12 +16,38 @@ const SKILL_FILE = "SKILL.md"; export interface SetupToolReport { readonly tool: AgentTool; + readonly mcp?: { + readonly status: "connected" | "already" | "failed" | "skipped"; + readonly reason?: string; + }; readonly skill: { readonly status: "wrote" | "replaced" | "skipped"; readonly path: string; }; } +export type ToolOutcome = + | { readonly outcome: "exited"; readonly code: number; readonly stderr: string } + | { readonly outcome: "not-found" } + | { readonly outcome: "timed-out" }; + +export type RunTool = ( + bin: string, + args: readonly string[], + options: { readonly cwd: string }, +) => Promise; + +export function toolRunner(_runner: ProcessRunner): RunTool { + return async () => ({ outcome: "not-found" }); +} + +export class SetupMcpFailedError extends Error { + constructor(readonly report: SetupReport) { + super(""); + this.name = "SetupMcpFailedError"; + } +} + export interface SetupReport { readonly scope: SetupScope; readonly tools: readonly SetupToolReport[]; @@ -32,6 +58,8 @@ export interface SetupAgentToolsOptions { readonly homeDirectory: string; readonly workingDirectory: string; readonly scope: SetupScope; + readonly runTool: RunTool; + readonly mcp?: boolean | undefined; readonly codexHome?: string | undefined; readonly tool?: AgentTool | undefined; } From bc334075a42e019b749b17b87fbfb25440c374f6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:59:19 +0200 Subject: [PATCH 08/27] feat(instructions): setup connects MCP through the tool's own command (#620) --- src/cli/commands/setup.ts | 2 +- src/cli/environment.ts | 1 + src/cli/index.test.ts | 15 +++++++ src/cli/index.ts | 4 ++ src/cli/lazy-loading.test.ts | 1 + src/cli/lazy-parent-watch.test.ts | 1 + src/cli/types.ts | 3 ++ src/instructions/setup.ts | 69 ++++++++++++++++++++++++++++--- src/mcp/main.test.ts | 1 + 9 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 09e367719..7d01acde5 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -9,7 +9,7 @@ async function run(argv: readonly string[], { environment }: CommandContext): Pr homeDirectory: environment.homeDirectory, workingDirectory: environment.workingDirectory, codexHome: environment.codexHome, - runTool: async () => ({ outcome: "not-found" }), + runTool: environment.runTool, scope: values.project === true ? "project" : "user", tool: values.tool as AgentTool | undefined, }); diff --git a/src/cli/environment.ts b/src/cli/environment.ts index 42fc01f9c..58fd592b8 100644 --- a/src/cli/environment.ts +++ b/src/cli/environment.ts @@ -286,6 +286,7 @@ export function buildCliEnvironment( homeDirectory: ports.homeDirectory, workingDirectory: ports.workingDirectory, codexHome: ports.codexHome, + runTool: ports.runTool, filesystem, connectAdmin: (resolveCredential) => autoLaunch.connect(resolveCredential), connectExistingAdmin: (resolveCredential) => connect(ipc, resolveCredential), diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 6d261d134..658bc0f30 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1223,6 +1223,7 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); const exitCode = await runCli( ["lease", "--platform", "ios", "--device", "iPhone 17 Pro", "--detach"], @@ -1257,6 +1258,7 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); const exitCode = await runCli(["daemon", "start"], output.environmentWith()); expect(exitCode).toBe(1); @@ -1289,6 +1291,7 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); const exitCode = await runCli( ["lease", "--platform", "ios", "--device", "iPhone 17 Pro", "--detach"], @@ -1333,6 +1336,7 @@ describe("CLI: auto-launch's startup deadline (#663)", () => { daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }; return { clock, ports }; } @@ -1451,6 +1455,7 @@ describe("CLI: auto-launch's failed start (#672)", () => { daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); let settled = false; const exitCode = runCli(["daemon", "start", "--json"], output.environmentWith()).then( @@ -1499,6 +1504,7 @@ describe("CLI: auto-launch's daemon that exits while starting (#760)", () => { daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1540,6 +1546,7 @@ describe("CLI: auto-launch's daemon that exits while starting (#760)", () => { daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1602,6 +1609,7 @@ describe("CLI: auto-launch's daemon that lost the socket to another daemon (#760 daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1646,6 +1654,7 @@ describe("CLI: auto-launch's handshake cut short by the launched daemon (#684)", daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); let settled = false; const exitCode = runCli( @@ -7900,6 +7909,7 @@ function outputCapture(ports?: CliEnvironmentPorts, env: NodeJS.ProcessEnv = {}) requesterId: "test-requester", homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), filesystem: new MemoryFilesystem(), connectAdmin: async () => fakeClient(), connectExistingAdmin: async () => fakeClient(), @@ -8178,6 +8188,7 @@ function realCliEnvironmentPorts( daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }; } @@ -9327,6 +9338,7 @@ describe("CLI: daemon install (#830)", () => { daemonHome: { role: options.role, dataDirectory, gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), service: { manager: serviceManager, platform: options.linux === true ? "linux" : "darwin", @@ -9742,6 +9754,7 @@ describe("CLI: a start goes through the service (#830)", () => { daemonHome: { role: "worker", dataDirectory: root, gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); const exitCode = await runCli(testCase.argv, output.environmentWith()); @@ -9801,6 +9814,7 @@ describe("CLI: a start through a service that fails (#830)", () => { daemonHome: { role: "worker", dataDirectory: directory, gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); let settled = false; const exitCode = runCli(["daemon", "start", "--json"], output.environmentWith()).then( @@ -9892,6 +9906,7 @@ describe("CLI: a service manager call fails, or the platform has none (#830)", ( daemonHome: { role: "worker", dataDirectory: root, gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), service: { manager, platform: options.platform ?? "darwin", diff --git a/src/cli/index.ts b/src/cli/index.ts index 4487be932..748679278 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -145,6 +145,10 @@ async function buildDefaultEnvironment(flag: DaemonFlag): Promise { + const { toolRunner } = await import("../instructions/index.js"); + return toolRunner(new NodeProcessRunner())(bin, args, options); + }, service: { manager: serviceManager, platform: process.platform, diff --git a/src/cli/lazy-loading.test.ts b/src/cli/lazy-loading.test.ts index 9f42116c4..74e464286 100644 --- a/src/cli/lazy-loading.test.ts +++ b/src/cli/lazy-loading.test.ts @@ -61,6 +61,7 @@ async function environment(): Promise { daemonHome: { role: "worker", dataDirectory: home, gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), filesystem: new MemoryFilesystem(), ipc: new MemoryIpcTransport(), launcher: new FakeDaemonLauncher(), diff --git a/src/cli/lazy-parent-watch.test.ts b/src/cli/lazy-parent-watch.test.ts index 445605f10..359b3f08b 100644 --- a/src/cli/lazy-parent-watch.test.ts +++ b/src/cli/lazy-parent-watch.test.ts @@ -44,6 +44,7 @@ describe("the default parent watch", () => { clock: new FakeClock(0), homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), daemonHome: { role: "worker", dataDirectory: mkdtempSync(join(tmpdir(), "simlock-cli-lazy-watch-")), diff --git a/src/cli/types.ts b/src/cli/types.ts index 15b528778..939ebe682 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -3,6 +3,7 @@ import type { SimlockAdminClient } from "../admin/index.js"; import type { EventEnvelope } from "../bus/index.js"; import type { PassthroughCommand } from "../client/index.js"; +import type { RunTool } from "../instructions/index.js"; import type { Clock, DaemonFlag, @@ -57,6 +58,7 @@ export interface CliEnvironment { readonly homeDirectory: string; readonly workingDirectory: string; readonly codexHome?: string | undefined; + readonly runTool: RunTool; readonly filesystem: Filesystem; readonly connectAdmin: ( resolveCredential: () => Promise, @@ -98,6 +100,7 @@ export interface CliEnvironmentPorts { readonly homeDirectory: string; readonly workingDirectory: string; readonly codexHome?: string | undefined; + readonly runTool: RunTool; readonly service?: ServiceSetup; readonly parentWatch?: ParentWatch; readonly signals?: Signals; diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index 55da1a48d..ef3c6b70f 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; -import type { Filesystem, ProcessRunner } from "../ports/index.js"; +import { ProcessSpawnError, type Filesystem, type ProcessRunner } from "../ports/index.js"; import { renderSkill } from "./skill.js"; const AGENT_TOOLS = { @@ -37,13 +37,30 @@ export type RunTool = ( options: { readonly cwd: string }, ) => Promise; -export function toolRunner(_runner: ProcessRunner): RunTool { - return async () => ({ outcome: "not-found" }); +const TOOL_TIMEOUT_MS = 30_000; +const MAX_REASON_LENGTH = 200; + +export function toolRunner(runner: ProcessRunner): RunTool { + return async (bin, args, { cwd }) => { + try { + const result = await runner.run(bin, args, { timeoutMs: TOOL_TIMEOUT_MS, cwd }); + if (result.code === null) return { outcome: "timed-out" }; + return { outcome: "exited", code: result.code, stderr: result.stderr }; + } catch (error: unknown) { + if (error instanceof ProcessSpawnError) return { outcome: "not-found" }; + throw error; + } + }; } export class SetupMcpFailedError extends Error { constructor(readonly report: SetupReport) { - super(""); + super( + report.tools + .filter(({ mcp }) => mcp?.status === "failed") + .map(({ tool, mcp }) => `MCP not connected for ${tool}: ${mcp?.reason}`) + .join("; "), + ); this.name = "SetupMcpFailedError"; } } @@ -89,7 +106,49 @@ export async function setupAgentTools(options: SetupAgentToolsOptions): Promise< await filesystem.mkdirp(entry.path); await filesystem.writeFileAtomic(join(entry.path, SKILL_FILE), skill); } - return { scope, tools: report }; + const connected: SetupToolReport[] = []; + for (const entry of report) { + const mcp = + options.mcp === true && entry.skill.status !== "skipped" + ? await connectMcp(options.runTool, entry.tool, scope, options.workingDirectory) + : undefined; + connected.push(mcp === undefined ? entry : { ...entry, mcp }); + } + const result: SetupReport = { scope, tools: connected }; + if (connected.some(({ mcp }) => mcp?.status === "failed")) throw new SetupMcpFailedError(result); + return result; +} + +type McpResult = NonNullable; + +async function connectMcp( + runTool: RunTool, + tool: AgentTool, + scope: SetupScope, + cwd: string, +): Promise { + if (tool === "codex" && scope === "project") { + return { status: "skipped", reason: "codex mcp add has no project scope" }; + } + const bin = tool === "claude-code" ? "claude" : "codex"; + const args = + tool === "claude-code" + ? ["mcp", "add", "-s", scope, "simlock", "--", "simlock", "mcp"] + : ["mcp", "add", "simlock", "--", "simlock", "mcp"]; + const outcome = await runTool(bin, args, { cwd }); + if (outcome.outcome === "not-found") return { status: "skipped", reason: `${bin} not found` }; + if (outcome.outcome === "timed-out") { + return { status: "failed", reason: "timed out after 30 s" }; + } + if (outcome.code === 0) return { status: "connected" }; + if (tool === "claude-code" && outcome.stderr.includes("already exists in")) { + return { status: "already" }; + } + return { status: "failed", reason: firstLine(outcome.stderr) }; +} + +function firstLine(text: string): string { + return (text.split("\n")[0] ?? "").slice(0, MAX_REASON_LENGTH); } async function isDirectory(filesystem: Filesystem, path: string): Promise { diff --git a/src/mcp/main.test.ts b/src/mcp/main.test.ts index c036fa056..162dc78c7 100644 --- a/src/mcp/main.test.ts +++ b/src/mcp/main.test.ts @@ -329,6 +329,7 @@ describe("MCP stdio lifecycle", () => { daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, homeDirectory: "/home/operator", workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), filesystem: new MemoryFilesystem(), ipc: new MemoryIpcTransport(), launcher: new FakeDaemonLauncher(), From 986efcfa8aab2c2d140d7912ba98f01c9a3e6eef Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:00:04 +0200 Subject: [PATCH 09/27] test: setup asks in a terminal and never blocks a script (#620) --- src/cli/commands/setup.ts | 2 + src/instructions/setup.test.ts | 90 ++++++++++++++++++++++++++++++++++ src/instructions/setup.ts | 7 ++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 7d01acde5..144755b64 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -11,6 +11,8 @@ async function run(argv: readonly string[], { environment }: CommandContext): Pr codexHome: environment.codexHome, runTool: environment.runTool, scope: values.project === true ? "project" : "user", + confirm: async () => false, + interactive: false, tool: values.tool as AgentTool | undefined, }); return 0; diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts index 6276535bc..946433909 100644 --- a/src/instructions/setup.test.ts +++ b/src/instructions/setup.test.ts @@ -33,6 +33,25 @@ function fakeTools(outcome: ToolOutcome = { outcome: "exited", code: 0, stderr: }; } +function fakeConfirm(yes: readonly string[] = []): { + readonly questions: string[]; + readonly confirm: (question: string) => Promise; +} { + const questions: string[] = []; + return { + questions, + confirm: async (question) => { + questions.push(question); + return yes.includes(question); + }, + }; +} + +const SCOPE_PROMPT = "Install only for this project (not every project)? [y/N] "; +const CLAUDE_PROMPT = "Also connect Simlock's MCP server to Claude Code? [y/N] "; +const CODEX_PROMPT = + "Also connect Simlock's MCP server to Codex? Codex counts it as a second agent beside the CLI. [y/N] "; + function setup( filesystem: MemoryFilesystem, options: Partial> = {}, @@ -43,6 +62,8 @@ function setup( workingDirectory: CWD, scope: "user", runTool: fakeTools().runTool, + confirm: fakeConfirm().confirm, + interactive: false, ...options, }); } @@ -289,4 +310,73 @@ describe("setupAgentTools", () => { options: { timeoutMs: 30_000, cwd: "/somewhere" }, }); }); + + it("asks in a terminal and never blocks a script: the scope, then MCP per tool", async () => { + const both = async (): Promise => { + const filesystem = await withBases(); + for (const base of [HOME, CWD]) { + await filesystem.mkdirp(`${base}/.claude`); + await filesystem.mkdirp(`${base}/.codex`); + } + return filesystem; + }; + + const yesToScope = fakeConfirm([SCOPE_PROMPT]); + const project = await setup(await both(), { interactive: true, confirm: yesToScope.confirm }); + expect(project.scope).toBe("project"); + expect(yesToScope.questions).toEqual([SCOPE_PROMPT, CLAUDE_PROMPT]); + + const defaults = fakeConfirm(); + const tools = fakeTools(); + const user = await setup(await both(), { + interactive: true, + confirm: defaults.confirm, + runTool: tools.runTool, + }); + expect(user.scope).toBe("user"); + expect(defaults.questions).toEqual([SCOPE_PROMPT, CLAUDE_PROMPT, CODEX_PROMPT]); + expect(user.tools.map((entry) => "mcp" in entry)).toEqual([false, false]); + expect(tools.calls).toEqual([]); + + for (const scope of ["user", "project"] as const) { + const flagged = fakeConfirm(); + await setup(await both(), { scope, interactive: true, confirm: flagged.confirm }); + expect(flagged.questions, scope).not.toContain(SCOPE_PROMPT); + } + + const mcpYes = fakeConfirm([CLAUDE_PROMPT, CODEX_PROMPT]); + const asked = fakeTools(); + const connected = await setup(await both(), { + interactive: true, + confirm: mcpYes.confirm, + runTool: asked.runTool, + }); + expect(connected.tools.map(({ mcp }) => mcp?.status)).toEqual(["connected", "connected"]); + expect(asked.calls.map(({ bin }) => bin)).toEqual(["claude", "codex"]); + + const noMcpAsked = fakeConfirm(); + await setup(await both(), { mcp: false, interactive: true, confirm: noMcpAsked.confirm }); + expect(noMcpAsked.questions).toEqual([SCOPE_PROMPT]); + + const script = fakeConfirm([SCOPE_PROMPT, CLAUDE_PROMPT]); + const scripted = fakeTools(); + const quiet = await setup(await both(), { + interactive: false, + confirm: script.confirm, + runTool: scripted.runTool, + }); + expect(script.questions).toEqual([]); + expect(quiet.scope).toBe("user"); + expect(quiet.tools.map((entry) => "mcp" in entry)).toEqual([false, false]); + expect(scripted.calls).toEqual([]); + + const projectAsk = fakeConfirm([CLAUDE_PROMPT, CODEX_PROMPT]); + const atProject = await setup(await both(), { + scope: "project", + interactive: true, + confirm: projectAsk.confirm, + }); + expect(projectAsk.questions).toEqual([CLAUDE_PROMPT]); + expect(atProject.tools.map(({ mcp }) => mcp?.status)).toEqual(["connected", undefined]); + }); }); diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index ef3c6b70f..7f2bfc3ef 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -74,7 +74,9 @@ export interface SetupAgentToolsOptions { readonly filesystem: Filesystem; readonly homeDirectory: string; readonly workingDirectory: string; - readonly scope: SetupScope; + readonly scope?: SetupScope | undefined; + readonly confirm: (question: string) => Promise; + readonly interactive: boolean; readonly runTool: RunTool; readonly mcp?: boolean | undefined; readonly codexHome?: string | undefined; @@ -84,7 +86,8 @@ export interface SetupAgentToolsOptions { export const AGENT_TOOL_IDS = Object.keys(AGENT_TOOLS) as readonly AgentTool[]; export async function setupAgentTools(options: SetupAgentToolsOptions): Promise { - const { filesystem, scope, tool } = options; + const { filesystem, tool } = options; + const scope = options.scope ?? "user"; const base = scope === "user" ? options.homeDirectory : options.workingDirectory; const tools = tool === undefined ? AGENT_TOOL_IDS : [tool]; From fb55a5bb9c805f9b08318bddc087714e9043beb5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:00:27 +0200 Subject: [PATCH 10/27] feat(instructions): setup asks for the scope and for MCP in a terminal (#620) --- src/instructions/setup.ts | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index 7f2bfc3ef..02356be0f 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -11,6 +11,12 @@ const AGENT_TOOLS = { export type AgentTool = keyof typeof AGENT_TOOLS; export type SetupScope = "user" | "project"; +const SCOPE_PROMPT = "Install only for this project (not every project)? [y/N] "; +const MCP_PROMPTS: Readonly> = { + "claude-code": "Also connect Simlock's MCP server to Claude Code? [y/N] ", + codex: + "Also connect Simlock's MCP server to Codex? Codex counts it as a second agent beside the CLI. [y/N] ", +}; const SIMLOCK_DIRECTORY = "simlock"; const SKILL_FILE = "SKILL.md"; @@ -87,7 +93,9 @@ export const AGENT_TOOL_IDS = Object.keys(AGENT_TOOLS) as readonly AgentTool[]; export async function setupAgentTools(options: SetupAgentToolsOptions): Promise { const { filesystem, tool } = options; - const scope = options.scope ?? "user"; + const scope = + options.scope ?? + (options.interactive && (await options.confirm(SCOPE_PROMPT)) ? "project" : "user"); const base = scope === "user" ? options.homeDirectory : options.workingDirectory; const tools = tool === undefined ? AGENT_TOOL_IDS : [tool]; @@ -103,6 +111,20 @@ export async function setupAgentTools(options: SetupAgentToolsOptions): Promise< report.push({ tool: id, skill: { status: install ? "wrote" : "skipped", path } }); } + const wantsMcp = new Set(); + for (const { tool: id, skill: entry } of report) { + if (entry.status === "skipped") continue; + if (options.mcp === true) wantsMcp.add(id); + else if ( + options.mcp === undefined && + options.interactive && + !(id === "codex" && scope === "project") && + (await options.confirm(MCP_PROMPTS[id])) + ) { + wantsMcp.add(id); + } + } + const skill = renderSkill(); for (const { skill: entry } of report) { if (entry.status === "skipped") continue; @@ -111,10 +133,9 @@ export async function setupAgentTools(options: SetupAgentToolsOptions): Promise< } const connected: SetupToolReport[] = []; for (const entry of report) { - const mcp = - options.mcp === true && entry.skill.status !== "skipped" - ? await connectMcp(options.runTool, entry.tool, scope, options.workingDirectory) - : undefined; + const mcp = wantsMcp.has(entry.tool) + ? await connectMcp(options.runTool, entry.tool, scope, options.workingDirectory) + : undefined; connected.push(mcp === undefined ? entry : { ...entry, mcp }); } const result: SetupReport = { scope, tools: connected }; From 2e296d9ec01b4f1b0e5da5f850591bcfa6384054 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:00:47 +0200 Subject: [PATCH 11/27] fix(instructions): the setup test leaves the scope to the prompt (#620) --- src/instructions/setup.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts index 946433909..0c2d039c0 100644 --- a/src/instructions/setup.test.ts +++ b/src/instructions/setup.test.ts @@ -60,7 +60,6 @@ function setup( filesystem, homeDirectory: HOME, workingDirectory: CWD, - scope: "user", runTool: fakeTools().runTool, confirm: fakeConfirm().confirm, interactive: false, From c22954715d2f81c5bcb33959eb8f5ed1660aa90a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:01:11 +0200 Subject: [PATCH 12/27] test: a second setup run replaces only its own files (#620) --- src/instructions/setup.test.ts | 62 +++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts index 0c2d039c0..13d20a784 100644 --- a/src/instructions/setup.test.ts +++ b/src/instructions/setup.test.ts @@ -1,6 +1,15 @@ +import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; -import { MemoryFilesystem, ProcessSpawnError, type ProcessRunner } from "../ports/index.js"; +import { + MemoryFilesystem, + NodeFilesystem, + ProcessSpawnError, + type ProcessRunner, +} from "../ports/index.js"; import { renderSkill, SetupMcpFailedError, @@ -378,4 +387,55 @@ describe("setupAgentTools", () => { expect(projectAsk.questions).toEqual([CLAUDE_PROMPT]); expect(atProject.tools.map(({ mcp }) => mcp?.status)).toEqual(["connected", undefined]); }); + + it("a second run replaces only its own files: same bytes, stale entries gone, a SKILL.md directory turned into the file", async () => { + const filesystem = await withBases(); + await filesystem.mkdirp(`${HOME}/.claude`); + await filesystem.mkdirp(`${HOME}/.codex`); + await setup(filesystem); + const first = await tree(filesystem, HOME); + + const second = await setup(filesystem); + + expect(second.tools.map(({ skill }) => skill.status)).toEqual(["replaced", "replaced"]); + expect(await tree(filesystem, HOME)).toEqual(first); + + await filesystem.mkdirp(`${HOME}/.claude/skills/simlock/old`); + await filesystem.writeFileAtomic(`${HOME}/.claude/skills/simlock/old/notes.md`, "stale"); + await filesystem.writeFileAtomic(`${HOME}/.claude/skills/simlock/SKILL.md`, "old rules"); + await filesystem.writeFileAtomic(`${HOME}/.claude/skills/other.md`, "not ours"); + await setup(filesystem, { tool: "claude-code" }); + expect(await tree(filesystem, `${HOME}/.claude/skills`)).toEqual([ + `${HOME}/.claude/skills/other.md`, + `${HOME}/.claude/skills/simlock`, + `${HOME}/.claude/skills/simlock/SKILL.md`, + ]); + expect(await filesystem.readFile(`${HOME}/.claude/skills/simlock/SKILL.md`)).toBe( + renderSkill(), + ); + expect(await filesystem.readFile(`${HOME}/.claude/skills/other.md`)).toBe("not ours"); + + // why: a rename onto a directory fails on the real filesystem, and the in-memory double + // would let it through. + const root = await mkdtemp(join(tmpdir(), "simlock-setup-")); + try { + const simlock = join(root, ".agents/skills/simlock"); + await mkdir(join(simlock, "SKILL.md/nested"), { recursive: true }); + const report = await setupAgentTools({ + filesystem: new NodeFilesystem(), + homeDirectory: root, + workingDirectory: CWD, + scope: "user", + tool: "codex", + runTool: fakeTools().runTool, + confirm: fakeConfirm().confirm, + interactive: false, + }); + expect(report.tools.map(({ skill }) => skill.status)).toEqual(["replaced"]); + expect(await readdir(simlock)).toEqual(["SKILL.md"]); + expect(await readFile(join(simlock, "SKILL.md"), "utf8")).toBe(renderSkill()); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); From 49839d9c3b8f67bae8370d41ac6eaf7629b6fdfb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:01:36 +0200 Subject: [PATCH 13/27] feat(instructions): a second setup run replaces its own files only (#620) --- src/instructions/setup.ts | 44 +++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index 02356be0f..cb7c373c4 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -1,6 +1,12 @@ import { join } from "node:path"; -import { ProcessSpawnError, type Filesystem, type ProcessRunner } from "../ports/index.js"; +import { + isMissingPathError, + ProcessSpawnError, + type Filesystem, + type PathDetails, + type ProcessRunner, +} from "../ports/index.js"; import { renderSkill } from "./skill.js"; const AGENT_TOOLS = { @@ -108,7 +114,12 @@ export async function setupAgentTools(options: SetupAgentToolsOptions): Promise< ? options.codexHome : join(base, layout.presence); const install = tool !== undefined || (await isDirectory(filesystem, presence)); - report.push({ tool: id, skill: { status: install ? "wrote" : "skipped", path } }); + const status = !install + ? "skipped" + : (await lstatKind(filesystem, path)) !== undefined + ? "replaced" + : "wrote"; + report.push({ tool: id, skill: { status, path } }); } const wantsMcp = new Set(); @@ -128,8 +139,7 @@ export async function setupAgentTools(options: SetupAgentToolsOptions): Promise< const skill = renderSkill(); for (const { skill: entry } of report) { if (entry.status === "skipped") continue; - await filesystem.mkdirp(entry.path); - await filesystem.writeFileAtomic(join(entry.path, SKILL_FILE), skill); + await installSkill(filesystem, entry.path, skill); } const connected: SetupToolReport[] = []; for (const entry of report) { @@ -179,3 +189,29 @@ async function isDirectory(filesystem: Filesystem, path: string): Promise { + await filesystem.mkdirp(directory); + const skillPath = join(directory, SKILL_FILE); + if ((await lstatKind(filesystem, skillPath)) !== "file") await filesystem.rm(skillPath); + await filesystem.writeFileAtomic(skillPath, skill); + for (const name of await filesystem.readdir(directory)) { + if (name !== SKILL_FILE) await filesystem.rm(join(directory, name)); + } +} + +async function lstatKind( + filesystem: Filesystem, + path: string, +): Promise { + try { + return (await filesystem.lstat(path)).kind; + } catch (error: unknown) { + if (isMissingPathError(error)) return undefined; + throw error; + } +} From 8af1e9a04ed95fd576ca58b885d3ad14751a2ec9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:01:53 +0200 Subject: [PATCH 14/27] test: setup refuses a file or a symlink where the skill directory goes (#620) --- src/instructions/index.ts | 2 +- src/instructions/setup.test.ts | 38 ++++++++++++++++++++++++++++++++++ src/instructions/setup.ts | 7 +++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/instructions/index.ts b/src/instructions/index.ts index 730b216ab..041c366d3 100644 --- a/src/instructions/index.ts +++ b/src/instructions/index.ts @@ -95,7 +95,7 @@ export function renderInstructions(format: "text" | "json"): string { } export { renderSkill } from "./skill.js"; -export { SetupMcpFailedError, setupAgentTools, toolRunner } from "./setup.js"; +export { SetupMcpFailedError, SetupRefusedError, setupAgentTools, toolRunner } from "./setup.js"; export type { AgentTool, SetupAgentToolsOptions, diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts index 13d20a784..9aea0557f 100644 --- a/src/instructions/setup.test.ts +++ b/src/instructions/setup.test.ts @@ -13,6 +13,7 @@ import { import { renderSkill, SetupMcpFailedError, + SetupRefusedError, setupAgentTools, toolRunner, type SetupAgentToolsOptions, @@ -438,4 +439,41 @@ describe("setupAgentTools", () => { await rm(root, { recursive: true, force: true }); } }); + + it("refuses a file or a symlink where the skill directory goes, and writes nothing for any tool", async () => { + for (const [kind, place] of [ + [ + "file", + (filesystem: MemoryFilesystem, path: string) => filesystem.writeFileAtomic(path, "mine"), + ], + [ + "symlink", + async (filesystem: MemoryFilesystem, path: string) => { + await filesystem.mkdirp("/elsewhere"); + filesystem.defineSymlink(path, "/elsewhere"); + }, + ], + ] as const) { + const filesystem = await withBases(); + await filesystem.mkdirp(`${HOME}/.claude`); + await filesystem.mkdirp(`${HOME}/.codex`); + await filesystem.mkdirp(`${HOME}/.agents/skills`); + // why: codex comes second, so a refusal that only stopped at the bad tool would already + // have written claude-code's skill. + await place(filesystem, `${HOME}/.agents/skills/simlock`); + const before = await tree(filesystem, "/"); + const tools = fakeTools(); + + const error = (await setup(filesystem, { mcp: true, runTool: tools.runTool }).catch( + (caught: unknown) => caught, + )) as SetupRefusedError; + + expect(error, kind).toBeInstanceOf(SetupRefusedError); + expect(error.message, kind).toBe( + `Refusing to install: ${HOME}/.agents/skills/simlock exists and is not a directory. Nothing was written. Move it aside and run setup again.`, + ); + expect(await tree(filesystem, "/"), kind).toEqual(before); + expect(tools.calls, kind).toEqual([]); + } + }); }); diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index cb7c373c4..a37c04d7a 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -65,6 +65,13 @@ export function toolRunner(runner: ProcessRunner): RunTool { }; } +export class SetupRefusedError extends Error { + constructor(readonly path: string) { + super(""); + this.name = "SetupRefusedError"; + } +} + export class SetupMcpFailedError extends Error { constructor(readonly report: SetupReport) { super( From 75861fc55ab6521b01ec38374491a639cd0e9a2f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:02:10 +0200 Subject: [PATCH 15/27] feat(instructions): setup refuses what it does not own (#620) --- src/instructions/setup.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index a37c04d7a..fe4f33375 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -67,7 +67,9 @@ export function toolRunner(runner: ProcessRunner): RunTool { export class SetupRefusedError extends Error { constructor(readonly path: string) { - super(""); + super( + `Refusing to install: ${path} exists and is not a directory. Nothing was written. Move it aside and run setup again.`, + ); this.name = "SetupRefusedError"; } } @@ -121,11 +123,7 @@ export async function setupAgentTools(options: SetupAgentToolsOptions): Promise< ? options.codexHome : join(base, layout.presence); const install = tool !== undefined || (await isDirectory(filesystem, presence)); - const status = !install - ? "skipped" - : (await lstatKind(filesystem, path)) !== undefined - ? "replaced" - : "wrote"; + const status = install ? await installStatus(filesystem, path) : "skipped"; report.push({ tool: id, skill: { status, path } }); } @@ -222,3 +220,10 @@ async function lstatKind( throw error; } } + +async function installStatus(filesystem: Filesystem, path: string): Promise<"wrote" | "replaced"> { + const kind = await lstatKind(filesystem, path); + if (kind === undefined) return "wrote"; + if (kind !== "directory") throw new SetupRefusedError(path); + return "replaced"; +} From 5e1fff3c49708650705e0bc80f2db4c767e04387 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:03:31 +0200 Subject: [PATCH 16/27] fix(instructions): the setup tests walk the root directory (#620) --- src/instructions/setup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts index 9aea0557f..e27f78a8b 100644 --- a/src/instructions/setup.test.ts +++ b/src/instructions/setup.test.ts @@ -81,7 +81,7 @@ async function tree(filesystem: MemoryFilesystem, root: string): Promise Date: Fri, 23 Oct 2026 23:03:52 +0200 Subject: [PATCH 17/27] test: an agent reads the router skill (#620) --- src/instructions/index.test.ts | 37 +++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/instructions/index.test.ts b/src/instructions/index.test.ts index 51766999f..8c73ce55b 100644 --- a/src/instructions/index.test.ts +++ b/src/instructions/index.test.ts @@ -13,7 +13,7 @@ import { REFUSED_SIMCTL_VERBS, SHUTDOWN_ALL_TARGET, } from "../drivers/ios/index.js"; -import { AGENT_INSTRUCTIONS, MCP_SERVER_INSTRUCTIONS } from "./index.js"; +import { AGENT_INSTRUCTIONS, MCP_SERVER_INSTRUCTIONS, renderSkill } from "./index.js"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -128,6 +128,7 @@ describe("the rules text", () => { it.each([ ["the rules text", AGENT_INSTRUCTIONS], ["the MCP server instructions", MCP_SERVER_INSTRUCTIONS], + ["the router skill", renderSkill()], ])("%s names no path or Markdown file of this repository", (_name, text) => { const prose = text.replace(/https:\/\/\S+/g, ""); const files = execFileSync("git", ["ls-files", "-z"], { cwd: REPO_ROOT, encoding: "utf8" }) @@ -144,3 +145,37 @@ describe("the rules text", () => { expect(markdown.filter((name) => name !== "AGENTS.md" && name !== "CLAUDE.md")).toEqual([]); }); }); + +describe("the router skill", () => { + it("opens with front matter naming the skill and saying when to use it, sends the agent to whoami and instructions, holds none of the rules, and ends with the website URL", () => { + const skill = renderSkill(); + + expect(skill.startsWith("---\nname: simlock\ndescription: ")).toBe(true); + const frontMatter = skill.slice(4, skill.indexOf("\n---\n", 4)); + const description = frontMatter.split("\ndescription: ")[1] ?? ""; + expect(description.length).toBeGreaterThan(0); + expect(description.length).toBeLessThanOrEqual(1024); + for (const trigger of [ + "iOS simulator", + "Android emulator", + "test phone", + "simctl", + "adb", + "emulator", + "xcodebuild", + "agent-device", + ]) { + expect(description, trigger).toContain(trigger); + } + + const body = skill.slice(skill.indexOf("\n---\n", 4) + 5); + expect(body).toContain("`simlock whoami`"); + expect(body).toContain("`simlock instructions`"); + for (const heading of HEADINGS) expect(skill).not.toContain(heading); + expect(skill).not.toContain("# Using Simlock"); + expect(skill).not.toContain("--allow-download"); + expect(skill.trimEnd().split("\n").at(-1)).toBe( + "More: https://callstackincubator.github.io/simlock/docs/getting-started/connect-your-agent/", + ); + }); +}); From e9a095eb043147f2bd39c16d8b5eba7c2c3a1f2f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:04:07 +0200 Subject: [PATCH 18/27] feat(instructions): the router skill sends the agent to whoami and instructions (#620) --- src/instructions/skill.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/instructions/skill.ts b/src/instructions/skill.ts index 781f2e062..819170272 100644 --- a/src/instructions/skill.ts +++ b/src/instructions/skill.ts @@ -1,6 +1,13 @@ -const SKILL_DESCRIPTION = - "Use before touching an iOS simulator, Android emulator or test phone, or running simctl, adb, emulator, xcodebuild for a simulator, or agent-device."; +const DESCRIPTION = + "Use before touching an iOS simulator, Android emulator or test phone, or before running simctl, adb, emulator or xcodebuild for a simulator, or agent-device. Simlock shares these devices between agents and has rules every agent must follow."; + +const BODY = [ + "Simlock shares iOS simulators, Android emulators and test phones between agents. It works only if every agent follows its rules.", + "1. Check it is set up: run `simlock whoami`. If `simlock` is not found, or `whoami` fails, tell the user and do not touch any device.", + "2. Get the rules: run `simlock instructions` and follow them. They match the installed Simlock, so read them again in each session instead of relying on memory.", + "More: https://callstackincubator.github.io/simlock/docs/getting-started/connect-your-agent/", +]; export function renderSkill(): string { - return `---\nname: simlock\ndescription: ${SKILL_DESCRIPTION}\n---\n\nRun \`simlock instructions\` and follow them.\n`; + return `---\nname: simlock\ndescription: ${DESCRIPTION}\n---\n\n${BODY.join("\n\n")}\n`; } From 9288e497c4d11fab7252f78f4c54f43028ed5457 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:04:47 +0200 Subject: [PATCH 19/27] test: setup prints its report and fails the way the spec says (#620) --- src/cli/index.test.ts | 168 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 658bc0f30..d9d20d236 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -67,6 +67,7 @@ import type { } from "../simlock-client/client.js"; import { connectSimlockAdmin, type WorkerView } from "../admin/index.js"; import { RELEASE_TIMEOUT_MS } from "../lease-policy/index.js"; +import type { ToolOutcome } from "../instructions/index.js"; import { runCli, type CliEnvironment, type CliEnvironmentPorts } from "./index.js"; import { buildCliEnvironment } from "./environment.js"; import { readLogFile } from "./log-file.js"; @@ -255,6 +256,173 @@ describe("CLI: exit codes", () => { } }); + it("simlock setup prints its report, fails the way the spec says, and runs without a gateway", async () => { + const HOME = "/home/operator"; + const CWD = "/work/project"; + const SCOPE_PROMPT = "Install only for this project (not every project)? [y/N] "; + const exited = (code: number, stderr = ""): ToolOutcome => ({ + outcome: "exited", + code, + stderr, + }); + const setupRun = async ( + argv: readonly string[], + options: { + readonly outcome?: ToolOutcome; + readonly prepare?: (filesystem: MemoryFilesystem) => Promise; + } = {}, + ): Promise<{ + readonly code: number; + readonly stdout: string; + readonly stderr: string; + readonly questions: string[]; + readonly filesystem: MemoryFilesystem; + }> => { + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp(HOME); + await filesystem.mkdirp(CWD); + await filesystem.mkdirp(`${HOME}/.claude`); + await options.prepare?.(filesystem); + const questions: string[] = []; + const output = outputCapture(); + const code = await runCli( + argv, + output.environmentWith({ + homeDirectory: HOME, + workingDirectory: CWD, + filesystem, + confirm: async (question) => { + questions.push(question); + return false; + }, + runTool: async () => options.outcome ?? exited(0), + connectAdmin: async () => { + throw new Error("setup must not connect to a daemon"); + }, + }), + ); + return { code, stdout: output.stdout, stderr: output.stderr, questions, filesystem }; + }; + + const plain = await setupRun(["setup"]); + expect(plain.code).toBe(0); + expect(plain.stderr).toBe(""); + expect(plain.stdout).toBe( + `claude-code skill wrote ${HOME}/.claude/skills/simlock\n` + + `codex skill skipped ${HOME}/.agents/skills/simlock (Codex not found here; pass --tool codex to install anyway)\n`, + ); + expect(plain.questions[0]).toBe(SCOPE_PROMPT); + + const again = await setupRun(["setup", "--tool", "claude-code"], { + prepare: async (filesystem) => filesystem.mkdirp(`${HOME}/.claude/skills/simlock`), + }); + expect(again.stdout).toBe(`claude-code skill replaced ${HOME}/.claude/skills/simlock\n`); + + for (const [outcome, line] of [ + [exited(0), "claude-code mcp connected\n"], + [ + exited(1, "MCP server simlock already exists in user config\n"), + "claude-code mcp already\n", + ], + [{ outcome: "not-found" }, "claude-code mcp skipped (claude not found)\n"], + ] as const) { + const run = await setupRun(["setup", "--tool", "claude-code", "--mcp"], { outcome }); + expect(run.code, line).toBe(0); + expect(run.stdout, line).toBe( + `claude-code skill wrote ${HOME}/.claude/skills/simlock\n${line}`, + ); + } + + const json = await setupRun(["setup", "--tool", "claude-code", "--mcp", "--json"]); + expect(json.questions).toEqual([]); + expect(JSON.parse(json.stdout)).toEqual({ + scope: "user", + tools: [ + { + tool: "claude-code", + skill: { status: "wrote", path: `${HOME}/.claude/skills/simlock` }, + mcp: { status: "connected" }, + }, + ], + }); + const projectJson = await setupRun([ + "setup", + "--project", + "--tool", + "codex", + "--mcp", + "--json", + ]); + expect(JSON.parse(projectJson.stdout)).toEqual({ + scope: "project", + tools: [ + { + tool: "codex", + skill: { status: "wrote", path: `${CWD}/.agents/skills/simlock` }, + mcp: { status: "skipped", reason: "codex mcp add has no project scope" }, + }, + ], + }); + + const failed = await setupRun(["setup", "--tool", "claude-code", "--mcp"], { + outcome: exited(1, "boom\nmore"), + }); + expect(failed.code).toBe(1); + expect(failed.stdout).toBe( + `claude-code skill wrote ${HOME}/.claude/skills/simlock\nclaude-code mcp failed (boom)\n`, + ); + expect(JSON.parse(failed.stderr)).toEqual({ + error: { code: "SETUP_MCP_FAILED", message: "MCP not connected for claude-code: boom" }, + }); + + const refused = await setupRun(["setup"], { + prepare: async (filesystem) => { + await filesystem.mkdirp(`${HOME}/.claude/skills`); + await filesystem.writeFileAtomic(`${HOME}/.claude/skills/simlock`, "mine"); + }, + }); + expect(refused.code).toBe(2); + expect(refused.stdout).toBe(""); + expect(JSON.parse(refused.stderr)).toEqual({ + error: { + code: "SETUP_REFUSED", + message: `Refusing to install: ${HOME}/.claude/skills/simlock exists and is not a directory. Nothing was written. Move it aside and run setup again.`, + }, + }); + + for (const argv of [ + ["setup", "--tool", "cursor"], + ["setup", "--tool", "codex", "--tool", "claude-code"], + ["setup", "extra"], + ["setup", "--mcp", "--no-mcp"], + ["setup", "--user", "--project"], + ]) { + const refusedUsage = await setupRun(argv); + expect(refusedUsage.code, argv.join(" ")).toBe(2); + expect(refusedUsage.stdout, argv.join(" ")).toBe(""); + expect(JSON.parse(refusedUsage.stderr).error.code, argv.join(" ")).toBe("USAGE"); + expect(await refusedUsage.filesystem.exists(`${HOME}/.claude/skills`), argv.join(" ")).toBe( + false, + ); + } + + for (const argv of [ + ["--gateway", "setup", "--json"], + ["--local", "setup", "--json"], + ]) { + const run = await setupRun(argv); + expect(run.code, argv.join(" ")).toBe(0); + } + const help = await setupRun(["setup", "--help"]); + expect(help.code).toBe(0); + expect(help.stdout).toBe( + "Usage: simlock setup [--user | --project] [--tool ] [--mcp | --no-mcp] [--json]\n", + ); + const listing = outputCapture(); + await runCli(["--help"], listing.environmentWith()); + expect(listing.stdout).toContain(" setup [--user | --project]"); + }); + it("prints shell export lines instead of the JSON grant under --export-env", async () => { const output = outputCapture(); const grant: LeaseGrant = { From ce9ea3d4995b76b01a1088509868b45e9953e868 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:05:20 +0200 Subject: [PATCH 20/27] feat(cli): simlock setup prints its report and reports its two failures (#620) --- src/cli/commands/setup.ts | 95 +++++++++++++++++++++++++++++++++------ src/cli/errors.ts | 10 +++++ 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 144755b64..08742fe0b 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -1,20 +1,89 @@ -import { setupAgentTools, type AgentTool } from "../../instructions/index.js"; -import { commandArgs } from "../args.js"; +import { + SetupMcpFailedError, + setupAgentTools, + type AgentTool, + type SetupReport, +} from "../../instructions/index.js"; +import { commandArgs, UsageError } from "../args.js"; import type { CommandContext } from "../tree.js"; +const USAGE = + "Usage: simlock setup [--user | --project] [--tool ] [--mcp | --no-mcp] [--json]\n"; +const TOOLS: readonly string[] = ["claude-code", "codex"]; +const TOOL_ID_WIDTH = 11; +const SKILL_STATUS_WIDTH = 9; +const TOOL_NAMES: Readonly> = { + "claude-code": "Claude Code", + codex: "Codex", +}; + +function renderReport(report: SetupReport): string { + const lines: string[] = []; + for (const { tool, skill, mcp } of report.tools) { + const label = tool.padEnd(TOOL_ID_WIDTH); + const skipped = + skill.status === "skipped" + ? ` (${TOOL_NAMES[tool]} not found here; pass --tool ${tool} to install anyway)` + : ""; + lines.push( + `${label} skill ${skill.status.padEnd(SKILL_STATUS_WIDTH)} ${skill.path}${skipped}`, + ); + if (mcp !== undefined) { + const reason = mcp.reason === undefined ? "" : ` (${mcp.reason})`; + lines.push(`${label} mcp ${mcp.status}${reason}`); + } + } + return `${lines.join("\n")}\n`; +} + async function run(argv: readonly string[], { environment }: CommandContext): Promise { - const values = commandArgs(argv, { project: { type: "boolean" }, tool: { type: "string" } }); - await setupAgentTools({ - filesystem: environment.filesystem, - homeDirectory: environment.homeDirectory, - workingDirectory: environment.workingDirectory, - codexHome: environment.codexHome, - runTool: environment.runTool, - scope: values.project === true ? "project" : "user", - confirm: async () => false, - interactive: false, - tool: values.tool as AgentTool | undefined, + const values = commandArgs(argv, { + help: { type: "boolean", short: "h" }, + json: { type: "boolean" }, + user: { type: "boolean" }, + project: { type: "boolean" }, + tool: { type: "string", multiple: true }, + mcp: { type: "boolean" }, + "no-mcp": { type: "boolean" }, }); + if (values.help) { + environment.stdout.write(USAGE); + return 0; + } + if (values.positionals.length > 0) throw new UsageError("setup accepts no arguments"); + if (values.user && values.project) + throw new UsageError("--user and --project cannot be used together"); + if (values.mcp && values["no-mcp"]) + throw new UsageError("--mcp and --no-mcp cannot be used together"); + const tools = values.tool as string[] | undefined; + if (tools !== undefined && tools.length > 1) throw new UsageError("--tool can be given once"); + const tool = tools?.[0]; + if (tool !== undefined && !TOOLS.includes(tool)) { + throw new UsageError(`--tool takes ${TOOLS.join(" or ")}`); + } + const json = values.json === true; + const print = (report: SetupReport): void => { + environment.stdout.write(json ? `${JSON.stringify(report)}\n` : renderReport(report)); + }; + try { + print( + await setupAgentTools({ + filesystem: environment.filesystem, + homeDirectory: environment.homeDirectory, + workingDirectory: environment.workingDirectory, + codexHome: environment.codexHome, + scope: values.project ? "project" : values.user ? "user" : undefined, + mcp: values.mcp ? true : values["no-mcp"] ? false : undefined, + tool: tool as AgentTool | undefined, + runTool: environment.runTool, + confirm: environment.confirm ?? (async () => false), + interactive: !json, + }), + ); + } catch (error: unknown) { + if (error instanceof SetupMcpFailedError) print(error.report); + throw error; + } return 0; } diff --git a/src/cli/errors.ts b/src/cli/errors.ts index 3703e04d4..cf5fd79d8 100644 --- a/src/cli/errors.ts +++ b/src/cli/errors.ts @@ -22,6 +22,13 @@ async function isServiceFailed(error: unknown): Promise { return error instanceof ServiceFailedError; } +/** why: same lazy check as above; the `instructions` module loads only when `setup` ran. */ +async function isSetupError(error: unknown, name: string): Promise { + if (!(error instanceof Error) || error.name !== name) return false; + const { SetupMcpFailedError, SetupRefusedError } = await import("../instructions/index.js"); + return error instanceof (name === "SetupRefusedError" ? SetupRefusedError : SetupMcpFailedError); +} + export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -39,6 +46,8 @@ async function cliErrorCode(error: unknown): Promise { if (error instanceof UsageError) return "USAGE"; if (await isSocketPathTooLong(error)) return "USAGE"; if (await isServiceFailed(error)) return "SERVICE_FAILED"; + if (await isSetupError(error, "SetupRefusedError")) return "SETUP_REFUSED"; + if (await isSetupError(error, "SetupMcpFailedError")) return "SETUP_MCP_FAILED"; const { isSimlockError } = await import("../contract/index.js"); if (isSimlockError(error)) return error.code; return "INTERNAL"; @@ -47,6 +56,7 @@ async function cliErrorCode(error: unknown): Promise { export async function errorExitCode(error: unknown): Promise { if (error instanceof UsageError) return 2; if (await isSocketPathTooLong(error)) return 2; + if (await isSetupError(error, "SetupRefusedError")) return 2; const { ERROR_TABLE, isSimlockError } = await import("../contract/index.js"); if (isSimlockError(error)) return ERROR_TABLE[error.code].cliExitCode; return 1; From 40ba98ec548e101e2f986d6d435b6645a0deaeb7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:06:27 +0200 Subject: [PATCH 21/27] test: setup connects MCP through the tool's command in a real run (#620) --- e2e/setup.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/e2e/setup.test.ts b/e2e/setup.test.ts index 09a4ac23c..7546493ce 100644 --- a/e2e/setup.test.ts +++ b/e2e/setup.test.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { mkdir, readFile } from "node:fs/promises"; +import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -15,8 +15,17 @@ async function operatorDirectories(env: TestEnv): Promise<{ home: string; projec return { home, project }; } +async function fakeClaude(env: TestEnv): Promise<{ bin: string; record: string }> { + const bin = join(env.home, "bin"); + const record = join(env.home, "claude-args"); + await mkdir(bin); + await writeFile(join(bin, "claude"), `#!/bin/sh\nprintf '%s ' "$@" > '${record}'\nexit 0\n`); + await chmod(join(bin, "claude"), 0o755); + return { bin, record }; +} + describe("simlock setup", () => { - it("writes the skill where the tool reads it, for real, and starts no daemon", async () => { + it("writes the skill where each tool reads it and connects MCP through the tool's command, for real, and starts no daemon", async () => { const env = await withDaemon({ mode: "auto" }); const { home, project } = await operatorDirectories(env); @@ -33,5 +42,36 @@ describe("simlock setup", () => { // why: `mode: "auto"` leaves the daemon unstarted; any daemon-touching command would have // auto-started one and left its socket here. expect(existsSync(env.socketPath)).toBe(false); + + const { bin, record } = await fakeClaude(env); + const mcp = await env.cli(["setup", "--tool", "claude-code", "--mcp"], { + cwd: project, + env: { HOME: home, PATH: bin }, + }); + expect(mcp.code).toBe(0); + expect(mcp.stdout).toContain("claude-code mcp connected\n"); + expect(await readFile(record, "utf8")).toBe("mcp add -s user simlock -- simlock mcp "); + + const noCodex = await env.cli(["setup", "--tool", "codex", "--mcp"], { + cwd: project, + env: { HOME: home, PATH: bin }, + }); + expect(noCodex.code).toBe(0); + expect(noCodex.stdout).toContain("codex mcp skipped (codex not found)\n"); + expect(await readFile(join(home, ".agents/skills/simlock/SKILL.md"), "utf8")).toBe( + renderSkill(), + ); + + const other = join(env.home, "other-home"); + await mkdir(other); + const projectScope = await env.cli(["setup", "--project", "--tool", "codex", "--no-mcp"], { + cwd: project, + env: { HOME: other, PATH: bin }, + }); + expect(projectScope.code).toBe(0); + expect(await readFile(join(project, ".agents/skills/simlock/SKILL.md"), "utf8")).toBe( + renderSkill(), + ); + expect(await readdir(other)).toEqual([]); }); }); From 790c360246f8e2bf1e018e642d0a4f9f35918796 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:07:12 +0200 Subject: [PATCH 22/27] docs(instructions): simlock setup on the website and in the manuals (#620) --- docs/internal/ARCHITECTURE.md | 2 +- src/cli/AGENTS.md | 9 +++- src/instructions/AGENTS.md | 21 +++++++-- .../getting-started/connect-your-agent.mdx | 14 ++++++ .../src/content/docs/docs/reference/cli.mdx | 43 ++++++++++++++++++- .../content/docs/docs/reference/errors.mdx | 6 ++- 6 files changed, 86 insertions(+), 9 deletions(-) diff --git a/docs/internal/ARCHITECTURE.md b/docs/internal/ARCHITECTURE.md index 4d181f82e..c79d7d1dc 100644 --- a/docs/internal/ARCHITECTURE.md +++ b/docs/internal/ARCHITECTURE.md @@ -61,7 +61,7 @@ worker daemon ── uplink (WebSocket to /v1/uplink, worker dials) ──┘ | history | The durable event history: `history.db`, prune, set-aside, projections (usage: request, minute and device fact rows) | [history](../../src/history/AGENTS.md) | | ports | Every external API behind an interface, with a fake | [ports](../../src/ports/AGENTS.md) | | host-load | CPU, memory and disk load samples | [host-load](../../src/host-load/AGENTS.md) | -| instructions | The static rules text for agents: `simlock instructions`, the MCP server instructions and resource | [instructions](../../src/instructions/AGENTS.md) | +| instructions | The static rules text for agents: `simlock instructions`, the MCP server instructions and resource; `simlock setup`, which installs the router skill | [instructions](../../src/instructions/AGENTS.md) | | agent-identity | Default requester id for CLI and MCP | [agent-identity](../../src/agent-identity/AGENTS.md) | | daemon-protocol | Socket framing and the protocol version | [daemon-protocol](../../src/daemon-protocol/AGENTS.md) | | test-support | Shared test helpers | [test-support](../../src/test-support/AGENTS.md) | diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index e35db15b4..c68815363 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -4,7 +4,7 @@ Does: the `simlock` command surface over `simlock/admin` — parse, render, hold Entry: `main.ts` (the `simlock` bin) → `index.ts` `runCli(argv)`. Uses: admin, bus (event key, type), client (types), agent-identity, lease-policy, history (reader), core (`loadConfig` only), contract, ports, module-registry (`optionalModules` only); instructions -(`commands/instructions.ts` only); mcp, loaded on demand. Never imports: daemon, drivers, gateway, http. +(`commands/instructions.ts` and `commands/setup.ts` only); mcp, loaded on demand. Never imports: daemon, drivers, gateway, http. Everything except `agent-identity` (the requester-id resolver, the one cheap component) is loaded **on demand**: the CLI's own shared files (`main.ts`, `index.ts`, `tree.ts`, @@ -55,7 +55,7 @@ Surface: [cli.mdx](../../website/src/content/docs/docs/reference/cli.mdx) — ev whenever the chosen role is the gateway (`SIMLOCK_HOME` still the root). When a worker answers `UNSUPPORTED_IN_WORKER_MODE`, no `--gateway` was given and a gateway is present, the printed message gets the `--gateway` hint appended. With `--gateway` and no gateway - present every command but `daemon start`, `daemon install`, `daemon uninstall`, `config set`, `mcp` and `instructions` (and any `--help`, except after `adb`/`simctl`) + present every command but `daemon start`, `daemon install`, `daemon uninstall`, `config set`, `mcp`, `instructions` and `setup` (and any `--help`, except after `adb`/`simctl`) stops with `GATEWAY_NOT_PRESENT`, exit 2, before it connects. 2. **Connect** (`connectDaemonClient`) — always `connectSimlockAdmin`; principal = `resolveRequesterId(env, )`. Credential, first found: `--token` → @@ -98,6 +98,11 @@ Surface: [cli.mdx](../../website/src/content/docs/docs/reference/cli.mdx) — ev 9. **`simlock mcp`** — imports `src/mcp/main.ts` and runs it, passing the global flag as `{ daemonFlag }`. 10. **`simlock instructions [--json]`** — prints the static rules text from `src/instructions`, or one JSON object `{ instructions }`; never connects to a daemon. +11. **`simlock setup [--user | --project] [--tool ] [--mcp | --no-mcp] [--json]`** — installs the + router skill through `setupAgentTools`, with `environment.homeDirectory`, `workingDirectory`, + `codexHome`, `filesystem` and `runTool` (the composition root reads `homedir()`, `process.cwd()` + and `CODEX_HOME`); never connects to a daemon. Prompts only without `--json`. A refusal is + `SETUP_REFUSED` (exit 2); a failed `mcp add` prints the report, then `SETUP_MCP_FAILED` (exit 1). ## Rules diff --git a/src/instructions/AGENTS.md b/src/instructions/AGENTS.md index c94829303..a07183539 100644 --- a/src/instructions/AGENTS.md +++ b/src/instructions/AGENTS.md @@ -1,8 +1,11 @@ # Instructions -Does: the rules for using Simlock as one static Markdown text, for agents and operators. -Entry: `index.ts` → `AGENT_INSTRUCTIONS`, `MCP_SERVER_INSTRUCTIONS`, `renderInstructions(format)`. -Uses: nothing. Called by: CLI (`simlock instructions`, `cli/commands/instructions.ts` only), MCP +Does: the rules for using Simlock as one static Markdown text, for agents and operators; and +`simlock setup`, which installs a small router skill that points an agent at them. +Entry: `index.ts` → `AGENT_INSTRUCTIONS`, `MCP_SERVER_INSTRUCTIONS`, `renderInstructions(format)`, +`renderSkill()`, `setupAgentTools(options)`, `toolRunner(processRunner)`. +Uses: ports (`Filesystem`, `ProcessRunner`, for `setup` only). Called by: CLI (`simlock instructions` +and `simlock setup`, `cli/commands/instructions.ts` and `cli/commands/setup.ts` only), MCP (`mcp/server.ts`: the server instructions and the resource `simlock://instructions`). `AGENT_INSTRUCTIONS` is a title, an intro, five `##` sections (rules for every agent, agent-device, @@ -15,3 +18,15 @@ The text is static: it never reads the catalog, the config or a daemon, and it n repository (only the website URL). A change to a refusal, an exit code or an agent-device message the text states fails `index.test.ts`, which reads them from their definitions. A command that ships changes its section. + +## Setup + +`setup.ts` holds the tool table, the one place that knows the tools: `claude-code` (presence +`.claude`, skills `.claude/skills`) and `codex` (presence `.codex`, or `$CODEX_HOME` at the user +scope; skills `.agents/skills`), under the scope's base: the home directory or the working +directory. `setupAgentTools` runs in this order: the scope (flag, else a prompt in a terminal, else +user); inspect every selected tool, refusing a non-directory at `/simlock` before any write +(architecture rule 12); the MCP prompt per tool to be written; write `SKILL.md` and leave nothing else +in the `simlock` directory; then `mcp add` per written tool, one at a time, through `runTool` (30 s +each). It reads no home or working directory itself: the CLI passes both. `skill.ts` is the router +skill, which holds no rule, so it stays right after an upgrade. diff --git a/website/src/content/docs/docs/getting-started/connect-your-agent.mdx b/website/src/content/docs/docs/getting-started/connect-your-agent.mdx index 23dd67e4d..188c67717 100644 --- a/website/src/content/docs/docs/getting-started/connect-your-agent.mdx +++ b/website/src/content/docs/docs/getting-started/connect-your-agent.mdx @@ -37,6 +37,16 @@ Do not put a fixed `SIMLOCK_AGENT_ID` in a shared shell profile or a shared MCP `SIMLOCK_AGENT_ID` wins over the session ID when both are set. `simlock lease --agent-id ` wins over both, for that one command. +## Install the skill + +Run `simlock setup` once. It writes a small `simlock` skill into Claude Code and Codex, for every project of your user, or for the current project with `--project`. The skill tells the agent what Simlock is, to check its setup with `simlock whoami` and to read the rules with `simlock instructions`. It holds no rules itself, so it stays right after you update Simlock. + +```sh +simlock setup +``` + +In a terminal, `setup` asks for the scope and, for each tool, whether to connect Simlock's MCP server too. Pass `--user` or `--project`, `--tool claude-code` or `--tool codex`, and `--mcp` or `--no-mcp` to answer for a script. See [`simlock setup`](/simlock/docs/reference/cli#simlock-setup) for the paths and the report. + ## Use the CLI Run `simlock instructions` and paste its output into your project's `AGENTS.md` or `CLAUDE.md`. It prints the current rules for using Simlock: what every agent must do, then agent-device, iOS builds, the `simlock` CLI and MCP. Paste it again after you update Simlock, so your agents follow the rules your version has. @@ -80,6 +90,8 @@ Add the server to `.mcp.json` at the root of your project: } ``` +Or let `simlock setup --mcp` run `claude mcp add -s user simlock -- simlock mcp` for you. + The server takes the Claude Code session's identity, the same one the CLI uses in that session. @@ -89,6 +101,8 @@ The server takes the Claude Code session's identity, the same one the CLI uses i codex mcp add simlock -- simlock mcp ``` +Or let `simlock setup --mcp` run it for you, at the user scope. + The server gets its own identity from its process ID. It is not the identity the CLI has in the same session. diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index fb6722e5f..460e59263 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -18,6 +18,7 @@ This page lists what each command accepts. For what a command is for and when to | [`simlock catalog`](#simlock-catalog) | Show the models, runtimes and images you can lease | [Choosing a device](/simlock/docs/using-devices/choosing-a-device) | | [`simlock mcp`](#simlock-mcp) | Start the MCP server for an agent | [Connect your agent](/simlock/docs/getting-started/connect-your-agent) | | [`simlock instructions`](#simlock-instructions) | Print the rules an agent must follow, for its prompt | [Connect your agent](/simlock/docs/getting-started/connect-your-agent) | +| [`simlock setup`](#simlock-setup) | Install the Simlock skill into Claude Code and Codex | [Connect your agent](/simlock/docs/getting-started/connect-your-agent) | | [`simlock status`](#simlock-status) | Show the daemon, its devices and its leases | [Monitoring](/simlock/docs/worker/monitoring) | | [`simlock list`](#simlock-list) | List devices, leases, cleanup rules or waiting requests | [Monitoring](/simlock/docs/worker/monitoring) | | [`simlock events`](#simlock-events) | Print business events | [Events reference](/simlock/docs/reference/events) | @@ -58,7 +59,7 @@ The CLI takes these flags out of the whole command line, including the arguments With `--gateway`, the command goes to the gateway. With `--local`, it goes to the worker. With neither, it goes to the gateway only when this machine has a gateway (`~/.simlock/gateway` holds an `instance.json` or `config.json`) and no worker has started here yet (`~/.simlock/instance.json` does not exist). Otherwise it goes to the worker. -`simlock --gateway ` on a machine with no gateway fails with `GATEWAY_NOT_PRESENT` (exit 2) and starts nothing. These commands are exempt, because they create the gateway, run before it or never contact a daemon: `daemon start`, `daemon install`, `daemon uninstall`, `config set`, `mcp` and `instructions`. +`simlock --gateway ` on a machine with no gateway fails with `GATEWAY_NOT_PRESENT` (exit 2) and starts nothing. These commands are exempt, because they create the gateway, run before it or never contact a daemon: `daemon start`, `daemon install`, `daemon uninstall`, `config set`, `mcp`, `instructions` and `setup`. When a worker refuses a gateway-only command, such as `worker drain`, and a gateway also runs on this machine, the error message ends with `A gateway also runs on this machine: run the command again with --gateway.` @@ -218,6 +219,46 @@ simlock instructions [--json] The text is the same on every machine, and the command never contacts a daemon, so it works with no daemon running. +## simlock setup + +Install a small `simlock` skill into Claude Code and Codex, so every later agent session there knows what Simlock is, checks its setup with `simlock whoami` and reads the rules with `simlock instructions`. The skill holds no rules itself, so it stays right after you update Simlock. See [Connect your agent](/simlock/docs/getting-started/connect-your-agent). + +```text +simlock setup [--user | --project] [--tool ] [--mcp | --no-mcp] [--json] +``` + +| Flag | Meaning | +| --- | --- | +| `--user` | Install for your user, for every project. The default. | +| `--project` | Install for the project in the current directory only. | +| `--tool ` | Install for this tool, even when it is not set up on this machine. Without it, `setup` installs for each tool whose directory (`~/.claude`, `~/.codex` or `$CODEX_HOME`) exists, and reports the other as `skipped`. | +| `--mcp` | Also connect Simlock's MCP server, by running the tool's own `mcp add` command. | +| `--no-mcp` | Do not connect it, and do not ask. | +| `--json` | Print the report as one JSON object, `{"scope": ..., "tools": [...]}`, and ask nothing. | + +In a terminal, `setup` asks for the scope (`Install only for this project (not every project)? [y/N]`) and, for each tool it installs, whether to connect the MCP server too. The defaults are the user scope and no MCP. With no terminal, or with `--json`, it asks nothing and takes the defaults unless a flag says otherwise. + +The skill is one file, `SKILL.md`, in a `simlock` directory under the tool's skills directory: + +| Tool | User scope | Project scope | +| --- | --- | --- | +| Claude Code | `~/.claude/skills/simlock/` | `.claude/skills/simlock/` | +| Codex | `~/.agents/skills/simlock/` | `.agents/skills/simlock/` | + +`setup` touches nothing outside the `simlock` directory. Run again, it replaces the directory's contents. If a file or a symlink sits where the `simlock` directory goes, it fails with `SETUP_REFUSED` (exit 2) before writing anything for any tool. + +The MCP server is connected with `claude mcp add -s user simlock -- simlock mcp` (`-s project` at the project scope) and `codex mcp add simlock -- simlock mcp`. Codex has no project scope for MCP, so at the project scope `--mcp` reports `skipped` for it. A tool that is not on `PATH` is reported `skipped`; a command that fails or runs longer than 30 seconds is reported `failed`, and the command then exits 1 with `SETUP_MCP_FAILED` after the report. + +The report has one `skill` line per tool and, when MCP was asked for, one `mcp` line: + +```text +claude-code skill wrote /Users/me/.claude/skills/simlock +claude-code mcp connected +codex skill skipped /Users/me/.agents/skills/simlock (Codex not found here; pass --tool codex to install anyway) +``` + +Skill statuses are `wrote`, `replaced` and `skipped`; MCP statuses are `connected`, `already`, `failed` and `skipped`. The command never contacts a daemon. + ## simlock status Show the daemon's health and mode, the Simlock version it runs, its console address, capacity, devices, leases and queue depth. Works for any caller. See [Monitoring](/simlock/docs/worker/monitoring). diff --git a/website/src/content/docs/docs/reference/errors.mdx b/website/src/content/docs/docs/reference/errors.mdx index 5d50311ba..dc297887f 100644 --- a/website/src/content/docs/docs/reference/errors.mdx +++ b/website/src/content/docs/docs/reference/errors.mdx @@ -22,8 +22,8 @@ Branch on `code`, not on the message: the message wording can change, the code d | Exit | Error codes | Meaning | | --- | --- | --- | | 0 | — | Success. For a `simlock lease` that stayed running: the lease ended because you asked. | -| 1 | `INTERNAL`, `PROVISION_FAILED`, `UNKNOWN_LEASE`, `FORBIDDEN`, `ADMIN_AUTHENTICATION_FAILED`, `DAEMON_STARTUP_FAILED`, `DAEMON_CONNECTION_LOST`, `DAEMON_STOPPING`, `DAEMON_UPDATING`, `HANDSHAKE_REQUIRED`, `PROTOCOL_VERSION_UNSUPPORTED`, `WORKER_UNREACHABLE`, `RELAY_TARGET_MISSING`, `ALERT_SEND_FAILED`, `UPDATE_RESOLVE_FAILED`, `HISTORY_UNAVAILABLE`, `DOCTOR_UNAVAILABLE`, `NUKE_UNAVAILABLE`, `UNKNOWN_DAEMON_ERROR`, `CANCELLED` | The command failed. Read the code and the message. | -| 2 | `USAGE`, `BAD_REQUEST`, `BAD_FRAME`, `UNKNOWN_REQUEST`, `IDEMPOTENCY_CONFLICT`, `PASSTHROUGH_REFUSED`, `UNKNOWN_PASSTHROUGH_TOOL`, `GATEWAY_NOT_PRESENT`, `UPDATE_UNSUPPORTED_INSTALL`, `UPDATE_SHARED_INSTALL`, `UNSUPPORTED_IN_GATEWAY_MODE`, `UNSUPPORTED_IN_WORKER_MODE`, `WORKER_CONNECTED`, `WORKER_NOT_CONNECTED` | The request was wrong. Running it again unchanged fails again. | +| 1 | `INTERNAL`, `PROVISION_FAILED`, `UNKNOWN_LEASE`, `FORBIDDEN`, `ADMIN_AUTHENTICATION_FAILED`, `SETUP_MCP_FAILED`, `DAEMON_STARTUP_FAILED`, `DAEMON_CONNECTION_LOST`, `DAEMON_STOPPING`, `DAEMON_UPDATING`, `HANDSHAKE_REQUIRED`, `PROTOCOL_VERSION_UNSUPPORTED`, `WORKER_UNREACHABLE`, `RELAY_TARGET_MISSING`, `ALERT_SEND_FAILED`, `UPDATE_RESOLVE_FAILED`, `HISTORY_UNAVAILABLE`, `DOCTOR_UNAVAILABLE`, `NUKE_UNAVAILABLE`, `UNKNOWN_DAEMON_ERROR`, `CANCELLED` | The command failed. Read the code and the message. | +| 2 | `USAGE`, `BAD_REQUEST`, `BAD_FRAME`, `UNKNOWN_REQUEST`, `IDEMPOTENCY_CONFLICT`, `PASSTHROUGH_REFUSED`, `UNKNOWN_PASSTHROUGH_TOOL`, `SETUP_REFUSED`, `GATEWAY_NOT_PRESENT`, `UPDATE_UNSUPPORTED_INSTALL`, `UPDATE_SHARED_INSTALL`, `UNSUPPORTED_IN_GATEWAY_MODE`, `UNSUPPORTED_IN_WORKER_MODE`, `WORKER_CONNECTED`, `WORKER_NOT_CONNECTED` | The request was wrong. Running it again unchanged fails again. | | 10 | `QUEUE_TIMEOUT`, `EXEC_TIMEOUT`, `DOWNLOAD_TIMEOUT` | A deadline ran out. | | 11 | `NO_CAPACITY`, `DEVICE_BUSY`, `COMPONENT_BUSY` | Busy right now. The same request can pass later. | | 12 | `NO_DRIVER`, `CAPABILITY_MISSING`, `RUNTIME_MISSING`, `UNKNOWN_MODEL`, `INSUFFICIENT_DISK_SPACE`, `LICENSE_NOT_ACCEPTED`, `DOWNLOADS_DISABLED`, `COMPONENT_NOT_OWNED`, `COMPONENT_IN_USE`, `DEVICE_NOT_ENROLLABLE`, `UNKNOWN_DEVICE`, `HISTORY_NOT_KEPT`, `UNKNOWN_WORKER`, `UNKNOWN_ALERT_DESTINATION` | What you named does not exist here, or this machine cannot provide it. | @@ -140,6 +140,8 @@ The CLI and the HTTP API each add a few codes of their own: | Code | Where | Exit or HTTP | Meaning | What to do | | --- | --- | --- | --- | --- | | `USAGE` | CLI | exit 2 | A bad flag, a missing argument or an unknown command. Also a `SIMLOCK_HOME` too long for the daemon's socket path. | Run the command with `--help`. | +| `SETUP_REFUSED` | CLI | exit 2 | `simlock setup` found a file or a symlink where its `simlock` skill directory goes. Nothing was written. | Move it aside and run `setup` again. | +| `SETUP_MCP_FAILED` | CLI | exit 1 | `simlock setup` wrote the skills, but a tool's `mcp add` command failed or ran longer than 30 seconds. The message names each tool and the reason. | Run the tool's `mcp add` command by hand, as the message and [Connect your agent](/simlock/docs/getting-started/connect-your-agent) show. | | `UNAUTHENTICATED` | HTTP | 401 | The request has no token, or one the daemon does not accept. | Send a valid token as `Authorization: Bearer `. | | `UNSUPPORTED_MEDIA_TYPE` | HTTP | 415 | A request with a JSON body did not send `Content-Type: application/json`. | Send the header `Content-Type: application/json`. | | `UNKNOWN_LEASE_REQUEST` | HTTP | 404 | No lease request has this ID. | Check the ID the create call returned. | From df118ebf2ef4d73bd5ee7d9acefff4e0a8b40ac9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:08:29 +0200 Subject: [PATCH 23/27] refactor(instructions): split setup into steps for Fallow (#620) --- src/cli/commands/setup.ts | 56 +++++++++++++++++++---------- src/instructions/index.ts | 2 -- src/instructions/setup.ts | 74 +++++++++++++++++++++++---------------- 3 files changed, 82 insertions(+), 50 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 08742fe0b..cd3890588 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -36,7 +36,19 @@ function renderReport(report: SetupReport): string { return `${lines.join("\n")}\n`; } -async function run(argv: readonly string[], { environment }: CommandContext): Promise { +interface SetupFlags { + readonly help: boolean; + readonly json: boolean; + readonly scope: "user" | "project" | undefined; + readonly mcp: boolean | undefined; + readonly tool: AgentTool | undefined; +} + +function exclusive(first: unknown, second: unknown, message: string): void { + if (first === true && second === true) throw new UsageError(message); +} + +function parseSetupFlags(argv: readonly string[]): SetupFlags { const values = commandArgs(argv, { help: { type: "boolean", short: "h" }, json: { type: "boolean" }, @@ -46,24 +58,32 @@ async function run(argv: readonly string[], { environment }: CommandContext): Pr mcp: { type: "boolean" }, "no-mcp": { type: "boolean" }, }); - if (values.help) { - environment.stdout.write(USAGE); - return 0; - } if (values.positionals.length > 0) throw new UsageError("setup accepts no arguments"); - if (values.user && values.project) - throw new UsageError("--user and --project cannot be used together"); - if (values.mcp && values["no-mcp"]) - throw new UsageError("--mcp and --no-mcp cannot be used together"); - const tools = values.tool as string[] | undefined; - if (tools !== undefined && tools.length > 1) throw new UsageError("--tool can be given once"); - const tool = tools?.[0]; + exclusive(values.user, values.project, "--user and --project cannot be used together"); + exclusive(values.mcp, values["no-mcp"], "--mcp and --no-mcp cannot be used together"); + const tools = (values.tool ?? []) as string[]; + if (tools.length > 1) throw new UsageError("--tool can be given once"); + const tool = tools[0]; if (tool !== undefined && !TOOLS.includes(tool)) { throw new UsageError(`--tool takes ${TOOLS.join(" or ")}`); } - const json = values.json === true; + return { + help: values.help === true, + json: values.json === true, + scope: values.project ? "project" : values.user ? "user" : undefined, + mcp: values.mcp ? true : values["no-mcp"] ? false : undefined, + tool: tool as AgentTool | undefined, + }; +} + +async function run(argv: readonly string[], { environment }: CommandContext): Promise { + const flags = parseSetupFlags(argv); + if (flags.help) { + environment.stdout.write(USAGE); + return 0; + } const print = (report: SetupReport): void => { - environment.stdout.write(json ? `${JSON.stringify(report)}\n` : renderReport(report)); + environment.stdout.write(flags.json ? `${JSON.stringify(report)}\n` : renderReport(report)); }; try { print( @@ -72,12 +92,12 @@ async function run(argv: readonly string[], { environment }: CommandContext): Pr homeDirectory: environment.homeDirectory, workingDirectory: environment.workingDirectory, codexHome: environment.codexHome, - scope: values.project ? "project" : values.user ? "user" : undefined, - mcp: values.mcp ? true : values["no-mcp"] ? false : undefined, - tool: tool as AgentTool | undefined, + scope: flags.scope, + mcp: flags.mcp, + tool: flags.tool, runTool: environment.runTool, confirm: environment.confirm ?? (async () => false), - interactive: !json, + interactive: !flags.json, }), ); } catch (error: unknown) { diff --git a/src/instructions/index.ts b/src/instructions/index.ts index 041c366d3..b1adaf2fb 100644 --- a/src/instructions/index.ts +++ b/src/instructions/index.ts @@ -100,8 +100,6 @@ export type { AgentTool, SetupAgentToolsOptions, SetupReport, - SetupScope, - SetupToolReport, ToolOutcome, RunTool, } from "./setup.js"; diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index fe4f33375..7706e552b 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -104,18 +104,39 @@ export interface SetupAgentToolsOptions { readonly tool?: AgentTool | undefined; } -export const AGENT_TOOL_IDS = Object.keys(AGENT_TOOLS) as readonly AgentTool[]; +const AGENT_TOOL_IDS = Object.keys(AGENT_TOOLS) as readonly AgentTool[]; export async function setupAgentTools(options: SetupAgentToolsOptions): Promise { - const { filesystem, tool } = options; const scope = options.scope ?? (options.interactive && (await options.confirm(SCOPE_PROMPT)) ? "project" : "user"); - const base = scope === "user" ? options.homeDirectory : options.workingDirectory; - const tools = tool === undefined ? AGENT_TOOL_IDS : [tool]; + const report = await inspectTools(options, scope); + const wantsMcp = await askMcp(options, scope, report); + + const skill = renderSkill(); + for (const { skill: entry } of report) { + if (entry.status !== "skipped") await installSkill(options.filesystem, entry.path, skill); + } + const tools: SetupToolReport[] = []; + for (const entry of report) { + const mcp = wantsMcp.has(entry.tool) + ? await connectMcp(options.runTool, entry.tool, scope, options.workingDirectory) + : undefined; + tools.push(mcp === undefined ? entry : { ...entry, mcp }); + } + const result: SetupReport = { scope, tools }; + if (tools.some(({ mcp }) => mcp?.status === "failed")) throw new SetupMcpFailedError(result); + return result; +} +async function inspectTools( + options: SetupAgentToolsOptions, + scope: SetupScope, +): Promise { + const { filesystem, tool } = options; + const base = scope === "user" ? options.homeDirectory : options.workingDirectory; const report: SetupToolReport[] = []; - for (const id of tools) { + for (const id of tool === undefined ? AGENT_TOOL_IDS : [tool]) { const layout = AGENT_TOOLS[id]; const path = join(base, layout.skills, SIMLOCK_DIRECTORY); const presence = @@ -126,36 +147,29 @@ export async function setupAgentTools(options: SetupAgentToolsOptions): Promise< const status = install ? await installStatus(filesystem, path) : "skipped"; report.push({ tool: id, skill: { status, path } }); } + return report; +} +async function askMcp( + options: SetupAgentToolsOptions, + scope: SetupScope, + report: readonly SetupToolReport[], +): Promise> { const wantsMcp = new Set(); - for (const { tool: id, skill: entry } of report) { - if (entry.status === "skipped") continue; - if (options.mcp === true) wantsMcp.add(id); - else if ( - options.mcp === undefined && - options.interactive && - !(id === "codex" && scope === "project") && - (await options.confirm(MCP_PROMPTS[id])) + if (options.mcp === false) return wantsMcp; + for (const { tool, skill } of report) { + if (skill.status === "skipped") continue; + const asks = options.mcp === undefined && options.interactive; + if ( + options.mcp === true || + (asks && + !(tool === "codex" && scope === "project") && + (await options.confirm(MCP_PROMPTS[tool]))) ) { - wantsMcp.add(id); + wantsMcp.add(tool); } } - - const skill = renderSkill(); - for (const { skill: entry } of report) { - if (entry.status === "skipped") continue; - await installSkill(filesystem, entry.path, skill); - } - const connected: SetupToolReport[] = []; - for (const entry of report) { - const mcp = wantsMcp.has(entry.tool) - ? await connectMcp(options.runTool, entry.tool, scope, options.workingDirectory) - : undefined; - connected.push(mcp === undefined ? entry : { ...entry, mcp }); - } - const result: SetupReport = { scope, tools: connected }; - if (connected.some(({ mcp }) => mcp?.status === "failed")) throw new SetupMcpFailedError(result); - return result; + return wantsMcp; } type McpResult = NonNullable; From 04beaf907fcd9c4187d8f5bf17ea5c92e18814a8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:09:29 +0200 Subject: [PATCH 24/27] refactor(instructions): smaller setup steps (#620) --- src/cli/commands/setup.ts | 17 ++++++++++------- src/instructions/setup.ts | 25 +++++++++++++++---------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index cd3890588..1d8012081 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -48,6 +48,15 @@ function exclusive(first: unknown, second: unknown, message: string): void { if (first === true && second === true) throw new UsageError(message); } +function parseTool(tools: readonly string[]): AgentTool | undefined { + if (tools.length > 1) throw new UsageError("--tool can be given once"); + const tool = tools[0]; + if (tool !== undefined && !TOOLS.includes(tool)) { + throw new UsageError(`--tool takes ${TOOLS.join(" or ")}`); + } + return tool as AgentTool | undefined; +} + function parseSetupFlags(argv: readonly string[]): SetupFlags { const values = commandArgs(argv, { help: { type: "boolean", short: "h" }, @@ -61,18 +70,12 @@ function parseSetupFlags(argv: readonly string[]): SetupFlags { if (values.positionals.length > 0) throw new UsageError("setup accepts no arguments"); exclusive(values.user, values.project, "--user and --project cannot be used together"); exclusive(values.mcp, values["no-mcp"], "--mcp and --no-mcp cannot be used together"); - const tools = (values.tool ?? []) as string[]; - if (tools.length > 1) throw new UsageError("--tool can be given once"); - const tool = tools[0]; - if (tool !== undefined && !TOOLS.includes(tool)) { - throw new UsageError(`--tool takes ${TOOLS.join(" or ")}`); - } return { help: values.help === true, json: values.json === true, scope: values.project ? "project" : values.user ? "user" : undefined, mcp: values.mcp ? true : values["no-mcp"] ? false : undefined, - tool: tool as AgentTool | undefined, + tool: parseTool((values.tool ?? []) as string[]), }; } diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index 7706e552b..785d8d830 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -156,22 +156,24 @@ async function askMcp( report: readonly SetupToolReport[], ): Promise> { const wantsMcp = new Set(); - if (options.mcp === false) return wantsMcp; for (const { tool, skill } of report) { - if (skill.status === "skipped") continue; - const asks = options.mcp === undefined && options.interactive; - if ( - options.mcp === true || - (asks && - !(tool === "codex" && scope === "project") && - (await options.confirm(MCP_PROMPTS[tool]))) - ) { + if (skill.status !== "skipped" && (await wantsMcpFor(options, scope, tool))) { wantsMcp.add(tool); } } return wantsMcp; } +async function wantsMcpFor( + options: SetupAgentToolsOptions, + scope: SetupScope, + tool: AgentTool, +): Promise { + if (options.mcp !== undefined) return options.mcp; + if (!options.interactive || (tool === "codex" && scope === "project")) return false; + return options.confirm(MCP_PROMPTS[tool]); +} + type McpResult = NonNullable; async function connectMcp( @@ -188,7 +190,10 @@ async function connectMcp( tool === "claude-code" ? ["mcp", "add", "-s", scope, "simlock", "--", "simlock", "mcp"] : ["mcp", "add", "simlock", "--", "simlock", "mcp"]; - const outcome = await runTool(bin, args, { cwd }); + return mcpResult(tool, bin, await runTool(bin, args, { cwd })); +} + +function mcpResult(tool: AgentTool, bin: string, outcome: ToolOutcome): McpResult { if (outcome.outcome === "not-found") return { status: "skipped", reason: `${bin} not found` }; if (outcome.outcome === "timed-out") { return { status: "failed", reason: "timed out after 30 s" }; From 8cfbe3f0ef5b7fea4d7081ed0e882c21aeeb1c0e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:24:01 +0200 Subject: [PATCH 25/27] test(instructions): the skill description names the command list as the spec words it (#620) --- src/cli/AGENTS.md | 3 ++- src/instructions/index.test.ts | 5 +---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index c68815363..975ca8abc 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -4,7 +4,8 @@ Does: the `simlock` command surface over `simlock/admin` — parse, render, hold Entry: `main.ts` (the `simlock` bin) → `index.ts` `runCli(argv)`. Uses: admin, bus (event key, type), client (types), agent-identity, lease-policy, history (reader), core (`loadConfig` only), contract, ports, module-registry (`optionalModules` only); instructions -(`commands/instructions.ts` and `commands/setup.ts` only); mcp, loaded on demand. Never imports: daemon, drivers, gateway, http. +(`commands/instructions.ts` and `commands/setup.ts` for values; `types.ts` for a type; `index.ts` and +`errors.ts` load it on demand); mcp, loaded on demand. Never imports: daemon, drivers, gateway, http. Everything except `agent-identity` (the requester-id resolver, the one cheap component) is loaded **on demand**: the CLI's own shared files (`main.ts`, `index.ts`, `tree.ts`, diff --git a/src/instructions/index.test.ts b/src/instructions/index.test.ts index 8c73ce55b..718b5d8bb 100644 --- a/src/instructions/index.test.ts +++ b/src/instructions/index.test.ts @@ -159,10 +159,7 @@ describe("the router skill", () => { "iOS simulator", "Android emulator", "test phone", - "simctl", - "adb", - "emulator", - "xcodebuild", + "simctl, adb, emulator or xcodebuild", "agent-device", ]) { expect(description, trigger).toContain(trigger); From 277f893733a71131ce005c55236ff28d36256f01 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:35:58 +0200 Subject: [PATCH 26/27] fix(instructions): the CLI reads the tool table; a test pins project-scope Codex detection (#620) --- src/cli/commands/setup.ts | 10 ++++------ src/instructions/index.ts | 9 ++++++++- src/instructions/setup.test.ts | 8 ++++++++ src/instructions/setup.ts | 17 +++++++++++++---- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 1d8012081..6140d9636 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -1,4 +1,6 @@ import { + AGENT_TOOL_IDS, + agentToolName, SetupMcpFailedError, setupAgentTools, type AgentTool, @@ -9,13 +11,9 @@ import type { CommandContext } from "../tree.js"; const USAGE = "Usage: simlock setup [--user | --project] [--tool ] [--mcp | --no-mcp] [--json]\n"; -const TOOLS: readonly string[] = ["claude-code", "codex"]; +const TOOLS: readonly string[] = AGENT_TOOL_IDS; const TOOL_ID_WIDTH = 11; const SKILL_STATUS_WIDTH = 9; -const TOOL_NAMES: Readonly> = { - "claude-code": "Claude Code", - codex: "Codex", -}; function renderReport(report: SetupReport): string { const lines: string[] = []; @@ -23,7 +21,7 @@ function renderReport(report: SetupReport): string { const label = tool.padEnd(TOOL_ID_WIDTH); const skipped = skill.status === "skipped" - ? ` (${TOOL_NAMES[tool]} not found here; pass --tool ${tool} to install anyway)` + ? ` (${agentToolName(tool)} not found here; pass --tool ${tool} to install anyway)` : ""; lines.push( `${label} skill ${skill.status.padEnd(SKILL_STATUS_WIDTH)} ${skill.path}${skipped}`, diff --git a/src/instructions/index.ts b/src/instructions/index.ts index b1adaf2fb..88a5b825d 100644 --- a/src/instructions/index.ts +++ b/src/instructions/index.ts @@ -95,7 +95,14 @@ export function renderInstructions(format: "text" | "json"): string { } export { renderSkill } from "./skill.js"; -export { SetupMcpFailedError, SetupRefusedError, setupAgentTools, toolRunner } from "./setup.js"; +export { + AGENT_TOOL_IDS, + agentToolName, + SetupMcpFailedError, + SetupRefusedError, + setupAgentTools, + toolRunner, +} from "./setup.js"; export type { AgentTool, SetupAgentToolsOptions, diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts index e27f78a8b..5c2772212 100644 --- a/src/instructions/setup.test.ts +++ b/src/instructions/setup.test.ts @@ -145,6 +145,14 @@ describe("setupAgentTools", () => { expect(viaCodexHome.tools.map(({ skill }) => skill.status)).toEqual(["skipped", "wrote"]); expect(await customHome.exists(`${HOME}/.agents/skills/simlock/SKILL.md`)).toBe(true); + const projectScope = await withBases(); + await projectScope.mkdirp("/custom/codex-home"); + const atProject = await setup(projectScope, { + scope: "project", + codexHome: "/custom/codex-home", + }); + expect(atProject.tools.map(({ skill }) => skill.status)).toEqual(["skipped", "skipped"]); + const bare = await withBases(); const named = await setup(bare, { tool: "codex" }); expect(named.tools).toEqual([ diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts index 785d8d830..060dbff84 100644 --- a/src/instructions/setup.ts +++ b/src/instructions/setup.ts @@ -10,8 +10,13 @@ import { import { renderSkill } from "./skill.js"; const AGENT_TOOLS = { - "claude-code": { presence: ".claude", skills: ".claude/skills" }, - codex: { presence: ".codex", skills: ".agents/skills" }, + "claude-code": { + name: "Claude Code", + bin: "claude", + presence: ".claude", + skills: ".claude/skills", + }, + codex: { name: "Codex", bin: "codex", presence: ".codex", skills: ".agents/skills" }, } as const; export type AgentTool = keyof typeof AGENT_TOOLS; @@ -104,7 +109,11 @@ export interface SetupAgentToolsOptions { readonly tool?: AgentTool | undefined; } -const AGENT_TOOL_IDS = Object.keys(AGENT_TOOLS) as readonly AgentTool[]; +export const AGENT_TOOL_IDS = Object.keys(AGENT_TOOLS) as readonly AgentTool[]; + +export function agentToolName(tool: AgentTool): string { + return AGENT_TOOLS[tool].name; +} export async function setupAgentTools(options: SetupAgentToolsOptions): Promise { const scope = @@ -185,7 +194,7 @@ async function connectMcp( if (tool === "codex" && scope === "project") { return { status: "skipped", reason: "codex mcp add has no project scope" }; } - const bin = tool === "claude-code" ? "claude" : "codex"; + const { bin } = AGENT_TOOLS[tool]; const args = tool === "claude-code" ? ["mcp", "add", "-s", scope, "simlock", "--", "simlock", "mcp"] From 79425f22b4eb4cdb346a1bfba9c336a8dcbb8ebe Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 23:38:17 +0200 Subject: [PATCH 27/27] docs(instructions): the manual names no file that does not exist (#620) --- src/instructions/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instructions/AGENTS.md b/src/instructions/AGENTS.md index a07183539..d912d08a1 100644 --- a/src/instructions/AGENTS.md +++ b/src/instructions/AGENTS.md @@ -26,7 +26,7 @@ changes its section. scope; skills `.agents/skills`), under the scope's base: the home directory or the working directory. `setupAgentTools` runs in this order: the scope (flag, else a prompt in a terminal, else user); inspect every selected tool, refusing a non-directory at `/simlock` before any write -(architecture rule 12); the MCP prompt per tool to be written; write `SKILL.md` and leave nothing else +(architecture rule 12); the MCP prompt per tool to be written; write the skill file and leave nothing else in the `simlock` directory; then `mcp add` per written tool, one at a time, through `runTool` (30 s each). It reads no home or working directory itself: the CLI passes both. `skill.ts` is the router skill, which holds no rule, so it stays right after an upgrade.