Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1508800
test: an operator runs simlock setup for real and the skill lands whe…
V3RON Oct 23, 2026
104f203
feat(cli): simlock setup writes the skill for a named tool (#620)
V3RON Oct 23, 2026
3c79d37
test: setup writes the skill where each tool reads it for the scope (…
V3RON Oct 23, 2026
3398afe
feat(instructions): setup writes under home or the working directory …
V3RON Oct 23, 2026
ebbe424
test: setup picks the tools set up here, and installs a named tool an…
V3RON Oct 23, 2026
6a69feb
feat(instructions): setup installs the tools set up here (#620)
V3RON Oct 23, 2026
e7d7a01
test: setup connects MCP through the tool's own command (#620)
V3RON Oct 23, 2026
bc33407
feat(instructions): setup connects MCP through the tool's own command…
V3RON Oct 23, 2026
986efcf
test: setup asks in a terminal and never blocks a script (#620)
V3RON Oct 23, 2026
fb55a5b
feat(instructions): setup asks for the scope and for MCP in a termina…
V3RON Oct 23, 2026
2e296d9
fix(instructions): the setup test leaves the scope to the prompt (#620)
V3RON Oct 23, 2026
c229547
test: a second setup run replaces only its own files (#620)
V3RON Oct 23, 2026
49839d9
feat(instructions): a second setup run replaces its own files only (#…
V3RON Oct 23, 2026
8af1e9a
test: setup refuses a file or a symlink where the skill directory goe…
V3RON Oct 23, 2026
75861fc
feat(instructions): setup refuses what it does not own (#620)
V3RON Oct 23, 2026
5e1fff3
fix(instructions): the setup tests walk the root directory (#620)
V3RON Oct 23, 2026
843be33
test: an agent reads the router skill (#620)
V3RON Oct 23, 2026
e9a095e
feat(instructions): the router skill sends the agent to whoami and in…
V3RON Oct 23, 2026
9288e49
test: setup prints its report and fails the way the spec says (#620)
V3RON Oct 23, 2026
ce9ea3d
feat(cli): simlock setup prints its report and reports its two failur…
V3RON Oct 23, 2026
40ba98e
test: setup connects MCP through the tool's command in a real run (#620)
V3RON Oct 23, 2026
790c360
docs(instructions): simlock setup on the website and in the manuals (…
V3RON Oct 23, 2026
df118eb
refactor(instructions): split setup into steps for Fallow (#620)
V3RON Oct 23, 2026
04beaf9
refactor(instructions): smaller setup steps (#620)
V3RON Oct 23, 2026
8cfbe3f
test(instructions): the skill description names the command list as t…
V3RON Oct 23, 2026
277f893
fix(instructions): the CLI reads the tool table; a test pins project-…
V3RON Oct 23, 2026
79425f2
docs(instructions): the manual names no file that does not exist (#620)
V3RON Oct 23, 2026
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
2 changes: 1 addition & 1 deletion docs/internal/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ worker daemon ── uplink (WebSocket to /v1/uplink, worker dials) ──┘
| history | The durable event history: `history.db`, prune, set-aside, projections (usage: request, minute and device fact rows) | [history](../../src/history/AGENTS.md) |
| ports | Every external API behind an interface, with a fake | [ports](../../src/ports/AGENTS.md) |
| host-load | CPU, memory and disk load samples | [host-load](../../src/host-load/AGENTS.md) |
| instructions | The static rules text for agents: `simlock instructions`, the MCP server instructions and resource | [instructions](../../src/instructions/AGENTS.md) |
| instructions | The static rules text for agents: `simlock instructions`, the MCP server instructions and resource; `simlock setup`, which installs the router skill | [instructions](../../src/instructions/AGENTS.md) |
| agent-identity | Default requester id for CLI and MCP | [agent-identity](../../src/agent-identity/AGENTS.md) |
| daemon-protocol | Socket framing and the protocol version | [daemon-protocol](../../src/daemon-protocol/AGENTS.md) |
| test-support | Shared test helpers | [test-support](../../src/test-support/AGENTS.md) |
Expand Down
2 changes: 2 additions & 0 deletions e2e/helpers/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface CliOptions {
readonly input?: string;
readonly timeout?: number;
readonly entry?: string;
readonly cwd?: string;
}

export interface CliError {
Expand Down Expand Up @@ -39,6 +40,7 @@ export function cli(
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [options.entry ?? CLI_ENTRY, ...args], {
env: { ...env, ...options.env },
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
});
let stdout = "";
let stderr = "";
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 { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

import { describe, expect, it } from "vitest";

import { renderSkill } from "../src/instructions/index.js";
import { withDaemon, type TestEnv } from "./helpers/index.js";

async function operatorDirectories(env: TestEnv): Promise<{ home: string; project: string }> {
const home = join(env.home, "operator-home");
const project = join(env.home, "project");
await mkdir(home);
await mkdir(project);
return { home, project };
}

async function fakeClaude(env: TestEnv): Promise<{ bin: string; record: string }> {
const bin = join(env.home, "bin");
const record = join(env.home, "claude-args");
await mkdir(bin);
await writeFile(join(bin, "claude"), `#!/bin/sh\nprintf '%s ' "$@" > '${record}'\nexit 0\n`);
await chmod(join(bin, "claude"), 0o755);
return { bin, record };
}

describe("simlock setup", () => {
it("writes the skill where each tool reads it and connects MCP through the tool's command, for real, and starts no daemon", async () => {
const env = await withDaemon({ mode: "auto" });
const { home, project } = await operatorDirectories(env);

const result = await env.cli(["setup", "--tool", "claude-code"], {
cwd: project,
env: { HOME: home },
});

expect(result.code).toBe(0);
expect(result.stderr).toBe("");
expect(await readFile(join(home, ".claude/skills/simlock/SKILL.md"), "utf8")).toBe(
renderSkill(),
);
// why: `mode: "auto"` leaves the daemon unstarted; any daemon-touching command would have
// auto-started one and left its socket here.
expect(existsSync(env.socketPath)).toBe(false);

const { bin, record } = await fakeClaude(env);
const mcp = await env.cli(["setup", "--tool", "claude-code", "--mcp"], {
cwd: project,
env: { HOME: home, PATH: bin },
});
expect(mcp.code).toBe(0);
expect(mcp.stdout).toContain("claude-code mcp connected\n");
expect(await readFile(record, "utf8")).toBe("mcp add -s user simlock -- simlock mcp ");

const noCodex = await env.cli(["setup", "--tool", "codex", "--mcp"], {
cwd: project,
env: { HOME: home, PATH: bin },
});
expect(noCodex.code).toBe(0);
expect(noCodex.stdout).toContain("codex mcp skipped (codex not found)\n");
expect(await readFile(join(home, ".agents/skills/simlock/SKILL.md"), "utf8")).toBe(
renderSkill(),
);

const other = join(env.home, "other-home");
await mkdir(other);
const projectScope = await env.cli(["setup", "--project", "--tool", "codex", "--no-mcp"], {
cwd: project,
env: { HOME: other, PATH: bin },
});
expect(projectScope.code).toBe(0);
expect(await readFile(join(project, ".agents/skills/simlock/SKILL.md"), "utf8")).toBe(
renderSkill(),
);
expect(await readdir(other)).toEqual([]);
});
});
10 changes: 8 additions & 2 deletions src/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ Does: the `simlock` command surface over `simlock/admin` — parse, render, hold
Entry: `main.ts` (the `simlock` bin) → `index.ts` `runCli(argv)`.
Uses: admin, bus (event key, type), client (types), agent-identity, lease-policy, history (reader), core
(`loadConfig` only), contract, ports, module-registry (`optionalModules` only); instructions
(`commands/instructions.ts` only); mcp, loaded on demand. Never imports: daemon, drivers, gateway, http.
(`commands/instructions.ts` and `commands/setup.ts` for values; `types.ts` for a type; `index.ts` and
`errors.ts` load it on demand); mcp, loaded on demand. Never imports: daemon, drivers, gateway, http.

Everything except `agent-identity` (the requester-id resolver, the one cheap component) is
loaded **on demand**: the CLI's own shared files (`main.ts`, `index.ts`, `tree.ts`,
Expand Down Expand Up @@ -55,7 +56,7 @@ Surface: [cli.mdx](../../website/src/content/docs/docs/reference/cli.mdx) — ev
whenever the chosen role is the gateway (`SIMLOCK_HOME` still the root). When a worker
answers `UNSUPPORTED_IN_WORKER_MODE`, no `--gateway` was given and a gateway is present,
the printed message gets the `--gateway` hint appended. With `--gateway` and no gateway
present every command but `daemon start`, `daemon install`, `daemon uninstall`, `config set`, `mcp` and `instructions` (and any `--help`, except after `adb`/`simctl`)
present every command but `daemon start`, `daemon install`, `daemon uninstall`, `config set`, `mcp`, `instructions` and `setup` (and any `--help`, except after `adb`/`simctl`)
stops with `GATEWAY_NOT_PRESENT`, exit 2, before it connects.
2. **Connect** (`connectDaemonClient`) — always `connectSimlockAdmin`; principal =
`resolveRequesterId(env, <pid>)`. Credential, first found: `--token` →
Expand Down Expand Up @@ -98,6 +99,11 @@ Surface: [cli.mdx](../../website/src/content/docs/docs/reference/cli.mdx) — ev
9. **`simlock mcp`** — imports `src/mcp/main.ts` and runs it, passing the global flag as `{ daemonFlag }`.
10. **`simlock instructions [--json]`** — prints the static rules text from `src/instructions`, or
one JSON object `{ instructions }`; never connects to a daemon.
11. **`simlock setup [--user | --project] [--tool <t>] [--mcp | --no-mcp] [--json]`** — installs the
router skill through `setupAgentTools`, with `environment.homeDirectory`, `workingDirectory`,
`codexHome`, `filesystem` and `runTool` (the composition root reads `homedir()`, `process.cwd()`
and `CODEX_HOME`); never connects to a daemon. Prompts only without `--json`. A refusal is
`SETUP_REFUSED` (exit 2); a failed `mcp add` prints the report, then `SETUP_MCP_FAILED` (exit 1).

## Rules

Expand Down
111 changes: 111 additions & 0 deletions src/cli/commands/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
AGENT_TOOL_IDS,
agentToolName,
SetupMcpFailedError,
setupAgentTools,
type AgentTool,
type SetupReport,
} from "../../instructions/index.js";
import { commandArgs, UsageError } from "../args.js";
import type { CommandContext } from "../tree.js";

const USAGE =
"Usage: simlock setup [--user | --project] [--tool <claude-code|codex>] [--mcp | --no-mcp] [--json]\n";
const TOOLS: readonly string[] = AGENT_TOOL_IDS;
const TOOL_ID_WIDTH = 11;
const SKILL_STATUS_WIDTH = 9;

function renderReport(report: SetupReport): string {
const lines: string[] = [];
for (const { tool, skill, mcp } of report.tools) {
const label = tool.padEnd(TOOL_ID_WIDTH);
const skipped =
skill.status === "skipped"
? ` (${agentToolName(tool)} not found here; pass --tool ${tool} to install anyway)`
: "";
lines.push(
`${label} skill ${skill.status.padEnd(SKILL_STATUS_WIDTH)} ${skill.path}${skipped}`,
);
if (mcp !== undefined) {
const reason = mcp.reason === undefined ? "" : ` (${mcp.reason})`;
lines.push(`${label} mcp ${mcp.status}${reason}`);
}
}
return `${lines.join("\n")}\n`;
}

interface SetupFlags {
readonly help: boolean;
readonly json: boolean;
readonly scope: "user" | "project" | undefined;
readonly mcp: boolean | undefined;
readonly tool: AgentTool | undefined;
}

function exclusive(first: unknown, second: unknown, message: string): void {
if (first === true && second === true) throw new UsageError(message);
}

function parseTool(tools: readonly string[]): AgentTool | undefined {
if (tools.length > 1) throw new UsageError("--tool can be given once");
const tool = tools[0];
if (tool !== undefined && !TOOLS.includes(tool)) {
throw new UsageError(`--tool takes ${TOOLS.join(" or ")}`);
}
return tool as AgentTool | undefined;
}

function parseSetupFlags(argv: readonly string[]): SetupFlags {
const values = commandArgs(argv, {
help: { type: "boolean", short: "h" },
json: { type: "boolean" },
user: { type: "boolean" },
project: { type: "boolean" },
tool: { type: "string", multiple: true },
mcp: { type: "boolean" },
"no-mcp": { type: "boolean" },
});
if (values.positionals.length > 0) throw new UsageError("setup accepts no arguments");
exclusive(values.user, values.project, "--user and --project cannot be used together");
exclusive(values.mcp, values["no-mcp"], "--mcp and --no-mcp cannot be used together");
return {
help: values.help === true,
json: values.json === true,
scope: values.project ? "project" : values.user ? "user" : undefined,
mcp: values.mcp ? true : values["no-mcp"] ? false : undefined,
tool: parseTool((values.tool ?? []) as string[]),
};
}

async function run(argv: readonly string[], { environment }: CommandContext): Promise<number> {
const flags = parseSetupFlags(argv);
if (flags.help) {
environment.stdout.write(USAGE);
return 0;
}
const print = (report: SetupReport): void => {
environment.stdout.write(flags.json ? `${JSON.stringify(report)}\n` : renderReport(report));
};
try {
print(
await setupAgentTools({
filesystem: environment.filesystem,
homeDirectory: environment.homeDirectory,
workingDirectory: environment.workingDirectory,
codexHome: environment.codexHome,
scope: flags.scope,
mcp: flags.mcp,
tool: flags.tool,
runTool: environment.runTool,
confirm: environment.confirm ?? (async () => false),
interactive: !flags.json,
}),
);
} catch (error: unknown) {
if (error instanceof SetupMcpFailedError) print(error.report);
throw error;
}
return 0;
}

export { run };
5 changes: 5 additions & 0 deletions src/cli/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,11 @@ export function buildCliEnvironment(
},
}),
requesterId,
homeDirectory: ports.homeDirectory,
workingDirectory: ports.workingDirectory,
codexHome: ports.codexHome,
runTool: ports.runTool,
filesystem,
connectAdmin: (resolveCredential) => autoLaunch.connect(resolveCredential),
connectExistingAdmin: (resolveCredential) => connect(ipc, resolveCredential),
daemonEndpointPresent: async () => {
Expand Down
10 changes: 10 additions & 0 deletions src/cli/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ async function isServiceFailed(error: unknown): Promise<boolean> {
return error instanceof ServiceFailedError;
}

/** why: same lazy check as above; the `instructions` module loads only when `setup` ran. */
async function isSetupError(error: unknown, name: string): Promise<boolean> {
if (!(error instanceof Error) || error.name !== name) return false;
const { SetupMcpFailedError, SetupRefusedError } = await import("../instructions/index.js");
return error instanceof (name === "SetupRefusedError" ? SetupRefusedError : SetupMcpFailedError);
}

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
Expand All @@ -39,6 +46,8 @@ async function cliErrorCode(error: unknown): Promise<string> {
if (error instanceof UsageError) return "USAGE";
if (await isSocketPathTooLong(error)) return "USAGE";
if (await isServiceFailed(error)) return "SERVICE_FAILED";
if (await isSetupError(error, "SetupRefusedError")) return "SETUP_REFUSED";
if (await isSetupError(error, "SetupMcpFailedError")) return "SETUP_MCP_FAILED";
const { isSimlockError } = await import("../contract/index.js");
if (isSimlockError(error)) return error.code;
return "INTERNAL";
Expand All @@ -47,6 +56,7 @@ async function cliErrorCode(error: unknown): Promise<string> {
export async function errorExitCode(error: unknown): Promise<number> {
if (error instanceof UsageError) return 2;
if (await isSocketPathTooLong(error)) return 2;
if (await isSetupError(error, "SetupRefusedError")) return 2;
const { ERROR_TABLE, isSimlockError } = await import("../contract/index.js");
if (isSimlockError(error)) return ERROR_TABLE[error.code].cliExitCode;
return 1;
Expand Down
Loading
Loading