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/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..7546493ce --- /dev/null +++ b/e2e/setup.test.ts @@ -0,0 +1,77 @@ +import { existsSync } from "node:fs"; +import { chmod, mkdir, readdir, readFile, writeFile } 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 }; +} + +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 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); + + 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); + + 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([]); + }); +}); diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index e35db15b4..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` 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`, @@ -55,7 +56,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 +99,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/cli/commands/setup.ts b/src/cli/commands/setup.ts new file mode 100644 index 000000000..6140d9636 --- /dev/null +++ b/src/cli/commands/setup.ts @@ -0,0 +1,111 @@ +import { + AGENT_TOOL_IDS, + agentToolName, + 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[] = AGENT_TOOL_IDS; +const TOOL_ID_WIDTH = 11; +const SKILL_STATUS_WIDTH = 9; + +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" + ? ` (${agentToolName(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`; +} + +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 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" }, + json: { type: "boolean" }, + user: { type: "boolean" }, + project: { type: "boolean" }, + tool: { type: "string", multiple: true }, + mcp: { type: "boolean" }, + "no-mcp": { type: "boolean" }, + }); + 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"); + 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: parseTool((values.tool ?? []) as string[]), + }; +} + +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(flags.json ? `${JSON.stringify(report)}\n` : renderReport(report)); + }; + try { + print( + await setupAgentTools({ + filesystem: environment.filesystem, + homeDirectory: environment.homeDirectory, + workingDirectory: environment.workingDirectory, + codexHome: environment.codexHome, + scope: flags.scope, + mcp: flags.mcp, + tool: flags.tool, + runTool: environment.runTool, + confirm: environment.confirm ?? (async () => false), + interactive: !flags.json, + }), + ); + } catch (error: unknown) { + if (error instanceof SetupMcpFailedError) print(error.report); + throw error; + } + return 0; +} + +export { run }; diff --git a/src/cli/environment.ts b/src/cli/environment.ts index c92250378..58fd592b8 100644 --- a/src/cli/environment.ts +++ b/src/cli/environment.ts @@ -283,6 +283,11 @@ export function buildCliEnvironment( }, }), requesterId, + homeDirectory: ports.homeDirectory, + workingDirectory: ports.workingDirectory, + codexHome: ports.codexHome, + runTool: ports.runTool, + filesystem, connectAdmin: (resolveCredential) => autoLaunch.connect(resolveCredential), connectExistingAdmin: (resolveCredential) => connect(ipc, resolveCredential), daemonEndpointPresent: async () => { 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; diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 1724ab67d..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 = { @@ -1221,6 +1389,9 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { ipc: ipcTransport, launcher, 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"], @@ -1253,6 +1424,9 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { ipc: new MemoryIpcTransport(), launcher, 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); @@ -1283,6 +1457,9 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { ipc: ipcTransport, launcher, 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"], @@ -1325,6 +1502,9 @@ 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 }; } @@ -1441,6 +1621,9 @@ describe("CLI: auto-launch's failed start (#672)", () => { ipc: new MemoryIpcTransport(), launcher, 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( @@ -1487,6 +1670,9 @@ 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", + workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1526,6 +1712,9 @@ 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", + workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1586,6 +1775,9 @@ 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", + workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); let settled = false; const exitCode = runCli(["status"], output.environmentWith()).then((code) => { @@ -1628,6 +1820,9 @@ 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", + workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }); let settled = false; const exitCode = runCli( @@ -7880,6 +8075,10 @@ function outputCapture(ports?: CliEnvironmentPorts, env: NodeJS.ProcessEnv = {}) configPath: "/simlock/config.json", daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, requesterId: "test-requester", + homeDirectory: "/home/operator", + workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), + filesystem: new MemoryFilesystem(), connectAdmin: async () => fakeClient(), connectExistingAdmin: async () => fakeClient(), daemonEndpointPresent: async () => false, @@ -8155,6 +8354,9 @@ function realCliEnvironmentPorts( ipc: new MemoryIpcTransport(), launcher: new FakeDaemonLauncher(), daemonHome: { role: "worker", dataDirectory: "/simlock", gatewayPresent: false }, + homeDirectory: "/home/operator", + workingDirectory: "/work/project", + runTool: async () => ({ outcome: "not-found" }), }; } @@ -9302,6 +9504,9 @@ describe("CLI: daemon install (#830)", () => { ipc: ipcTransport, launcher: new FakeDaemonLauncher(), 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", @@ -9715,6 +9920,9 @@ describe("CLI: a start goes through the service (#830)", () => { spawner, }), 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()); @@ -9772,6 +9980,9 @@ describe("CLI: a start through a service that fails (#830)", () => { spawner: new FakeDaemonLauncher(), }), 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( @@ -9861,6 +10072,9 @@ 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", + 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 82980b437..748679278 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,13 @@ async function buildDefaultEnvironment(flag: DaemonFlag): Promise process.stderr.write(`${line}\n`), }), daemonHome, + homeDirectory: homedir(), + workingDirectory: process.cwd(), + codexHome: process.env.CODEX_HOME, + runTool: async (bin, args, options) => { + const { toolRunner } = await import("../instructions/index.js"); + return toolRunner(new NodeProcessRunner())(bin, args, options); + }, service: { manager: serviceManager, platform: process.platform, @@ -165,6 +174,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..74e464286 100644 --- a/src/cli/lazy-loading.test.ts +++ b/src/cli/lazy-loading.test.ts @@ -59,6 +59,9 @@ async function environment(): Promise { const ports: CliEnvironmentPorts = { clock: new FakeClock(0), 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 9302ca596..359b3f08b 100644 --- a/src/cli/lazy-parent-watch.test.ts +++ b/src/cli/lazy-parent-watch.test.ts @@ -42,6 +42,9 @@ describe("the default parent watch", () => { const environment = buildCliEnvironment( { 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/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..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, @@ -54,6 +55,11 @@ export interface CliEnvironment { readonly configPath: string; readonly daemonHome: DaemonHome; readonly requesterId: string; + readonly homeDirectory: string; + readonly workingDirectory: string; + readonly codexHome?: string | undefined; + readonly runTool: RunTool; + readonly filesystem: Filesystem; readonly connectAdmin: ( resolveCredential: () => Promise, ) => Promise; @@ -91,6 +97,10 @@ export interface CliEnvironmentPorts { readonly ipc: IpcConnector; readonly launcher: DaemonLauncher; readonly daemonHome: DaemonHome; + 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/AGENTS.md b/src/instructions/AGENTS.md index c94829303..d912d08a1 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 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. diff --git a/src/instructions/index.test.ts b/src/instructions/index.test.ts index 51766999f..718b5d8bb 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,34 @@ 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 or 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/", + ); + }); +}); diff --git a/src/instructions/index.ts b/src/instructions/index.ts index 222aa43f4..88a5b825d 100644 --- a/src/instructions/index.ts +++ b/src/instructions/index.ts @@ -93,3 +93,20 @@ export function renderInstructions(format: "text" | "json"): string { ? JSON.stringify({ instructions: AGENT_INSTRUCTIONS }) : AGENT_INSTRUCTIONS; } + +export { renderSkill } from "./skill.js"; +export { + AGENT_TOOL_IDS, + agentToolName, + SetupMcpFailedError, + SetupRefusedError, + setupAgentTools, + toolRunner, +} from "./setup.js"; +export type { + AgentTool, + SetupAgentToolsOptions, + SetupReport, + ToolOutcome, + RunTool, +} from "./setup.js"; diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts new file mode 100644 index 000000000..5c2772212 --- /dev/null +++ b/src/instructions/setup.test.ts @@ -0,0 +1,487 @@ +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, + NodeFilesystem, + ProcessSpawnError, + type ProcessRunner, +} from "../ports/index.js"; +import { + renderSkill, + SetupMcpFailedError, + SetupRefusedError, + 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 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> = {}, +): ReturnType { + return setupAgentTools({ + filesystem, + homeDirectory: HOME, + workingDirectory: CWD, + runTool: fakeTools().runTool, + confirm: fakeConfirm().confirm, + interactive: false, + ...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}` : `${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([]); + } + }); + + 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 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([ + { 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`, + ]); + }); + + 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" }, + }); + }); + + 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]); + }); + + 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 }); + } + }); + + 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 new file mode 100644 index 000000000..060dbff84 --- /dev/null +++ b/src/instructions/setup.ts @@ -0,0 +1,257 @@ +import { join } from "node:path"; + +import { + isMissingPathError, + ProcessSpawnError, + type Filesystem, + type PathDetails, + type ProcessRunner, +} from "../ports/index.js"; +import { renderSkill } from "./skill.js"; + +const AGENT_TOOLS = { + "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; +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"; + +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; + +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 SetupRefusedError extends Error { + constructor(readonly path: string) { + super( + `Refusing to install: ${path} exists and is not a directory. Nothing was written. Move it aside and run setup again.`, + ); + this.name = "SetupRefusedError"; + } +} + +export class SetupMcpFailedError extends Error { + constructor(readonly report: SetupReport) { + super( + report.tools + .filter(({ mcp }) => mcp?.status === "failed") + .map(({ tool, mcp }) => `MCP not connected for ${tool}: ${mcp?.reason}`) + .join("; "), + ); + this.name = "SetupMcpFailedError"; + } +} + +export interface SetupReport { + readonly scope: SetupScope; + readonly tools: readonly SetupToolReport[]; +} + +export interface SetupAgentToolsOptions { + readonly filesystem: Filesystem; + readonly homeDirectory: string; + readonly workingDirectory: string; + readonly scope?: SetupScope | undefined; + readonly confirm: (question: string) => Promise; + readonly interactive: boolean; + readonly runTool: RunTool; + readonly mcp?: boolean | undefined; + readonly codexHome?: string | undefined; + readonly tool?: AgentTool | undefined; +} + +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 = + options.scope ?? + (options.interactive && (await options.confirm(SCOPE_PROMPT)) ? "project" : "user"); + 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 tool === undefined ? AGENT_TOOL_IDS : [tool]) { + 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)); + 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, skill } of report) { + 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( + 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 } = AGENT_TOOLS[tool]; + const args = + tool === "claude-code" + ? ["mcp", "add", "-s", scope, "simlock", "--", "simlock", "mcp"] + : ["mcp", "add", "simlock", "--", "simlock", "mcp"]; + 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" }; + } + 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 { + if (!(await filesystem.exists(path))) return false; + return (await filesystem.stat(path)).kind === "directory"; +} + +async function installSkill( + filesystem: Filesystem, + directory: string, + skill: 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; + } +} + +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"; +} diff --git a/src/instructions/skill.ts b/src/instructions/skill.ts new file mode 100644 index 000000000..819170272 --- /dev/null +++ b/src/instructions/skill.ts @@ -0,0 +1,13 @@ +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: ${DESCRIPTION}\n---\n\n${BODY.join("\n\n")}\n`; +} diff --git a/src/mcp/main.test.ts b/src/mcp/main.test.ts index b835ad5e1..162dc78c7 100644 --- a/src/mcp/main.test.ts +++ b/src/mcp/main.test.ts @@ -327,6 +327,9 @@ describe("MCP stdio lifecycle", () => { { clock: new FakeClock(0), 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(), 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. |