diff --git a/README.md b/README.md index 6d458ec9..7aa4e160 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,8 @@ The daemon starts on demand — there's no separate setup step. Use `simlock nuke --yes --delete-devices` only for an emergency reset of 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 diff --git a/docs/CLI.md b/docs/CLI.md index a983a5dc..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`, `instructions`, 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. @@ -868,6 +872,46 @@ 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/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/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 1f3a2847..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"], @@ -2883,6 +2887,106 @@ describe("CLI: instructions", () => { }); }); +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"); @@ -3106,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"], @@ -3229,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 80595b2d..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"; @@ -45,6 +46,15 @@ import { 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] @@ -57,6 +67,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 [--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 @@ -156,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; @@ -351,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; @@ -432,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 @@ -499,6 +529,8 @@ function defaultCliEnvironment(env: NodeJS.ProcessEnv = process.env): CliEnviron simlockHome: dataDirectory, }), dataDirectory, + homeDirectory: homedir(), + workingDirectory: process.cwd(), }, env, ); @@ -612,6 +644,8 @@ export async function runCli( 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]}`)); } @@ -636,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"; } @@ -858,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; } @@ -894,6 +930,50 @@ function runInstructions(argv: readonly string[], environment: CliEnvironment): 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/instructions/index.test.ts b/src/instructions/index.test.ts index ab9c6f59..cdb758d4 100644 --- a/src/instructions/index.test.ts +++ b/src/instructions/index.test.ts @@ -11,7 +11,7 @@ import { REFUSED_SIMCTL_VERBS, SHUTDOWN_ALL_TARGET, } from "../drivers/ios/index.js"; -import { AGENT_INSTRUCTIONS } from "./index.js"; +import { AGENT_INSTRUCTIONS, renderSkill } from "./index.js"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -25,9 +25,12 @@ function refusalLine(tool: "simctl" | "adb"): string { } describe("agent instructions", () => { - it("name no path inside this repository", () => { - expect(AGENT_INSTRUCTIONS).not.toMatch(/docs\//); - expect(AGENT_INSTRUCTIONS).not.toMatch(/\.md\b/); + 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 @@ -44,10 +47,24 @@ describe("agent instructions", () => { .map((_, index, parts) => `${parts.slice(0, index + 1).join("/")}/`), ), ); - const named = [...files, ...directories].filter((path) => AGENT_INSTRUCTIONS.includes(path)); + 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. diff --git a/src/instructions/index.ts b/src/instructions/index.ts index a6576fbf..34bd440e 100644 --- a/src/instructions/index.ts +++ b/src/instructions/index.ts @@ -72,3 +72,23 @@ export function renderInstructions(format: "text" | "json"): string { ? 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"; +}