catherd-cli 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +75 -36
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.codex-plugin/plugin.json +11 -0
  5. package/plugin/.mcp-codex.json +10 -0
  6. package/plugin/bin/catherd-mcp +1 -1
  7. package/plugin/skills/catherd/SKILL.md +44 -38
  8. package/plugin/skills/catherd-setup/SKILL.md +22 -6
  9. package/src/adapters/access.ts +7 -1
  10. package/src/adapters/cli.ts +1 -64
  11. package/src/cli.ts +48 -19
  12. package/src/domain/host.ts +17 -0
  13. package/src/domain/notice.ts +5 -2
  14. package/src/domain/profile-rules.ts +17 -0
  15. package/src/domain/profile.ts +32 -5
  16. package/src/domain/record.ts +1 -0
  17. package/src/entry/catalog-command.ts +4 -2
  18. package/src/entry/deps.ts +6 -3
  19. package/src/entry/doctor-command.ts +11 -2
  20. package/src/entry/host-arg.ts +28 -0
  21. package/src/entry/init-command.ts +18 -7
  22. package/src/entry/lock-command.ts +7 -2
  23. package/src/entry/mcp/handshake.ts +3 -1
  24. package/src/entry/mcp/run-tools.ts +5 -2
  25. package/src/entry/mcp/server.ts +129 -40
  26. package/src/entry/mcp/setup-tools.ts +7 -2
  27. package/src/entry/profile-command.ts +108 -12
  28. package/src/entry/runs-command.ts +81 -5
  29. package/src/entry/tui/effects.ts +19 -8
  30. package/src/entry/tui/fixtures.ts +33 -10
  31. package/src/entry/tui/run.tsx +10 -4
  32. package/src/entry/tui/state.ts +19 -6
  33. package/src/entry/tui/stories.ts +11 -4
  34. package/src/entry/tui/views/profile-actions.ts +4 -2
  35. package/src/entry/tui/views/profiles.tsx +2 -2
  36. package/src/entry/tui/views/save-dialog.tsx +2 -2
  37. package/src/infra/cli.ts +65 -0
  38. package/src/infra/codex-queue.ts +165 -0
  39. package/src/infra/delivery.ts +83 -0
  40. package/src/infra/dispatch-dir.ts +1 -0
  41. package/src/infra/env.ts +4 -1
  42. package/src/infra/host-context.ts +58 -0
  43. package/src/infra/peer-inbox.ts +4 -1
  44. package/src/infra/supervisor.ts +3 -2
  45. package/src/services/admission.ts +7 -3
  46. package/src/services/agent-links.ts +30 -5
  47. package/src/services/backends.ts +7 -0
  48. package/src/services/dispatch-service.ts +17 -7
  49. package/src/services/dispatches.ts +6 -1
  50. package/src/services/doctor-backends.ts +30 -7
  51. package/src/services/doctor-checks.ts +20 -5
  52. package/src/services/doctor-push.ts +69 -90
  53. package/src/services/doctor.ts +105 -33
  54. package/src/services/finalize.ts +1 -1
  55. package/src/services/lane-service.ts +1 -0
  56. package/src/services/notifier.ts +322 -70
  57. package/src/services/peek.ts +46 -9
  58. package/src/services/ports.ts +8 -2
  59. package/src/services/profile-service.ts +116 -30
  60. package/src/services/profile-store.ts +23 -6
  61. package/src/services/routing-service.ts +6 -1
  62. package/src/services/run-debug.ts +98 -4
  63. package/src/services/run-service.ts +2 -0
  64. package/src/services/run-store.ts +10 -4
  65. package/src/services/runs-page.ts +3 -2
  66. package/src/services/session-view.ts +18 -13
  67. package/src/services/sessions.ts +32 -16
  68. package/src/services/setup.ts +31 -10
  69. package/src/services/summary.ts +40 -5
  70. package/src/services/treat-likes.ts +22 -9
@@ -1,10 +1,11 @@
1
+ import { HOST_ARG } from "./host-arg.ts";
1
2
  import { defineCommand } from "citty";
2
3
  import { CatherdError } from "../domain/errors.ts";
3
4
  import { scrubSecrets } from "../infra/env.ts";
4
5
  import { gitToplevel } from "../infra/git.ts";
5
6
  import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
6
7
  import { killGroup } from "../infra/proc.ts";
7
- import { profileFor } from "../services/profile-store.ts";
8
+ import { activeName, readProfileDoc } from "../services/profile-store.ts";
8
9
  import { printError } from "./cli-kit.ts";
9
10
 
10
11
  /**
@@ -71,6 +72,7 @@ export async function runForwarding(argv: string[]): Promise<number> {
71
72
  const watchdog = Bun.spawn(
72
73
  ["sh", "-c", 'read _ || kill -s KILL -- "-$1" 2>/dev/null', "sh", String(child.pid)],
73
74
  {
75
+ env: scrubSecrets(process.env),
74
76
  stdin: "pipe",
75
77
  stdout: "ignore",
76
78
  stderr: "ignore",
@@ -95,6 +97,7 @@ export const lockCommand = defineCommand({
95
97
  "Run a heavy command behind the machine-wide semaphore, in its own process group and session: it gets no /dev/tty and no job control",
96
98
  },
97
99
  args: {
100
+ ...HOST_ARG,
98
101
  slots: {
99
102
  type: "string",
100
103
  description: "Slots (default: CATHERD_LOCK_SLOTS, else the profile's lock.heavy, else half the cores)",
@@ -115,7 +118,9 @@ export const lockCommand = defineCommand({
115
118
  const repo = await gitToplevel(process.cwd());
116
119
  const slots = resolveSlots(
117
120
  args.slots,
118
- () => profileFor(repo).lock.heavy,
121
+ // lock.heavy is host-independent: read it stored, so a worker (whose host identity is scrubbed) and an
122
+ // unknown terminal host still get the profile's value instead of the cpus/2 fallback
123
+ () => readProfileDoc(activeName(repo)).lock?.heavy ?? "cpus/2",
119
124
  (m) => console.error(m),
120
125
  );
121
126
  process.exitCode = await withHeavySlot(slots, () => runForwarding(argv));
@@ -15,7 +15,9 @@ const STDERR_TAIL = 4_000;
15
15
  /** The env the doctor's MCP server starts with: catherd's own secrets scrubbed, as for every process it starts. */
16
16
  export const handshakeEnv = (
17
17
  base: Record<string, string | undefined> = process.env,
18
- ): Record<string, string> => scrubSecrets(base);
18
+ ): Record<string, string> => {
19
+ return scrubSecrets(base);
20
+ };
19
21
 
20
22
  /**
21
23
  * Spec §10.3 and 1.1 §12: starts the MCP server the way the plugin does, `sh <launcher>` over stdio, and
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { ID_PATTERN } from "../../domain/ids.ts";
4
4
  import { ROLES } from "../../domain/roles.ts";
5
+ import { knownQueueCapability } from "../../infra/codex-queue.ts";
5
6
  import { redact } from "../../infra/log.ts";
6
7
  import { registerSavedSecrets } from "../../services/jev-service.ts";
7
8
  import type { Deps } from "../../services/ports.ts";
@@ -59,9 +60,11 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
59
60
  inputSchema: { run: z.string().optional() },
60
61
  },
61
62
  (a) =>
62
- handle(() => {
63
+ handle(async () => {
63
64
  registerSavedSecrets();
64
- return redact(status(deps, a.run));
65
+ const queue =
66
+ deps.host.host === "codex" && !deps.host.conflict ? knownQueueCapability(process.env) : null;
67
+ return redact(status(deps, a.run, queue));
65
68
  }),
66
69
  );
67
70
 
@@ -1,9 +1,13 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
4
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
5
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
6
+ import { sessionKey, type HostContext } from "../../domain/host.ts";
5
7
  import { errorMessage } from "../../domain/errors.ts";
8
+ import { resolveHost } from "../../infra/host-context.ts";
6
9
  import { log } from "../../infra/log.ts";
10
+ import { currentSession } from "../../services/sessions.ts";
7
11
  import { startNotifier } from "../../services/notifier.ts";
8
12
  import type { Deps } from "../../services/ports.ts";
9
13
  import { reconcileAll } from "../../services/reconcile.ts";
@@ -16,10 +20,18 @@ import { sdkToolError, toolOf } from "./result.ts";
16
20
  import { registerRunTools } from "./run-tools.ts";
17
21
  import { registerSetupTools } from "./setup-tools.ts";
18
22
 
23
+ type ObserveSession = (context: Deps) => (() => void) | undefined;
24
+
19
25
  type Handler = (...args: unknown[]) => CallToolResult | Promise<CallToolResult>;
20
26
 
21
27
  /** Spec §10.2: every tool call is logged with its duration and outcome (the error code), never its input. */
22
- function logToolCalls(server: McpServer): void {
28
+ function logToolCalls(
29
+ server: McpServer,
30
+ deps: Deps,
31
+ scope: AsyncLocalStorage<Deps>,
32
+ generation: () => number,
33
+ observe?: ObserveSession,
34
+ ): void {
23
35
  const register = server.registerTool.bind(server) as unknown as (
24
36
  n: string,
25
37
  c: unknown,
@@ -28,7 +40,28 @@ function logToolCalls(server: McpServer): void {
28
40
  (server as unknown as { registerTool: typeof register }).registerTool = (name, config, handler) =>
29
41
  register(name, config, async (...args: unknown[]) => {
30
42
  const started = Date.now();
31
- const r = await handler(...args);
43
+ const epoch = generation();
44
+ const extra = args.at(-1) as { _meta?: Record<string, unknown> } | undefined;
45
+ let host = deps.host;
46
+ if (host.host === "codex" && !host.conflict && extra?._meta && "threadId" in extra._meta) {
47
+ const threadId = extra._meta.threadId;
48
+ host =
49
+ typeof threadId === "string"
50
+ ? resolveHost({
51
+ clientName: "codex-mcp-client",
52
+ env: { CODEX_THREAD_ID: threadId, CODEX_SESSION_ID: host.session?.sessionId },
53
+ })
54
+ : { host: "unknown", session: null, conflict: "Codex _meta.threadId must be a UUID string" };
55
+ }
56
+ const context: Deps = {
57
+ ...deps,
58
+ get host(): HostContext {
59
+ return epoch === generation() ? host : { host: "unknown", session: null, conflict: null };
60
+ },
61
+ };
62
+ const after = observe?.(context);
63
+ const r = await scope.run(context, () => handler(...args));
64
+ after?.();
32
65
  const code = r.isError ? (r.structuredContent as { code?: string } | undefined)?.code : undefined;
33
66
  log(r.isError ? "warn" : "info", "tool", {
34
67
  tool: name,
@@ -40,9 +73,25 @@ function logToolCalls(server: McpServer): void {
40
73
  });
41
74
  }
42
75
 
43
- export function buildServer(deps: Deps = defaultDeps()): McpServer {
76
+ export function buildServer(deps: Deps = defaultDeps(), observe?: ObserveSession): McpServer {
44
77
  const server = new McpServer({ name: "catherd", version: deps.version });
45
- logToolCalls(server);
78
+ const scope = new AsyncLocalStorage<Deps>();
79
+ const scoped: Deps = new Proxy(deps, {
80
+ get: (target, key) => (key === "profiles" ? profiles : Reflect.get(scope.getStore() ?? target, key)),
81
+ });
82
+ const profiles = deps.profiles.withHost?.(() => scoped.host) ?? deps.profiles;
83
+ let generation = 0;
84
+ const invalidate = () => {
85
+ generation++;
86
+ deps.host = { host: "unknown", session: null, conflict: null };
87
+ };
88
+ logToolCalls(server, deps, scope, () => generation, observe);
89
+ deps.host = { host: "unknown", session: null, conflict: null };
90
+ server.server.oninitialized = () => {
91
+ invalidate();
92
+ deps.host = resolveHost({ clientName: server.server.getClientVersion()?.name, env: process.env });
93
+ };
94
+ server.server.onclose = invalidate;
46
95
  // The SDK validates input before a tool's `handle` runs and reports a failure through this (private)
47
96
  // method as plain text; test/entry/mcp.test.ts fails loudly if an SDK upgrade renames it. The wrapper
48
97
  // above never sees such a call, so it is logged here (spec §10.2: every call), by the tool it names.
@@ -52,47 +101,87 @@ export function buildServer(deps: Deps = defaultDeps()): McpServer {
52
101
  log("warn", "tool", { tool: toolOf(message), ok: false, code });
53
102
  return r;
54
103
  };
55
- registerRunTools(server, deps);
56
- registerLaneTools(server, deps);
57
- registerDispatchTools(server, deps);
58
- registerSetupTools(server, deps);
59
- registerProtocolTools(server, deps);
104
+ registerRunTools(server, scoped);
105
+ registerLaneTools(server, scoped);
106
+ registerDispatchTools(server, scoped);
107
+ registerSetupTools(server, scoped);
108
+ registerProtocolTools(server, scoped);
60
109
  return server;
61
110
  }
62
111
 
63
- /**
64
- * Spec §4.7: connect first, so the client never waits on a scan; then reconcile every run. Spec §3.4: the notifier
65
- * starts before reconcile, so what reconcile settles is announced, and scans for the rest once it is done. Spec
66
- * 1.2 §3.2: the source sync starts once connected and is never awaited, so neither the handshake nor a tool call
67
- * waits on the network. Tests pass their own transport and sync.
68
- */
112
+ /** Connect immediately; initialized triggers recovery without delaying the handshake. */
69
113
  export async function startMcpServer(
70
- o: { transport?: Transport; sync?: () => Promise<unknown> } = {},
114
+ o: { transport?: Transport; sync?: () => Promise<unknown>; deps?: Deps } = {},
71
115
  ): Promise<void> {
72
- const deps = defaultDeps();
73
- // which of the session's variables this server got (never their values): the live check of spec §3.9
74
- log("info", "session", {
75
- sessionId: Boolean(deps.session?.sessionId),
76
- hostSessionId: Boolean(deps.session?.hostSessionId),
77
- socket: Boolean(deps.session?.socketPath),
78
- token: Boolean(deps.session?.token),
116
+ const deps = o.deps ?? defaultDeps();
117
+ const observed = new Map<string, ReturnType<typeof startNotifier>>();
118
+ const server = buildServer(deps, (context) => {
119
+ const target = currentSession(context);
120
+ if (!target) return;
121
+ const key = sessionKey(target);
122
+ let active = observed.get(key);
123
+ if (!active) {
124
+ active = startNotifier(context);
125
+ observed.set(key, active);
126
+ }
127
+ const n = active;
128
+ return () => {
129
+ if (observed.get(key) === n) void n.scan();
130
+ };
79
131
  });
80
- const notifier = startNotifier(deps);
81
- await buildServer(deps).connect(o.transport ?? new StdioServerTransport());
82
- const sync = o.sync ?? (() => backgroundSync());
83
- void Promise.resolve()
84
- .then(sync)
85
- .catch((e: unknown) => log("debug", "sources", { error: errorMessage(e) }));
86
- try {
87
- const r = await reconcileAll(deps);
88
- const shown = r.warnings.length;
89
- for (const w of r.warnings) console.error(`catherd: ${w}`);
90
- void r.done.then(() => {
91
- for (const w of r.warnings.slice(shown)) console.error(`catherd: ${w}`);
132
+ const initialized = server.server.oninitialized!;
133
+ const closed = server.server.onclose!;
134
+ let generation = 0;
135
+ let notifier: ReturnType<typeof startNotifier> | undefined;
136
+ let recovery: Deps | undefined;
137
+ const invalidate = () => {
138
+ generation++;
139
+ if (recovery) recovery.host = { host: "unknown", session: null, conflict: null };
140
+ notifier?.stop();
141
+ notifier = undefined;
142
+ for (const n of observed.values()) n.stop();
143
+ observed.clear();
144
+ closed();
145
+ };
146
+ server.server.onclose = invalidate;
147
+ server.server.oninitialized = () => {
148
+ invalidate();
149
+ initialized();
150
+ log("info", "session", {
151
+ host: deps.host.host,
152
+ conflict: deps.host.conflict,
153
+ sessionId: Boolean(deps.session?.sessionId),
154
+ hostSessionId: Boolean(deps.session?.hostSessionId),
155
+ socket: Boolean(deps.session?.socketPath),
156
+ token: Boolean(deps.session?.token),
92
157
  });
93
- } catch (e) {
94
- console.error(`catherd: reconcile failed: ${errorMessage(e)}`);
95
- }
96
- // whatever finished unread while no server ran, or finished under another server (spec §3.4)
97
- void notifier.scan();
158
+ const epoch = generation;
159
+ recovery = { ...deps };
160
+ const context = recovery;
161
+ notifier = startNotifier(context);
162
+ const active = notifier;
163
+ const target = currentSession(context);
164
+ if (target) observed.set(sessionKey(target), active);
165
+ void Promise.resolve()
166
+ .then(() => {
167
+ if (epoch === generation) return (o.sync ?? (() => backgroundSync()))();
168
+ })
169
+ .catch((e: unknown) => log("debug", "sources", { error: errorMessage(e) }));
170
+ void (async () => {
171
+ try {
172
+ if (epoch !== generation) return;
173
+ const r = await reconcileAll(context);
174
+ if (epoch !== generation) return;
175
+ const shown = r.warnings.length;
176
+ for (const w of r.warnings) console.error(`catherd: ${w}`);
177
+ void r.done.then(() => {
178
+ if (epoch === generation) for (const w of r.warnings.slice(shown)) console.error(`catherd: ${w}`);
179
+ });
180
+ if (epoch === generation) void active.scan();
181
+ } catch (e) {
182
+ console.error(`catherd: reconcile failed: ${errorMessage(e)}`);
183
+ }
184
+ })();
185
+ };
186
+ await server.connect(o.transport ?? new StdioServerTransport());
98
187
  }
@@ -76,9 +76,14 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
76
76
  {
77
77
  description:
78
78
  "A profile (without a name: the profile this repo runs on, i.e. the one bound to `repo`, default this server's directory, else the active one) as the run engine reads it, with every default filled in: per role its access mode, rungs and default rung, and whether its backend enforces the access (enforcement); billing, jev, harness isolation, failover, budget, timeouts, preflight, lock and notify. Also `active` (the global active profile), `here` (the profile this repo runs on) and every name.",
79
- inputSchema: { name: PROFILE, repo: REPO },
79
+ inputSchema: { name: PROFILE, repo: REPO, raw: z.boolean().optional() },
80
80
  },
81
- (a) => handle(async () => deps.profiles.get(a.name, await toplevel(a.repo))),
81
+ (a) =>
82
+ handle(async () =>
83
+ a.raw && deps.profiles.raw
84
+ ? deps.profiles.raw(a.name, await toplevel(a.repo))
85
+ : deps.profiles.get(a.name, await toplevel(a.repo)),
86
+ ),
82
87
  );
83
88
 
84
89
  server.registerTool(
@@ -1,9 +1,12 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { HOST_ARG, terminalHost } from "./host-arg.ts";
1
3
  import { assertProfileName } from "../domain/profile.ts";
2
4
  import { defineCommand } from "citty";
3
5
  import type { Catalog } from "../domain/catalog.ts";
4
6
  import { rungInfo } from "../domain/catalog.ts";
5
7
  import { CatherdError } from "../domain/errors.ts";
6
- import { type Change, patchAt, type Profile } from "../domain/profile.ts";
8
+ import type { OrchestrationHost } from "../domain/host.ts";
9
+ import { ProfileDocSchema, type Change, patchAt, type Profile } from "../domain/profile.ts";
7
10
  import { inferredScores, type Issue } from "../domain/profile-rules.ts";
8
11
  import { ROLES, type Role } from "../domain/roles.ts";
9
12
  import { gitToplevel } from "../infra/git.ts";
@@ -15,6 +18,7 @@ import {
15
18
  deleteProfile,
16
19
  diffNamed,
17
20
  patchProfile,
21
+ resetHostDefaults,
18
22
  unbind,
19
23
  } from "../services/profile-service.ts";
20
24
  import {
@@ -24,6 +28,7 @@ import {
24
28
  listProfiles,
25
29
  profileExists,
26
30
  readProjects,
31
+ readProfileDoc,
27
32
  requireProfile,
28
33
  roleEnforcement,
29
34
  runnableBackends,
@@ -139,7 +144,10 @@ const list = defineCommand({
139
144
  name: "list",
140
145
  description: "Every profile, the one this repo runs on (*), and the repos bound to each",
141
146
  },
142
- args: json,
147
+ args: {
148
+ ...HOST_ARG,
149
+ ...json,
150
+ },
143
151
  async run({ args }) {
144
152
  const active = await activeHere();
145
153
  const bindings = Object.entries(readProjects().bindings);
@@ -161,11 +169,20 @@ const show = defineCommand({
161
169
  name: "show",
162
170
  description: "A profile with every default filled in (without a name: the one this repo runs on)",
163
171
  },
164
- args: { name: { type: "positional", required: false, description: "profile name" }, ...json },
172
+ args: {
173
+ ...HOST_ARG,
174
+ name: { type: "positional", required: false, description: "profile name" },
175
+ raw: { type: "boolean", description: "stored document without effective defaults" },
176
+ ...json,
177
+ },
165
178
  async run({ args }) {
166
179
  const here = await activeHere();
167
180
  const name = args.name === undefined ? here : requireProfile(args.name);
168
- const p = getProfile(name);
181
+ if (args.raw)
182
+ return args.json
183
+ ? printJson(readProfileDoc(name))
184
+ : console.log(JSON.stringify(readProfileDoc(name), null, 2));
185
+ const p = getProfile(name, terminalHost(args.host).host);
169
186
  const o = {
170
187
  active: name === here,
171
188
  enforcement: roleEnforcement(p),
@@ -183,6 +200,7 @@ const use = defineCommand({
183
200
  "Make a profile active, bind it to this repo with --repo, or unbind this repo with --repo --clear",
184
201
  },
185
202
  args: {
203
+ ...HOST_ARG,
186
204
  name: { type: "positional", required: false, description: "profile name" },
187
205
  repo: { type: "boolean", description: "bind it to the git repo you are in, instead of making it active" },
188
206
  clear: { type: "boolean", description: "with --repo and no name: unbind the git repo you are in" },
@@ -193,7 +211,7 @@ const use = defineCommand({
193
211
  throw new CatherdError("E_INPUT_INVALID", "--clear goes with --repo and no profile name", {
194
212
  fix: "catherd profile use --repo --clear, inside the repo to unbind",
195
213
  });
196
- const r = unbind(await repoHere());
214
+ const r = unbind(await repoHere(), terminalHost(args.host).host);
197
215
  console.log(`${mark("ok")} ${r.repo} is unbound`);
198
216
  printSynced(r);
199
217
  return;
@@ -202,7 +220,7 @@ const use = defineCommand({
202
220
  throw new CatherdError("E_INPUT_INVALID", "which profile? name one", {
203
221
  fix: "catherd profile use <name> [--repo], or catherd profile use --repo --clear",
204
222
  });
205
- const r = activate(args.name, args.repo ? await repoHere() : null);
223
+ const r = activate(args.name, args.repo ? await repoHere() : null, terminalHost(args.host).host);
206
224
  console.log(`${mark("ok")} ${r.active} ${r.repo ? `is bound to ${r.repo}` : "is active"}`);
207
225
  printSynced(r);
208
226
  },
@@ -211,11 +229,12 @@ const use = defineCommand({
211
229
  const create = defineCommand({
212
230
  meta: { name: "new", description: "A new profile from the default one, or from --from <profile>" },
213
231
  args: {
232
+ ...HOST_ARG,
214
233
  name: { type: "positional", required: true, description: "new profile name" },
215
234
  from: { type: "string", description: "copy this profile instead of the default one" },
216
235
  },
217
236
  run({ args }) {
218
- const r = createProfile(args.name, args.from);
237
+ const r = createProfile(args.name, args.from, terminalHost(args.host).host);
219
238
  if (!r.saved) throw refused(r.errors);
220
239
  console.log(`${mark("ok")} created ${args.name}${args.from ? ` from ${args.from}` : ""}`);
221
240
  printIssues([], r.warnings);
@@ -225,11 +244,12 @@ const create = defineCommand({
225
244
  const copy = defineCommand({
226
245
  meta: { name: "copy", description: "Copy a profile under a new name" },
227
246
  args: {
247
+ ...HOST_ARG,
228
248
  from: { type: "positional", required: true, description: "profile to copy" },
229
249
  to: { type: "positional", required: true, description: "new profile name" },
230
250
  },
231
251
  run({ args }) {
232
- const r = createProfile(args.to, args.from);
252
+ const r = createProfile(args.to, args.from, terminalHost(args.host).host);
233
253
  if (!r.saved) throw refused(r.errors);
234
254
  console.log(`${mark("ok")} copied ${args.from} to ${args.to}`);
235
255
  printIssues([], r.warnings);
@@ -238,7 +258,10 @@ const copy = defineCommand({
238
258
 
239
259
  const rm = defineCommand({
240
260
  meta: { name: "rm", description: "Delete a profile and its agent files (never the active or a bound one)" },
241
- args: { name: { type: "positional", required: true, description: "profile name" } },
261
+ args: {
262
+ ...HOST_ARG,
263
+ name: { type: "positional", required: true, description: "profile name" },
264
+ },
242
265
  run({ args }) {
243
266
  deleteProfile(args.name);
244
267
  console.log(`${mark("ok")} deleted ${args.name}`);
@@ -252,6 +275,7 @@ const set = defineCommand({
252
275
  "Set one field: e.g. roles.worker.access read-only, budget.usd 20, failover.<rung> <rung>, roles.worker.rungs a,b; null removes",
253
276
  },
254
277
  args: {
278
+ ...HOST_ARG,
255
279
  path: { type: "positional", required: true, description: "dotted path, e.g. roles.verifier.access" },
256
280
  value: { type: "positional", required: true, description: "JSON, or a plain word; null removes the key" },
257
281
  profile: { type: "string", description: "the profile to change (default: the one this repo runs on)" },
@@ -262,7 +286,9 @@ const set = defineCommand({
262
286
  throw new CatherdError("E_INPUT_INVALID", `no profile named "${args.profile}"`, {
263
287
  fix: `catherd profile new ${args.profile}`,
264
288
  });
265
- const r = patchProfile(args.profile ?? (await activeHere()), patchAt(args.path, args.value));
289
+ const r = patchProfile(args.profile ?? (await activeHere()), patchAt(args.path, args.value), {
290
+ host: terminalHost(args.host).host,
291
+ });
266
292
  if (!r.saved) throw refused(r.errors);
267
293
  if (r.diff.length === 0) console.log("no change");
268
294
  for (const c of r.diff) console.log(`${mark("ok")} ${formatChange(c)}`);
@@ -282,6 +308,7 @@ const diff = defineCommand({
282
308
  description: "What differs between two profiles (the second defaults to the one this repo runs on)",
283
309
  },
284
310
  args: {
311
+ ...HOST_ARG,
285
312
  a: { type: "positional", required: true, description: "profile" },
286
313
  b: { type: "positional", required: false, description: "profile (default: the one this repo runs on)" },
287
314
  ...json,
@@ -290,6 +317,7 @@ const diff = defineCommand({
290
317
  const changes = diffNamed(
291
318
  args.b === undefined ? await activeHere() : requireProfile(args.b),
292
319
  requireProfile(args.a),
320
+ terminalHost(args.host).host,
293
321
  );
294
322
  if (args.json) return printJson(changes);
295
323
  if (changes.length === 0) console.log("no differences");
@@ -300,6 +328,7 @@ const diff = defineCommand({
300
328
  const validate = defineCommand({
301
329
  meta: { name: "validate", description: "Errors that block a save, and warnings that do not" },
302
330
  args: {
331
+ ...HOST_ARG,
303
332
  name: {
304
333
  type: "positional",
305
334
  required: false,
@@ -309,7 +338,11 @@ const validate = defineCommand({
309
338
  },
310
339
  async run({ args }) {
311
340
  const repo = await gitToplevel(process.cwd());
312
- const v = validateNamed(args.name === undefined ? activeName(repo) : requireProfile(args.name), repo);
341
+ const v = validateNamed(
342
+ args.name === undefined ? activeName(repo) : requireProfile(args.name),
343
+ repo,
344
+ terminalHost(args.host).host,
345
+ );
313
346
  if (args.json) printJson({ valid: v.errors.length === 0, ...v });
314
347
  else if (v.errors.length === 0 && v.warnings.length === 0) console.log(`${mark("ok")} valid`);
315
348
  else printIssues(v.errors, v.warnings);
@@ -317,8 +350,71 @@ const validate = defineCommand({
317
350
  },
318
351
  });
319
352
 
353
+ const HOSTS: readonly OrchestrationHost[] = ["claude-code", "codex", "unknown"];
354
+
355
+ const resetDefaults = defineCommand({
356
+ meta: {
357
+ name: "reset-host-defaults",
358
+ description: "Review and explicitly reset only architect/verifier host defaults",
359
+ },
360
+ args: {
361
+ ...HOST_ARG,
362
+ ...JSON_ARG,
363
+ name: { type: "positional", required: false },
364
+ preview: { type: "boolean" },
365
+ expect: { type: "string", description: "reviewed preview JSON file" },
366
+ },
367
+ async run({ args }) {
368
+ const name = args.name === undefined ? await activeHere() : requireProfile(args.name);
369
+ const host = terminalHost(args.host).host;
370
+ let r;
371
+ if (args.preview) r = resetHostDefaults(name, host, { preview: true });
372
+ else {
373
+ if (!args.expect)
374
+ throw new CatherdError(
375
+ "E_INPUT_INVALID",
376
+ "saving host defaults requires --expect <reviewed-preview.json>",
377
+ { fix: "run reset-host-defaults --preview --json and review the output first" },
378
+ );
379
+ let expected, reviewed;
380
+ try {
381
+ const raw = JSON.parse(readFileSync(args.expect, "utf8")) as Record<string, unknown> | null;
382
+ expected = ProfileDocSchema.parse(raw?.expect);
383
+ const { profile, host: previewed } = raw ?? {};
384
+ if (typeof profile !== "string" || !HOSTS.includes(previewed as OrchestrationHost))
385
+ throw new Error("unbound preview");
386
+ reviewed = { profile, host: previewed as OrchestrationHost };
387
+ } catch {
388
+ throw new CatherdError("E_INPUT_INVALID", "invalid reviewed preview file", {
389
+ fix: "pass the JSON output of reset-host-defaults --preview --json",
390
+ });
391
+ }
392
+ r = resetHostDefaults(name, host, { preview: false, expect: expected, reviewed });
393
+ }
394
+ if (args.json) printJson(r);
395
+ else {
396
+ for (const c of r.diff) console.log(formatChange(c));
397
+ printIssues(r.errors, r.warnings);
398
+ printSynced(r);
399
+ }
400
+ if (r.errors.length) process.exitCode = EXIT.error;
401
+ },
402
+ });
403
+
320
404
  /** Spec §8: `catherd profile list|show|use [--repo [--clear]]|new|copy|rm|set <path> <value>|diff|validate`. */
321
405
  export const profileCommand = defineCommand({
322
406
  meta: { name: "profile", description: "Profiles: which models each role runs, and how" },
323
- subCommands: { list, show, use, new: create, copy, rm, set, diff, validate },
407
+ args: HOST_ARG,
408
+ subCommands: {
409
+ "reset-host-defaults": resetDefaults,
410
+ list,
411
+ show,
412
+ use,
413
+ new: create,
414
+ copy,
415
+ rm,
416
+ set,
417
+ diff,
418
+ validate,
419
+ },
324
420
  });