Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).

Expand Down
86 changes: 76 additions & 10 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <start|stop|status|logs>` are the exception: they default to a
agents are the primary audience. `status`, `catalog`, `instructions`,
`setup`, and `daemon <start|stop|status|logs>` 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)
Expand All @@ -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

Expand All @@ -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 |
Expand All @@ -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.
Expand Down Expand Up @@ -792,7 +796,8 @@ token revoke <token-id>` 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`
Expand Down Expand Up @@ -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":"<markdown>"}`, 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 <claude-code|codex>] [--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.
Expand Down
42 changes: 42 additions & 0 deletions e2e/agent-instructions.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
4 changes: 4 additions & 0 deletions e2e/helpers/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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 = "";
Expand Down
23 changes: 23 additions & 0 deletions e2e/mcp-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
77 changes: 77 additions & 0 deletions e2e/setup.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading