diff --git a/README.md b/README.md index 944b5639..7aa4e160 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,10 @@ simlock status --json The daemon starts on demand — there's no separate setup step. Use `simlock doctor` to reconcile managed state with reality, and `simlock nuke --yes --delete-devices` only for an emergency reset of -Simlock-managed devices. +Simlock-managed devices. Run `simlock instructions` to print the rules your +agents must follow, ready to paste into their system prompt or `AGENTS.md`. +Run `simlock setup` to install those rules as a skill for Claude Code and +Codex instead, for your user or, with `--project`, for the current project. See [docs/CLI.md](docs/CLI.md) for the full command reference and [docs/CLI.md#simlock-mcp](docs/CLI.md#simlock-mcp) or the [README section @@ -196,7 +199,8 @@ with its own id. The server exposes exactly four tools: `list_devices` (read-only catalog of what can be leased), `lease_simulator`, `release_simulator`, and `lease_status` (cheap, safe to poll after a context compaction to check whether a device is -still leased to this session). Full tool contracts, progress reporting, and +still leased to this session). It also serves the agent rules +`simlock instructions` prints as one resource, `simlock://instructions`. Full tool contracts, progress reporting, and lease-loss notifications are documented in [docs/CLI.md](docs/CLI.md#simlock-mcp). diff --git a/docs/CLI.md b/docs/CLI.md index b2046b95..be9f0db8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -3,8 +3,8 @@ Part of the user manual: every command the simlock CLI is expected to implement. Results are JSON on **stdout**; progress/diagnostics are JSON lines on **stderr** — this is the default output, not an opt-in, because -agents are the primary audience. `status`, `catalog`, and -`daemon ` are the exception: they default to a +agents are the primary audience. `status`, `catalog`, `instructions`, +`setup`, and `daemon ` are the exception: they default to a human-oriented view for interactive/operator use and accept `--json` to switch to the structured form. Every other command's output is already unconditionally JSON, so passing `--json` to it is a usage error (exit 2) @@ -19,11 +19,13 @@ On failure, every command writes one structured line to stderr: `code` is the daemon's own error code where the failure came from the daemon, or a stable CLI-level code otherwise: `USAGE` for bad flags/missing -arguments/unknown commands, `INTERNAL` for anything unexpected. An unknown -command or a missing required argument gets a `message` that ends with a -pointer to `simlock --help`, so a human hitting one from a terminal isn't -stranded with only a JSON blob — the full command banner itself is no -longer dumped to stderr on every failure, only on request via `--help`. +arguments/unknown commands, `SETUP_REFUSED` for a `simlock setup` that found +a file or symlink where its `simlock` directory belongs, `INTERNAL` for +anything unexpected. An unknown command or a missing required argument gets a +`message` that ends with a pointer to `simlock --help`, so a human hitting one +from a terminal isn't stranded with only a JSON blob — the full command banner +itself is no longer dumped to stderr on every failure, only on request via +`--help`. ## Global exit codes @@ -33,6 +35,7 @@ longer dumped to stderr on every failure, only on request via `--help`. | 1 | `INTERNAL` | internal / unexpected error | | 1 | `WORKER_UNREACHABLE` | the gateway cannot reach the worker this lease or request lives on (its uplink is down) | | 2 | `USAGE` | usage error (bad flags, missing required args, unknown command) | +| 2 | `SETUP_REFUSED` | `simlock setup` found a file or symlink where its `simlock` skill directory belongs, and wrote nothing | | 2 | `BAD_FRAME` | malformed request frame sent to the daemon | | 2 | `BAD_REQUEST` | request payload failed validation | | 2 | `UNSUPPORTED_IN_GATEWAY_MODE` | this command acts on one machine's devices and the daemon answering is a gateway; run it on the worker | @@ -52,8 +55,9 @@ longer dumped to stderr on every failure, only on request via `--help`. | 13 | `REQUESTER_ALREADY_LEASED` | requester already holds a lease or has a pending request — one lease per agent in v1; release the named lease first | | 14 | — | `lease` without `--detach` only: the daemon ended the lease without the holder asking (TTL expiry, operator `release`, or an unrecoverable device) | -Every row but 14 matches the `cliExitCode` column of the contract's error -table (`src/contract/errors.ts`'s `ERROR_TABLE`) exactly — the CLI does not +Every row but 14 and the CLI-level codes `USAGE` and `SETUP_REFUSED` matches +the `cliExitCode` column of the contract's error table +(`src/contract/errors.ts`'s `ERROR_TABLE`) exactly — the CLI does not maintain a second mapping; 14 is not a daemon error code but an outcome of a `lease` that stays alive, so it lives beside the table's other `lease` outcome, 0. @@ -792,7 +796,8 @@ token revoke ` closes any uplink that token opened. Start Simlock's local stdio MCP server. It accepts no flags. Standard output is reserved for MCP JSON-RPC; fatal diagnostics are written to stderr. The server exposes the focused `list_devices`, `lease_simulator`, `release_simulator`, and -`lease_status` tool surface for one agent session. The server auto-starts the +`lease_status` tool surface for one agent session, and one resource, +`simlock://instructions`: the text `simlock instructions` prints. The server auto-starts the daemon when needed, on a tool call; its renew timer reconnects only to a daemon that is already listening, and never launches one. `lease_simulator` accepts the contract's optional `ttlMs` — defaulting to `lease.defaultTtlMs` @@ -846,6 +851,67 @@ under the pre-0.3.0 hand-written schemas (`leaseId`, `deviceId`, `allowDownload`, `requesterId`, ...); those did not change shape, only their schema's source of truth. +## `simlock instructions [--json]` + +Prints the rules an agent must follow to share devices through Simlock, as one +self-contained Markdown block to paste into an agent's system prompt or its +`AGENTS.md`. Simlock only works when every agent goes through it, and this is +the text that tells an agent how: never call `simctl`, `adb`, `avdmanager`, or +`emulator` directly; set a stable `SIMLOCK_AGENT_ID`; run `simlock catalog` +before `simlock lease`, and keep the lease alive or renew it; hold one lease at +a time and release it; never pass `--allow-download` unless told to; what exit +codes 10, 11, 13, and 14 mean; how to reach the leased device, and which +passthrough commands are refused on purpose; and the four MCP tools. + +`--json` prints one JSON object instead, `{"instructions":""}`, whose +`instructions` field is exactly the text the plain command prints. `--help` +prints the usage line; any other flag or argument is a usage error (exit 2). + +The text is static: it does not depend on the catalog, the config, or the +daemon, and the command never connects to or starts the daemon. The MCP server +serves the same text as the `simlock://instructions` resource +(`text/markdown`), so an MCP client can read it without anyone pasting it. + +## `simlock setup [--project] [--tool ] [--json]` + +Installs the text `simlock instructions` prints as a skill for Claude Code and +Codex, so each of their sessions starts with the rules and nobody pastes them. +The skill is one file, `SKILL.md`: a short header naming the skill `simlock` +and saying when it applies, a blank line, then the instructions byte for byte. + +By default it installs for the current user, under your home directory. +`--project` installs into the current directory instead, so the files can be +committed and the whole team gets them: + +| Tool | `--tool` | User (default) | Project (`--project`) | +|---|---|---|---| +| Claude Code | `claude-code` | `~/.claude/skills/simlock/SKILL.md` | `.claude/skills/simlock/SKILL.md` | +| Codex | `codex` | `~/.codex/skills/simlock/SKILL.md` | `.codex/skills/simlock/SKILL.md` | + +With `--tool`, only that tool is installed, and its directories are created if +they are missing. Without it, a tool is installed only where it is already set +up (its `.claude` or `.codex` directory exists in that scope); the others are +reported as skipped. Any other `--tool` value, a second `--tool`, or an +argument is a usage error (exit 2) and writes nothing. + +The `simlock` directory is Simlock's own. Each run writes `SKILL.md` and +deletes anything else in that directory, so a re-run after an upgrade leaves +only the new text. Nothing outside it is touched: other skills, `AGENTS.md`, +`CLAUDE.md`, and each tool's settings stay as they are. To uninstall, delete +the directory. If a file or symlink sits where that directory belongs, the run +is refused with `SETUP_REFUSED` (exit 2), whose message names the path, and +nothing is written for any tool. + +The output is one line per tool: the tool, `wrote`, `replaced` (the directory +was already there), or `skipped`, and the directory. A skipped line ends with +a hint to pass `--tool`. `--json` prints the same report as one JSON object: + +```json +{"scope":"user","tools":[{"tool":"claude-code","status":"wrote","path":"/Users/me/.claude/skills/simlock"},{"tool":"codex","status":"skipped","path":"/Users/me/.codex/skills/simlock"}]} +``` + +The command never connects to or starts the daemon. + ## `simlock status` Human and JSON status include derived warm counts globally and per platform. diff --git a/e2e/agent-instructions.test.ts b/e2e/agent-instructions.test.ts new file mode 100644 index 00000000..309187f4 --- /dev/null +++ b/e2e/agent-instructions.test.ts @@ -0,0 +1,42 @@ +import { existsSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { AGENT_INSTRUCTIONS } from "../src/instructions/index.js"; +import { withDaemon } from "./helpers/index.js"; + +describe("simlock instructions", () => { + it("prints the agent instructions on stdout and exits 0 without starting a daemon", async () => { + const env = await withDaemon({ mode: "auto" }); + + const result = await env.cli(["instructions"]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe(AGENT_INSTRUCTIONS); + // `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); + }); + + it("--json prints one JSON object whose instructions field equals the text output", async () => { + const env = await withDaemon({ mode: "auto" }); + + const text = await env.cli(["instructions"]); + const json = await env.cli(["instructions", "--json"]); + + expect(json.code).toBe(0); + expect(json.stdout.trimEnd().split("\n")).toHaveLength(1); + expect(json.json).toEqual({ instructions: text.stdout }); + }); + + it("--bogus fails with USAGE and exit 2", async () => { + const env = await withDaemon({ mode: "auto" }); + + const result = await env.cli(["instructions", "--bogus"]); + + expect(result.code).toBe(2); + expect(result.stdout).toBe(""); + expect(result.error?.code).toBe("USAGE"); + }); +}); diff --git a/e2e/helpers/cli.ts b/e2e/helpers/cli.ts index 2aaba636..67889a99 100644 --- a/e2e/helpers/cli.ts +++ b/e2e/helpers/cli.ts @@ -7,6 +7,8 @@ const CLI_ENTRY = join(REPO_ROOT, "dist/cli/main.js"); export interface CliOptions { readonly env?: NodeJS.ProcessEnv; + /** The working directory the CLI runs in; the test runner's own when omitted. */ + readonly cwd?: string; readonly input?: string; readonly timeout?: number; } @@ -38,6 +40,7 @@ export function cli( return new Promise((resolve, reject) => { const child = spawn(process.execPath, [CLI_ENTRY, ...args], { env: { ...env, ...options.env }, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), }); let stdout = ""; let stderr = ""; @@ -92,6 +95,7 @@ export function cliBackground( ): CliBackgroundHandle { const child = spawn(process.execPath, [CLI_ENTRY, ...args], { env: { ...env, ...options.env }, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), }); let stdout = ""; let stderr = ""; diff --git a/e2e/mcp-session.test.ts b/e2e/mcp-session.test.ts index d4b21bff..25580871 100644 --- a/e2e/mcp-session.test.ts +++ b/e2e/mcp-session.test.ts @@ -8,6 +8,29 @@ interface McpErrorPayload { } describe("MCP session semantics", () => { + it("declares the resources capability, lists simlock://instructions, and reading it returns the same text the CLI prints", async () => { + const env = await withDaemon(); + const mcp = await env.mcpClient({ env: { SIMLOCK_AGENT_ID: "flow7-instructions" } }); + + try { + expect(mcp.client.getServerCapabilities()?.resources).toBeDefined(); + const listed = await mcp.client.listResources(); + expect(listed.resources).toContainEqual( + expect.objectContaining({ mimeType: "text/markdown", uri: "simlock://instructions" }), + ); + + const read = await mcp.client.readResource({ uri: "simlock://instructions" }); + const printed = await env.cli(["instructions"]); + expect(printed.code).toBe(0); + expect(printed.stdout.length).toBeGreaterThan(0); + expect(read.contents).toEqual([ + { mimeType: "text/markdown", text: printed.stdout, uri: "simlock://instructions" }, + ]); + } finally { + await mcp.close(); + } + }); + it("exercises all four tools, and streams strictly increasing progress with human messages", async () => { const env = await withDaemon(); await env.driverScript.set({ diff --git a/e2e/setup.test.ts b/e2e/setup.test.ts new file mode 100644 index 00000000..ece19c6a --- /dev/null +++ b/e2e/setup.test.ts @@ -0,0 +1,77 @@ +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { withDaemon, type TestEnv } from "./helpers/index.js"; + +/** An empty home and an empty project directory inside the test's own temp directory. */ +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("--tool claude-code writes .claude/skills/simlock/SKILL.md under HOME, exits 0, 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(""); + const skillDirectory = join(home, ".claude/skills/simlock"); + expect(result.stdout).toBe(`claude-code wrote ${skillDirectory}\n`); + expect(await readdir(skillDirectory)).toEqual(["SKILL.md"]); + expect(await readdir(project)).toEqual([]); + // `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); + }); + + it("--project --tool codex writes .codex/skills/simlock/SKILL.md under the working directory and nothing under HOME", async () => { + const env = await withDaemon({ mode: "auto" }); + const { home, project } = await operatorDirectories(env); + + const result = await env.cli(["setup", "--project", "--tool", "codex", "--json"], { + cwd: project, + env: { HOME: home }, + }); + + expect(result.code).toBe(0); + // The CLI reports the directory the way `process.cwd()` spells it, which on macOS is the + // resolved `/private/var/...` form of a temp directory. + const report = result.json as { scope: string; tools: { tool: string; path: string }[] }; + expect(report.scope).toBe("project"); + expect(report.tools).toHaveLength(1); + expect(report.tools[0]).toMatchObject({ tool: "codex", status: "wrote" }); + expect(report.tools[0]?.path.endsWith(join("project", ".codex/skills/simlock"))).toBe(true); + expect(await readdir(join(project, ".codex/skills/simlock"))).toEqual(["SKILL.md"]); + expect(await readdir(home)).toEqual([]); + }); + + it("the installed SKILL.md ends with exactly the text simlock instructions prints", async () => { + const env = await withDaemon({ mode: "auto" }); + const { home, project } = await operatorDirectories(env); + + const instructions = await env.cli(["instructions"]); + const setup = await env.cli(["setup", "--tool", "claude-code"], { + cwd: project, + env: { HOME: home }, + }); + + expect(instructions.code).toBe(0); + expect(setup.code).toBe(0); + const skill = await readFile(join(home, ".claude/skills/simlock/SKILL.md"), "utf8"); + expect(instructions.stdout.length).toBeGreaterThan(0); + expect(skill.endsWith(instructions.stdout)).toBe(true); + expect(skill.startsWith("---\nname: simlock\n")).toBe(true); + }); +}); diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 61a76d22..78e6ed14 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1163,6 +1163,8 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { ipc: ipcTransport, launcher, dataDirectory: "/simlock", + homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); const exitCode = await runCli( ["lease", "--platform", "ios", "--device", "iPhone 17 Pro", "--detach"], @@ -1200,6 +1202,8 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { ipc: ipcTransport, launcher, dataDirectory: "/simlock", + homeDirectory: "/home/operator", + workingDirectory: "/work/project", }); const exitCode = await runCli( ["lease", "--platform", "ios", "--device", "iPhone 17 Pro", "--detach"], @@ -2863,6 +2867,126 @@ describe("CLI: a SIMLOCK_HOME the kernel could not bind", () => { }); }); +describe("CLI: instructions", () => { + it("--help prints the command's usage instead of the instructions, exit 0", async () => { + const output = outputCapture(); + + await expect(runCli(["instructions", "--help"], output.environmentWith())).resolves.toBe(0); + + expect(output.stdout).toBe("Usage: simlock instructions [--json]\n"); + expect(output.stderr).toBe(""); + }); + + it("a positional argument fails with USAGE and exit 2, printing nothing on stdout", async () => { + const output = outputCapture(); + + await expect(runCli(["instructions", "extra"], output.environmentWith())).resolves.toBe(2); + + expect(output.stdout).toBe(""); + expect(JSON.parse(output.stderr)).toMatchObject({ error: { code: "USAGE" } }); + }); +}); + +describe("CLI: setup", () => { + /** Every path in the filesystem, so a test can say a run wrote nothing at all. */ + async function everyPath(filesystem: MemoryFilesystem, root = "/"): Promise { + 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 everyPath(filesystem, path))); + } + return paths.sort(); + } + + /** A home where both tools are set up, so a run that wrote anything would show it. */ + async function setUpHome(): Promise { + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp("/home/operator/.claude"); + await filesystem.mkdirp("/home/operator/.codex"); + await filesystem.mkdirp("/work/project"); + return filesystem; + } + + it("--help prints the usage line and exits 0", async () => { + const filesystem = await setUpHome(); + const output = outputCapture(realCliEnvironmentPorts(filesystem)); + + await expect(runCli(["setup", "--help"], output.environmentWith())).resolves.toBe(0); + + expect(output.stdout).toBe( + "Usage: simlock setup [--project] [--tool ] [--json]\n", + ); + expect(output.stderr).toBe(""); + }); + + it.each([ + ["--tool bogus", ["setup", "--tool", "bogus"]], + ["two --tool flags", ["setup", "--tool", "codex", "--tool", "claude-code"]], + ["a positional", ["setup", "codex"]], + ])("%s fails with USAGE, exit 2, and writes nothing", async (_case, argv) => { + const filesystem = await setUpHome(); + const before = await everyPath(filesystem); + const output = outputCapture(realCliEnvironmentPorts(filesystem)); + + await expect(runCli(argv, output.environmentWith())).resolves.toBe(2); + + expect(output.stdout).toBe(""); + expect(JSON.parse(output.stderr)).toMatchObject({ error: { code: "USAGE" } }); + expect(await everyPath(filesystem)).toEqual(before); + }); + + it("--json prints one JSON object with scope and one entry per tool", async () => { + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp("/home/operator/.claude"); + const output = outputCapture(realCliEnvironmentPorts(filesystem)); + + await expect(runCli(["setup", "--json"], output.environmentWith())).resolves.toBe(0); + + expect(output.stdout.endsWith("\n")).toBe(true); + expect(output.stdout.trimEnd().split("\n")).toHaveLength(1); + expect(JSON.parse(output.stdout)).toEqual({ + scope: "user", + tools: [ + { + tool: "claude-code", + status: "wrote", + path: "/home/operator/.claude/skills/simlock", + }, + { tool: "codex", status: "skipped", path: "/home/operator/.codex/skills/simlock" }, + ], + }); + }); + + it("the human view prints one line per tool, with a --tool hint for a skipped one", async () => { + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp("/work/project/.codex"); + const output = outputCapture(realCliEnvironmentPorts(filesystem)); + + await expect(runCli(["setup", "--project"], output.environmentWith())).resolves.toBe(0); + + expect(output.stdout).toBe( + "claude-code skipped /work/project/.claude/skills/simlock (not set up here; pass --tool claude-code to install)\n" + + "codex wrote /work/project/.codex/skills/simlock\n", + ); + }); + + it("a refused run fails with SETUP_REFUSED, exit 2, and prints nothing on stdout", async () => { + const filesystem = await setUpHome(); + await filesystem.mkdirp("/home/operator/.claude/skills"); + await filesystem.writeFileAtomic("/home/operator/.claude/skills/simlock", "a file"); + const output = outputCapture(realCliEnvironmentPorts(filesystem)); + + await expect(runCli(["setup"], output.environmentWith())).resolves.toBe(2); + + expect(output.stdout).toBe(""); + const { error } = JSON.parse(output.stderr) as { error: { code: string; message: string } }; + expect(error.code).toBe("SETUP_REFUSED"); + expect(error.message).toContain("/home/operator/.claude/skills/simlock"); + }); +}); + describe("CLI: pure helpers", () => { it("fallbackRequesterId prefers SIMLOCK_AGENT_ID over a pid-derived default", () => { expect(fallbackRequesterId({ SIMLOCK_AGENT_ID: "agent-7" })).toBe("agent-7"); @@ -3086,6 +3210,11 @@ function outputCapture(ports?: CliEnvironmentPorts): OutputCapture { sleep: async () => {}, readConfigFile: async () => ({}), writeConfigFile: async () => {}, + // `setup` is exercised through the real `buildCliEnvironment` (pass `ports`); a + // suite that reaches it through this mock has wired the wrong environment. + setupAgentTools: async () => { + throw new Error("setupAgentTools is not wired in the mocked environment"); + }, validateConfig: async () => {}, readLogFile: async () => "", signals: new EventEmitter() as unknown as CliEnvironment["signals"], @@ -3209,6 +3338,8 @@ function realCliEnvironmentPorts( ipc: new MemoryIpcTransport(), launcher: new FakeDaemonLauncher(), dataDirectory: "/simlock", + homeDirectory: "/home/operator", + workingDirectory: "/work/project", }; } diff --git a/src/cli/index.ts b/src/cli/index.ts index f2dff4e9..05a38528 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,3 +1,4 @@ +import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; @@ -44,6 +45,16 @@ import { } from "../lease-policy/index.js"; import { spawnPassthrough } from "./passthrough.js"; import { ERROR_TABLE } from "../contract/index.js"; +import { renderInstructions } from "../instructions/index.js"; +import { + AGENT_TOOL_IDS, + isAgentTool, + SetupRefusedError, + setupAgentTools, + type SetupReport, + type SetupScope, + type AgentTool, +} from "../instructions/setup.js"; const USAGE = `Usage: simlock [options] @@ -55,6 +66,9 @@ Commands: simctl Run xcrun simctl against Simlock's iOS device set 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 [--project] [--tool ] [--json] + Install those rules as a skill for Claude Code and Codex Run 'simlock --help' for command usage. Pass --token anywhere on the command line to connect as admin @@ -154,6 +168,12 @@ export interface CliEnvironment { readonly sleep: (milliseconds: number) => Promise; readonly readConfigFile: () => Promise>; readonly writeConfigFile: (contents: Record) => Promise; + /** `simlock setup`: installs the agent rules into agent tools under the home directory + * (user scope) or the working directory (project scope), both fixed at startup. */ + readonly setupAgentTools: (options: { + readonly scope: SetupScope; + readonly tool?: AgentTool | undefined; + }) => Promise; /** `config set` (ADR §11 part D): validates the merged file through the config loader before * `writeConfigFile` is ever called. Throws (any error) for an invalid merged config. */ readonly validateConfig: (merged: Record) => Promise; @@ -349,6 +369,10 @@ export interface CliEnvironmentPorts { readonly ipc: IpcConnector; readonly launcher: DaemonLauncher; readonly dataDirectory: string; + /** Where `simlock setup` installs by default, and where `--project` installs. Read once at + * the composition root, so nothing below it reaches for `homedir()` or `process.cwd()`. */ + readonly homeDirectory: string; + readonly workingDirectory: string; readonly parentWatch?: ParentWatch; readonly signals?: Signals; readonly stderr?: Output; @@ -430,6 +454,14 @@ export function buildCliEnvironment( await filesystem.mkdirp(dataDirectory); await filesystem.writeFileAtomic(configPath, `${JSON.stringify(contents, null, 2)}\n`); }, + setupAgentTools: ({ scope, tool }) => + setupAgentTools({ + filesystem, + homeDirectory: ports.homeDirectory, + workingDirectory: ports.workingDirectory, + scope, + tool, + }), // ADR §11 part D: "validates the merged file through the config loader before writing." // B9: two things the pre-fix version got wrong -- // - `warn` was never passed, so `validateConfigLayer`'s default no-op silently dropped @@ -497,6 +529,8 @@ function defaultCliEnvironment(env: NodeJS.ProcessEnv = process.env): CliEnviron simlockHome: dataDirectory, }), dataDirectory, + homeDirectory: homedir(), + workingDirectory: process.cwd(), }, env, ); @@ -608,6 +642,10 @@ export async function runCli( ); case "mcp": return await runMcp(rest.slice(1), environment); + case "instructions": + return runInstructions(rest.slice(1), environment); + case "setup": + return await runSetup(rest.slice(1), environment); default: throw new UsageError(withHelpHint(`Unknown command: ${rest[0]}`)); } @@ -632,6 +670,7 @@ function writeError(environment: CliEnvironment, error: unknown): void { function cliErrorCode(error: unknown): string { if (error instanceof UsageError || error instanceof SocketPathTooLongError) return "USAGE"; + if (error instanceof SetupRefusedError) return "SETUP_REFUSED"; if (isSimlockError(error)) return error.code; return "INTERNAL"; } @@ -854,6 +893,7 @@ async function resolveRemoteLeaseId( * CLI-maintained map. */ export function errorExitCode(error: unknown): number { if (error instanceof UsageError || error instanceof SocketPathTooLongError) return 2; + if (error instanceof SetupRefusedError) return 2; if (isSimlockError(error)) return ERROR_TABLE[error.code].cliExitCode; return 1; } @@ -870,6 +910,70 @@ async function runMcp(argv: readonly string[], environment: CliEnvironment): Pro return 0; } +/** + * Prints the static agent instructions. Never connects to the daemon, so it never auto-starts + * one either: the text belongs to the frontend, and the daemon has nothing to add to it. + */ +function runInstructions(argv: readonly string[], environment: CliEnvironment): number { + const values = commandArgs(argv, { + help: { type: "boolean", short: "h" }, + json: { type: "boolean" }, + }); + if (values.help) { + environment.stdout.write("Usage: simlock instructions [--json]\n"); + return 0; + } + if (values.positionals.length > 0) + throw new UsageError(`instructions accepts no arguments: ${values.positionals.join(" ")}`); + if (values.json) environment.stdout.write(`${renderInstructions("json")}\n`); + else environment.stdout.write(renderInstructions("text")); + return 0; +} + +const SETUP_USAGE = `Usage: simlock setup [--project] [--tool <${AGENT_TOOL_IDS.join("|")}>] [--json]`; + +/** + * Installs the agent rules as a skill into Claude Code and Codex. Never connects to the daemon, + * so it never auto-starts one: like `instructions`, the text is the frontend's. + */ +async function runSetup(argv: readonly string[], environment: CliEnvironment): Promise { + const values = commandArgs(argv, { + help: { type: "boolean", short: "h" }, + json: { type: "boolean" }, + project: { type: "boolean" }, + tool: { type: "string", multiple: true }, + }); + if (values.help) { + environment.stdout.write(`${SETUP_USAGE}\n`); + return 0; + } + if (values.positionals.length > 0) + throw new UsageError(`setup accepts no arguments: ${values.positionals.join(" ")}`); + const tools = (values.tool ?? []) as string[]; + if (tools.length > 1) throw new UsageError("setup accepts one --tool at most"); + const tool = tools[0]; + if (tool !== undefined && !isAgentTool(tool)) + throw new UsageError(`Unknown --tool: ${tool} (expected ${AGENT_TOOL_IDS.join(" or ")})`); + const report = await environment.setupAgentTools({ + scope: values.project === true ? "project" : "user", + tool, + }); + environment.stdout.write( + values.json === true ? `${JSON.stringify(report)}\n` : renderSetupReport(report), + ); + return 0; +} + +function renderSetupReport(report: SetupReport): string { + return report.tools + .map(({ path, status, tool }) => { + const hint = + status === "skipped" ? ` (not set up here; pass --tool ${tool} to install)` : ""; + return `${tool} ${status} ${path}${hint}\n`; + }) + .join(""); +} + /** Parses user-facing durations only at the CLI boundary. */ export function parseDuration(value: string): number { const match = /^(\d+)(ms|s|m|h)?$/.exec(value); diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index dd0d8fa7..56393fa5 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -227,7 +227,7 @@ const RECLAIM_INSTEAD = * positional scan is the only rule that catches every spelling without this module having * to parse adb's own option grammar. */ -const REFUSED_ADB_VERB = "kill-server"; +export const REFUSED_ADB_VERB = "kill-server"; /** * Console commands `simlock adb` will not proxy, matched as a run of adjacent arguments @@ -346,7 +346,7 @@ function callerSuppliedScopeFlag(args: readonly string[]): string | undefined { return walked.kind === "refused" ? walked.argument : undefined; } -const REFUSED_ADB_SEQUENCES: readonly { +export const REFUSED_ADB_SEQUENCES: readonly { readonly sequence: readonly string[]; readonly reason: string; }[] = [ diff --git a/src/drivers/ios/index.ts b/src/drivers/ios/index.ts index 85023d7b..cd44905c 100644 --- a/src/drivers/ios/index.ts +++ b/src/drivers/ios/index.ts @@ -79,7 +79,7 @@ export const IOS_PASSTHROUGH_TOOL = "simctl"; * reads as tampering on the next reconcile. Injecting `--set` for them would hand back * exactly the capability the device set exists to take away (ADR 0001, decision 7). */ -const REFUSED_SIMCTL_VERBS = new Set(["create", "erase", "delete"]); +export const REFUSED_SIMCTL_VERBS: ReadonlySet = new Set(["create", "erase", "delete"]); /** * simctl's usage is `simctl [--set ] [--profiles ] `, so these are @@ -91,14 +91,17 @@ const REFUSED_SIMCTL_VERBS = new Set(["create", "erase", "delete"]); * its own terms: this wrapper exists to supply the device set, and a caller-supplied one * would aim Simlock's own containment wherever it pointed. */ -const CALLER_SUPPLIED_SCOPE_FLAGS = new Set(["set", "profiles"]); +export const CALLER_SUPPLIED_SCOPE_FLAGS: ReadonlySet = new Set(["set", "profiles"]); /** * `shutdown all` is the iOS analogue of `adb kill-server`: it stops every device in the * set, for every agent, and each affected lease then spends its recovery budget rebooting * -- one that runs out ends as `lease_lost`. Shutting down a single device stays allowed. */ -const SHUTDOWN_ALL_TARGET = "all"; +export const SHUTDOWN_ALL_TARGET = "all"; + +/** `runtime delete`: the one `runtime` operation refused -- see `#assertProxyable`. */ +export const REFUSED_RUNTIME_OPERATION = "delete"; /** Every lifecycle refusal ends the same way: the Simlock command that does it safely. */ const RECLAIM_INSTEAD = @@ -1275,9 +1278,12 @@ export class IosSimctlDriver implements Driver { // A bare `simctl` reaches this too, so refusing it takes no capability away. The // wrapper is advertised as the safe path, and being the convenient route to an // unrecoverable multi-gigabyte deletion is not that. - if (verb === "runtime" && operands.find((operand) => !operand.startsWith("-")) === "delete") { + if ( + verb === "runtime" && + operands.find((operand) => !operand.startsWith("-")) === REFUSED_RUNTIME_OPERATION + ) { this.#refuse( - "runtime delete", + `runtime ${REFUSED_RUNTIME_OPERATION}`, "it deletes a runtime shared with Xcode, and Simlock will not download one back (`--allow-download` cannot install iOS runtimes). Delete it through Xcode if that is really what you meant.", ); } diff --git a/src/instructions/index.test.ts b/src/instructions/index.test.ts new file mode 100644 index 00000000..cdb758d4 --- /dev/null +++ b/src/instructions/index.test.ts @@ -0,0 +1,89 @@ +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { REFUSED_ADB_SEQUENCES, REFUSED_ADB_VERB } from "../drivers/android/index.js"; +import { + CALLER_SUPPLIED_SCOPE_FLAGS, + REFUSED_RUNTIME_OPERATION, + REFUSED_SIMCTL_VERBS, + SHUTDOWN_ALL_TARGET, +} from "../drivers/ios/index.js"; +import { AGENT_INSTRUCTIONS, renderSkill } from "./index.js"; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +/** The one line of the instructions that lists what `simlock ` refuses. */ +function refusalLine(tool: "simctl" | "adb"): string { + const lines = AGENT_INSTRUCTIONS.split("\n").filter((line) => + line.trim().startsWith(`- \`simlock ${tool}\` refuses `), + ); + expect(lines, `exactly one refusal line for simlock ${tool}`).toHaveLength(1); + return lines[0] as string; +} + +describe("agent instructions", () => { + it.each([ + ["the agent instructions name", AGENT_INSTRUCTIONS], + ["the rendered skill names", renderSkill()], + ])("%s no path inside this repository", (_name, text) => { + expect(text).not.toMatch(/docs\//); + expect(text).not.toMatch(/\.md\b/); + + // Every tracked file, and every directory holding one, as it would be written in prose. + // Enumerated from the git index so the check covers the whole repository, not a list of + // paths this test happened to think of. + const files = execFileSync("git", ["ls-files", "-z"], { cwd: REPO_ROOT, encoding: "utf8" }) + .split("\0") + .filter((name) => name.length > 0); + expect(files.length).toBeGreaterThan(100); + const directories = new Set( + files.flatMap((name) => + name + .split("/") + .slice(0, -1) + .map((_, index, parts) => `${parts.slice(0, index + 1).join("/")}/`), + ), + ); + const named = [...files, ...directories].filter((path) => text.includes(path)); + expect(named).toEqual([]); + }); + + it("the rendered skill ends with the exact agent instructions, and its front matter names the skill simlock", () => { + const skill = renderSkill(); + + expect(skill.endsWith(AGENT_INSTRUCTIONS)).toBe(true); + const header = skill.slice(0, skill.length - AGENT_INSTRUCTIONS.length); + // Front matter, one blank line, then the instructions: nothing else around them. + const match = /^---\n([\s\S]*?)\n---\n\n$/.exec(header); + expect(match, `front matter then a blank line, got ${JSON.stringify(header)}`).not.toBeNull(); + const fields = (match?.[1] ?? "").split("\n"); + expect(fields).toContain("name: simlock"); + expect(fields.filter((line) => line.startsWith("description: "))).toHaveLength(1); + expect(fields).toHaveLength(2); + }); + + it("name every refused passthrough verb the drivers refuse", () => { + // Read from the drivers' own refusal constants, so a verb a driver starts refusing fails + // this test until the instructions tell agents about it. + const simctl = [ + ...REFUSED_SIMCTL_VERBS, + `shutdown ${SHUTDOWN_ALL_TARGET}`, + `runtime ${REFUSED_RUNTIME_OPERATION}`, + ...[...CALLER_SUPPLIED_SCOPE_FLAGS].map((flag) => `--${flag}`), + ]; + const adb = [ + REFUSED_ADB_VERB, + ...REFUSED_ADB_SEQUENCES.map(({ sequence }) => sequence.join(" ")), + ]; + expect(simctl.length).toBeGreaterThan(1); + expect(adb.length).toBeGreaterThan(1); + + const simctlLine = refusalLine("simctl"); + for (const verb of simctl) expect(simctlLine).toContain(`\`${verb}\``); + const adbLine = refusalLine("adb"); + for (const verb of adb) expect(adbLine).toContain(`\`${verb}\``); + }); +}); diff --git a/src/instructions/index.ts b/src/instructions/index.ts new file mode 100644 index 00000000..34bd440e --- /dev/null +++ b/src/instructions/index.ts @@ -0,0 +1,94 @@ +/** + * The rules an agent must follow to share devices through Simlock, as one self-contained block + * an operator pastes into an agent's system prompt. Simlock is advisory: it only works when + * every agent goes through it, so these rules are the half of the system the daemon cannot + * enforce. + * + * The one source of this text. `simlock instructions` prints it and the MCP server serves it + * as the `simlock://instructions` resource; neither keeps a copy. It is static and + * frontend-owned (architecture rule 8): the daemon never learns about it. It names no file + * in this repository (documentation rule 3) -- a reader who installed the package has none. + */ +export const AGENT_INSTRUCTIONS = `# Using Simlock to share iOS simulators and Android emulators + +Simlock hands out simulators and emulators to parallel agents so they do not fight over the same device. It only works if every agent follows these rules. + +## Never call the platform tools directly + +- Do not run \`xcrun simctl\`, \`adb\`, \`avdmanager\`, or \`emulator\` yourself. +- Use \`simlock simctl \` and \`simlock adb \` instead. They pass your arguments through unchanged and add the device set or adb server port that reaches Simlock's devices. A bare \`simctl\` or \`adb\` cannot see those devices, and can break other agents' devices. +- Only drive the device you leased. Never touch a device another agent holds. + +## Lease a device + +1. Set a stable \`SIMLOCK_AGENT_ID\` for your whole session, distinct from every other agent's, and export it before any \`simlock\` command. +2. Run \`simlock catalog\` first to see which device models and OS versions can be leased. Do not guess. +3. Run \`simlock lease --platform --device \` (add \`--os \` to pick a runtime) in the background. It blocks until the device is ready, prints one JSON line on stdout, then keeps running: it renews the lease, and releases it when it exits. +4. Read that one JSON line on stdout: it holds \`lease.id\`, \`lease.ttlDeadline\`, the \`device\`, and an \`environment\` object. Progress (queue position, provisioning, booting) arrives as JSON lines on stderr. +5. If you cannot keep a process running, use \`simlock lease ... --detach\` instead. It prints the same line and exits, and nothing renews the lease for you: run \`simlock lease renew \` before \`ttlDeadline\`, every time, or the lease expires and the device is taken back. + +## One lease, and give it back + +- Hold at most one lease at a time. Release it before asking for another. +- Release with \`simlock release \`, or by stopping the background \`simlock lease\` process. +- \`simlock release --all\` and \`simlock nuke\` are operator commands. Never run them: they take devices away from every agent. +- Never pass \`--allow-download\` unless a person told you to. It can download many gigabytes. + +## Exit codes to handle + +- \`10\`: timed out waiting for a device. Retry later, or with a longer \`--timeout\`. +- \`11\`: no capacity, and \`--no-wait\` was set. Retry later, or drop \`--no-wait\` to wait in the queue. +- \`13\`: you already hold a lease or have a request queued. When it is a lease, the error message names it: keep using it, or release it first. When it is a queued request, wait for it or stop the \`simlock lease\` that made it. +- \`14\`: your background \`simlock lease\` ended because Simlock took the lease back (TTL expiry, an operator release, or a device that could not be recovered). The device is no longer yours: stop using it, and lease again if you still need one. + +Every failure also writes one JSON line on stderr, \`{"error":{"code":"...","message":"..."}}\`. Branch on \`code\`, not on the message. + +## Reach the leased device + +- Use \`simlock simctl\` and \`simlock adb\`. On iOS, name the device by the udid in the grant (\`device.driverDeviceId\`). +- If another tool has to call the real binary, use the grant's \`environment\` block: \`SIMLOCK_IOS_DEVICE_SET\` is the path to pass as \`xcrun simctl --set\`, and \`ANDROID_ADB_SERVER_PORT\` is the port \`adb\` reads on its own. \`simlock lease ... --export-env\` prints it as shell \`export\` lines. +- When the device is on another machine (through a gateway), only \`simlock simctl --lease \` and \`simlock adb --lease \` can reach it. +- Some commands are refused on purpose, because they would break the device for Simlock or for other agents. Do not look for a way around them; use \`simlock release\` instead: + - \`simlock simctl\` refuses \`create\`, \`erase\`, \`delete\`, \`shutdown all\`, \`runtime delete\`, and any option before the subcommand, \`--set\` and \`--profiles\` included. + - \`simlock adb\` refuses \`kill-server\`, \`emu kill\`, \`emu avd stop\`, \`emu avd snapshot delete\`, any option before the subcommand other than \`-s\`, \`-t\`, \`-d\`, or \`-e\` (\`--version\` and \`--help\` on their own still work), and, when the device is on another machine, a bare \`shell\` with no command to run. + +## Over MCP + +If Simlock is connected to you as an MCP server, use its four tools instead of the lease commands: + +- \`list_devices\`: what can be leased. Call it before leasing. +- \`lease_simulator\`: lease one device. The lease is renewed for you, and released when the MCP server exits. +- \`release_simulator\`: give the device back. +- \`lease_status\`: whether you still hold a lease, and which one. Call it after a context compaction, before assuming you still have a device. + +The rules above still apply: reach the device through \`simlock simctl\` and \`simlock adb\`, never through the platform tools. + +More: https://github.com/callstackincubator/simlock +`; + +/** What `simlock instructions` prints: the Markdown itself, or one JSON object carrying it. */ +export function renderInstructions(format: "text" | "json"): string { + return format === "json" + ? JSON.stringify({ instructions: AGENT_INSTRUCTIONS }) + : AGENT_INSTRUCTIONS; +} + +/** + * The skill header `simlock setup` puts in front of the instructions. Its `description` is what + * an agent tool reads to decide when the skill applies, so it names the situation, not Simlock's + * internals, and no file in this repository (documentation rule 3). + */ +const SKILL_FRONT_MATTER = `--- +name: simlock +description: Use before you touch an iOS simulator or Android emulator, or run simctl, adb, avdmanager, or emulator, so you lease the device through Simlock and never break another agent's device. +--- +`; + +/** + * The `SKILL.md` `simlock setup` installs: the skill header, a blank line, then + * `AGENT_INSTRUCTIONS` unchanged, so the installed rules are byte for byte what + * `simlock instructions` prints. + */ +export function renderSkill(): string { + return `${SKILL_FRONT_MATTER}\n${AGENT_INSTRUCTIONS}`; +} diff --git a/src/instructions/setup.test.ts b/src/instructions/setup.test.ts new file mode 100644 index 00000000..f1a8394a --- /dev/null +++ b/src/instructions/setup.test.ts @@ -0,0 +1,225 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { MemoryFilesystem, NodeFilesystem } from "../ports/filesystem.js"; +import { renderSkill } from "./index.js"; +import { SetupRefusedError, setupAgentTools, type SetupAgentToolsOptions } from "./setup.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", + ...options, + }); +} + +/** Every path under `root`, files and directories, so a test can say "nothing else changed". */ +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("installs a named tool even when its presence directory is missing, creating the skills directory", async () => { + const filesystem = await withBases(); + + const report = await setup(filesystem, { tool: "codex" }); + + expect(report).toEqual({ + scope: "user", + tools: [{ tool: "codex", status: "wrote", path: `${HOME}/.codex/skills/simlock` }], + }); + expect(await filesystem.readFile(`${HOME}/.codex/skills/simlock/SKILL.md`)).toBe(renderSkill()); + expect(await tree(filesystem, HOME)).toEqual([ + `${HOME}/.codex`, + `${HOME}/.codex/skills`, + `${HOME}/.codex/skills/simlock`, + `${HOME}/.codex/skills/simlock/SKILL.md`, + ]); + }); + + it("with no tool named, installs only tools whose presence directory exists and reports the others as skipped", async () => { + const filesystem = await withBases(); + await filesystem.mkdirp(`${HOME}/.claude`); + + const report = await setup(filesystem); + + expect(report.tools).toEqual([ + { tool: "claude-code", status: "wrote", path: `${HOME}/.claude/skills/simlock` }, + { tool: "codex", status: "skipped", path: `${HOME}/.codex/skills/simlock` }, + ]); + expect(await filesystem.exists(`${HOME}/.claude/skills/simlock/SKILL.md`)).toBe(true); + expect(await filesystem.exists(`${HOME}/.codex`)).toBe(false); + }); + + it("with no tool named, a file where a tool's presence directory belongs counts as not set up", async () => { + const filesystem = await withBases(); + await filesystem.mkdirp(`${HOME}/.claude`); + await filesystem.writeFileAtomic(`${HOME}/.codex`, "not a directory"); + + const report = await setup(filesystem); + + expect(report.tools.map(({ status }) => status)).toEqual(["wrote", "skipped"]); + expect(await filesystem.readFile(`${HOME}/.codex`)).toBe("not a directory"); + }); + + // On the real filesystem: the in-memory double's `mkdirp` does not walk through a symlink. + it("with no tool named, a presence directory reached through a symlink counts as set up", async () => { + const root = await mkdtemp(join(tmpdir(), "simlock-setup-")); + try { + const home = join(root, "home"); + await mkdir(join(root, "dotfiles/claude"), { recursive: true }); + await mkdir(home); + await symlink(join(root, "dotfiles/claude"), join(home, ".claude")); + + const report = await setupAgentTools({ + filesystem: new NodeFilesystem(), + homeDirectory: home, + workingDirectory: CWD, + scope: "user", + }); + + expect(report.tools.map(({ status }) => status)).toEqual(["wrote", "skipped"]); + expect(await readFile(join(root, "dotfiles/claude/skills/simlock/SKILL.md"), "utf8")).toBe( + renderSkill(), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("the project scope writes under the working directory and nothing under the home directory, and the user scope the reverse", async () => { + const project = await withBases(); + const projectReport = await setup(project, { scope: "project", tool: "claude-code" }); + expect(projectReport.scope).toBe("project"); + expect(await tree(project, CWD)).toContain(`${CWD}/.claude/skills/simlock/SKILL.md`); + expect(await tree(project, HOME)).toEqual([]); + + const user = await withBases(); + const userReport = await setup(user, { scope: "user", tool: "claude-code" }); + expect(userReport.scope).toBe("user"); + expect(await tree(user, HOME)).toContain(`${HOME}/.claude/skills/simlock/SKILL.md`); + expect(await tree(user, CWD)).toEqual([]); + }); + + it("a second run reports replaced and leaves the same files", 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 report = await setup(filesystem); + + expect(report.tools.map(({ status }) => status)).toEqual(["replaced", "replaced"]); + expect(await tree(filesystem, HOME)).toEqual(first); + for (const tool of [".claude", ".codex"]) + expect(await filesystem.readFile(`${HOME}/${tool}/skills/simlock/SKILL.md`)).toBe( + renderSkill(), + ); + }); + + it("removes a stale file inside the Simlock directory from an earlier run, and overwrites an old SKILL.md", async () => { + const filesystem = await withBases(); + 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"); + + const report = await setup(filesystem, { tool: "claude-code" }); + + expect(report.tools).toEqual([ + { tool: "claude-code", status: "replaced", path: `${HOME}/.claude/skills/simlock` }, + ]); + 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"); + }); + + // On the real filesystem: a rename onto a directory fails there, and the in-memory double + // would quietly let it through. + it("replaces a directory named SKILL.md inside the Simlock directory with the skill", async () => { + const home = await mkdtemp(join(tmpdir(), "simlock-setup-")); + try { + const simlock = join(home, ".codex/skills/simlock"); + await mkdir(join(simlock, "SKILL.md/nested"), { recursive: true }); + + const report = await setupAgentTools({ + filesystem: new NodeFilesystem(), + homeDirectory: home, + workingDirectory: CWD, + scope: "user", + tool: "codex", + }); + + expect(report.tools.map(({ status }) => status)).toEqual(["replaced"]); + expect(await readdir(simlock)).toEqual(["SKILL.md"]); + expect(await readFile(join(simlock, "SKILL.md"), "utf8")).toBe(renderSkill()); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it.each([ + [ + "file", + async (filesystem: MemoryFilesystem, path: string) => + filesystem.writeFileAtomic(path, "someone else's"), + ], + [ + "symlink", + async (filesystem: MemoryFilesystem, path: string) => { + await filesystem.mkdirp("/elsewhere"); + filesystem.defineSymlink(path, "/elsewhere"); + }, + ], + ])( + "a %s at the Simlock directory path refuses the run, and nothing is written for any tool", + async (_kind, place) => { + const filesystem = await withBases(); + await filesystem.mkdirp(`${HOME}/.claude`); + await filesystem.mkdirp(`${HOME}/.codex/skills`); + // codex comes second in the table, so a refusal that only stopped at the bad tool would + // already have written claude-code's skill. + await place(filesystem, `${HOME}/.codex/skills/simlock`); + const before = await tree(filesystem, "/"); + + const run = setup(filesystem); + + await expect(run).rejects.toBeInstanceOf(SetupRefusedError); + await expect(run).rejects.toThrow(`${HOME}/.codex/skills/simlock`); + expect(await tree(filesystem, "/")).toEqual(before); + }, + ); +}); diff --git a/src/instructions/setup.ts b/src/instructions/setup.ts new file mode 100644 index 00000000..a257eab5 --- /dev/null +++ b/src/instructions/setup.ts @@ -0,0 +1,140 @@ +import { join } from "node:path"; + +import { isMissingPathError, type Filesystem, type PathDetails } from "../ports/filesystem.js"; +import { renderSkill } from "./index.js"; + +/** + * The agent tools `simlock setup` installs the rules into, and the one place that knows where + * each keeps its skills. `presence` is the directory whose existence says the tool is set up + * there; `skills` is where the tool looks for skills. Both are relative to the scope's base: + * the home directory for the user scope, the working directory for the project scope. + */ +const AGENT_TOOLS = { + "claude-code": { presence: ".claude", skills: ".claude/skills" }, + codex: { presence: ".codex", skills: ".codex/skills" }, +} as const; + +export type AgentTool = keyof typeof AGENT_TOOLS; +export type SetupScope = "user" | "project"; + +export const AGENT_TOOL_IDS = Object.keys(AGENT_TOOLS) as readonly AgentTool[]; + +/** Simlock's whole footprint in a tool: one directory it owns outright, holding one file. */ +const SIMLOCK_DIRECTORY = "simlock"; +const SKILL_FILE = "SKILL.md"; + +export interface SetupToolReport { + readonly tool: AgentTool; + readonly status: "wrote" | "replaced" | "skipped"; + /** The Simlock directory: where the skill was written, or would have been for a skipped tool. */ + readonly path: string; +} + +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; + /** Install for this tool whether or not it is set up there. Omitted: every tool that is. */ + readonly tool?: AgentTool | undefined; +} + +/** Something other than a directory sits where a Simlock directory belongs. Nothing was written. */ +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 function isAgentTool(value: string): value is AgentTool { + return Object.hasOwn(AGENT_TOOLS, value); +} + +/** + * Installs the agent rules as a skill into each selected tool. Inspects every tool before + * writing any, so a refusal leaves every tool exactly as it found it (architecture rule 12). + * Writes nothing outside `/simlock`, and within it leaves only `SKILL.md`. + */ +export async function setupAgentTools(options: SetupAgentToolsOptions): Promise { + const { filesystem, scope, tool } = options; + const base = scope === "user" ? options.homeDirectory : options.workingDirectory; + const tools = tool === undefined ? AGENT_TOOL_IDS : [tool]; + + // Inspect: every status is settled, and every refusal thrown, before anything is written. + const report: SetupToolReport[] = []; + for (const id of tools) { + const layout = AGENT_TOOLS[id]; + const path = join(base, layout.skills, SIMLOCK_DIRECTORY); + const install = + tool !== undefined || (await isDirectory(filesystem, join(base, layout.presence))); + const status = !install + ? "skipped" + : (await isExistingDirectory(filesystem, path)) + ? "replaced" + : "wrote"; + report.push({ tool: id, status, path }); + } + + // Write. + const skill = renderSkill(); + for (const entry of report) { + if (entry.status !== "skipped") await installSkill(filesystem, entry.path, skill); + } + return { scope, tools: report }; +} + +/** Leaves `directory` holding exactly one file, `SKILL.md`, with `skill` in it. */ +async function installSkill( + filesystem: Filesystem, + directory: string, + skill: string, +): Promise { + await filesystem.mkdirp(directory); + const skillPath = join(directory, SKILL_FILE); + // Anything but a file under that name (a directory left by hand) cannot be renamed over. + // It is inside the Simlock directory, so it is Simlock's to remove. + 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)); + } +} + +/** + * Whether a Simlock directory is already there, read without following a symlink. A file, a + * symlink, or anything else at that path refuses the run: the directory is Simlock's to replace + * whole, and a symlink would point that replacement somewhere Simlock does not own. + */ +async function isExistingDirectory(filesystem: Filesystem, path: string): Promise { + const kind = await lstatKind(filesystem, path); + if (kind === undefined) return false; + if (kind !== "directory") throw new SetupRefusedError(path); + return true; +} + +/** What is at `path`, not following a final symlink; `undefined` when nothing is. */ +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; + } +} + +/** Whether a tool is set up at `path`: a directory there, or a symlink to one. A file is not. */ +async function isDirectory(filesystem: Filesystem, path: string): Promise { + if (!(await filesystem.exists(path))) return false; + return (await filesystem.stat(path)).kind === "directory"; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e0ae6cfc..75f13ca9 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,6 +1,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ProgressNotification } from "@modelcontextprotocol/sdk/types.js"; +import { AGENT_INSTRUCTIONS } from "../instructions/index.js"; + import { leaseSimulatorInputSchema, leaseSimulatorOutputSchema, @@ -20,6 +22,8 @@ import { const SERVER_INFO = { name: "simlock", version: "1.0.0" }; +const INSTRUCTIONS_URI = "simlock://instructions"; + /** * Progress is reported on a 3-stage scale (queued / provisioning-or-reclaiming / booting), each * worth 1000 units, so a fresh stage's base always exceeds the previous stage's maximum. @@ -95,6 +99,23 @@ function createLeaseProgressReporter( export function createMcpServer(session: McpSession): McpServer { const server = new McpServer(SERVER_INFO, { capabilities: { logging: {} } }); + // The same text `simlock instructions` prints, so a client can put it in the agent's context + // without an operator pasting it. A resource, not a tool: it is read, never invoked. + // Registering it is also what declares the `resources` capability -- the SDK adds it. + server.registerResource( + "instructions", + INSTRUCTIONS_URI, + { + title: "Simlock agent instructions", + description: + "The rules an agent must follow to share simulators and emulators through Simlock: never call the platform tools directly, how to lease and release, what each exit code means, and which commands are refused.", + mimeType: "text/markdown", + }, + (uri) => ({ + contents: [{ mimeType: "text/markdown", text: AGENT_INSTRUCTIONS, uri: uri.href }], + }), + ); + server.registerTool( "lease_simulator", {