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.
- package/README.md +75 -36
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +44 -38
- package/plugin/skills/catherd-setup/SKILL.md +22 -6
- package/src/adapters/access.ts +7 -1
- package/src/adapters/cli.ts +1 -64
- package/src/cli.ts +48 -19
- package/src/domain/host.ts +17 -0
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +32 -5
- package/src/domain/record.ts +1 -0
- package/src/entry/catalog-command.ts +4 -2
- package/src/entry/deps.ts +6 -3
- package/src/entry/doctor-command.ts +11 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +18 -7
- package/src/entry/lock-command.ts +7 -2
- package/src/entry/mcp/handshake.ts +3 -1
- package/src/entry/mcp/run-tools.ts +5 -2
- package/src/entry/mcp/server.ts +129 -40
- package/src/entry/mcp/setup-tools.ts +7 -2
- package/src/entry/profile-command.ts +108 -12
- package/src/entry/runs-command.ts +81 -5
- package/src/entry/tui/effects.ts +19 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +19 -6
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +2 -2
- package/src/entry/tui/views/save-dialog.tsx +2 -2
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +83 -0
- package/src/infra/dispatch-dir.ts +1 -0
- package/src/infra/env.ts +4 -1
- package/src/infra/host-context.ts +58 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +7 -3
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +6 -1
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +105 -33
- package/src/services/finalize.ts +1 -1
- package/src/services/lane-service.ts +1 -0
- package/src/services/notifier.ts +322 -70
- package/src/services/peek.ts +46 -9
- package/src/services/ports.ts +8 -2
- package/src/services/profile-service.ts +116 -30
- package/src/services/profile-store.ts +23 -6
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +2 -0
- package/src/services/run-store.ts +10 -4
- package/src/services/runs-page.ts +3 -2
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +32 -16
- package/src/services/setup.ts +31 -10
- package/src/services/summary.ts +40 -5
- 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 {
|
|
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
|
-
|
|
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> =>
|
|
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
|
-
|
|
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
|
|
package/src/entry/mcp/server.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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,
|
|
56
|
-
registerLaneTools(server,
|
|
57
|
-
registerDispatchTools(server,
|
|
58
|
-
registerSetupTools(server,
|
|
59
|
-
registerProtocolTools(server,
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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) =>
|
|
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
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
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
|
-
|
|
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
|
});
|