catherd-cli 0.2.1 → 1.0.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 +114 -30
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +227 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +316 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +58 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -26
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +33 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +133 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +196 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ID_PATTERN } from "../../domain/ids.ts";
|
|
4
|
+
import { ROLES } from "../../domain/roles.ts";
|
|
5
|
+
import { redact } from "../../infra/log.ts";
|
|
6
|
+
import { registerSavedSecrets } from "../../services/jev-service.ts";
|
|
7
|
+
import type { Deps } from "../../services/ports.ts";
|
|
8
|
+
import {
|
|
9
|
+
readKnowledge,
|
|
10
|
+
readRunFile,
|
|
11
|
+
recordAgentRun,
|
|
12
|
+
result,
|
|
13
|
+
setNext,
|
|
14
|
+
startRun,
|
|
15
|
+
writeRunFile,
|
|
16
|
+
} from "../../services/run-service.ts";
|
|
17
|
+
import { runsSummary, status } from "../../services/summary.ts";
|
|
18
|
+
import { handle } from "./result.ts";
|
|
19
|
+
|
|
20
|
+
export function registerRunTools(server: McpServer, deps: Deps): void {
|
|
21
|
+
server.registerTool(
|
|
22
|
+
"run_start",
|
|
23
|
+
{
|
|
24
|
+
description:
|
|
25
|
+
"Start a catherd run for a git repository: creates its run folder (outside the repo) and state.md. Returns the run id and the folder, and hints when state.md could not be written yet.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
repo: z.string().min(1),
|
|
28
|
+
title: z.string().min(1),
|
|
29
|
+
a_lines: z.array(z.string().min(1)).min(1),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
(a) => handle(() => startRun(deps, { repo: a.repo, title: a.title, aLines: a.a_lines })),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
server.registerTool(
|
|
36
|
+
"write_run_file",
|
|
37
|
+
{
|
|
38
|
+
description:
|
|
39
|
+
"Write a file in the run folder: plan.md, lanes/Mx.Ly.md, a dossier or notes. The path is relative to the run folder; catherd's own files are refused.",
|
|
40
|
+
inputSchema: { run: z.string(), path: z.string().min(1), content: z.string() },
|
|
41
|
+
},
|
|
42
|
+
(a) => handle(() => writeRunFile(a)),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
server.registerTool(
|
|
46
|
+
"read_run_file",
|
|
47
|
+
{
|
|
48
|
+
description: "Read a file in the run folder, by a path relative to it.",
|
|
49
|
+
inputSchema: { run: z.string(), path: z.string().min(1) },
|
|
50
|
+
},
|
|
51
|
+
(a) => handle(() => readRunFile(a)),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
server.registerTool(
|
|
55
|
+
"status",
|
|
56
|
+
{
|
|
57
|
+
description:
|
|
58
|
+
"catherd's version, and one screen per run: the state.md tail, live roles, totals, reported Claude subagents, Jev fallbacks, per backend how many dispatches ran native and isolated, budget and landed milestones. Without a run: every run with live roles, else the newest. Reads only.",
|
|
59
|
+
inputSchema: { run: z.string().optional() },
|
|
60
|
+
},
|
|
61
|
+
(a) =>
|
|
62
|
+
handle(() => {
|
|
63
|
+
registerSavedSecrets();
|
|
64
|
+
return redact(status(deps, a.run));
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
server.registerTool(
|
|
69
|
+
"result",
|
|
70
|
+
{
|
|
71
|
+
description: "A role's latest dispatch: its state, its record once finished, and its capped reply.",
|
|
72
|
+
inputSchema: { run: z.string(), name: z.string().regex(ID_PATTERN) },
|
|
73
|
+
},
|
|
74
|
+
(a) => handle(() => result(deps, a)),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
server.registerTool(
|
|
78
|
+
"set_next",
|
|
79
|
+
{
|
|
80
|
+
description:
|
|
81
|
+
"Record the run's next step, the last line of state.md: when pausing, or when the plan changes. dispatch, climb and land write state.md themselves. Returns { state } (state.md's text), and hints ['state.md not refreshed: …'] with state null when git fails (the step is still recorded).",
|
|
82
|
+
inputSchema: { run: z.string(), next: z.string().min(1) },
|
|
83
|
+
},
|
|
84
|
+
(a) => handle(() => setNext(a)),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
server.registerTool(
|
|
88
|
+
"record_agent_run",
|
|
89
|
+
{
|
|
90
|
+
description:
|
|
91
|
+
"After every native Claude subagent (Agent tool) of a run, record what the Agent result reported: total_tokens and duration_ms. The budget counts it. Pass lane (e.g. M1.L1) when the subagent worked a lane, so its time counts toward that lane's kind in the catalog timings.",
|
|
92
|
+
inputSchema: {
|
|
93
|
+
run: z.string(),
|
|
94
|
+
name: z.string().regex(ID_PATTERN),
|
|
95
|
+
role: z.enum(ROLES),
|
|
96
|
+
rung: z.string().min(3),
|
|
97
|
+
total_tokens: z.number().int().nonnegative(),
|
|
98
|
+
duration_ms: z.number().nonnegative().optional(),
|
|
99
|
+
cost_usd: z.number().nonnegative().optional(),
|
|
100
|
+
status: z.enum(["ok", "failed", "cancelled"]).default("ok"),
|
|
101
|
+
lane: z.string().regex(ID_PATTERN).optional(),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
(a) =>
|
|
105
|
+
handle(() =>
|
|
106
|
+
recordAgentRun(deps, {
|
|
107
|
+
lane: a.lane,
|
|
108
|
+
run: a.run,
|
|
109
|
+
name: a.name,
|
|
110
|
+
role: a.role,
|
|
111
|
+
rung: a.rung,
|
|
112
|
+
totalTokens: a.total_tokens,
|
|
113
|
+
durationMs: a.duration_ms,
|
|
114
|
+
costUsd: a.cost_usd,
|
|
115
|
+
status: a.status,
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
server.registerTool(
|
|
121
|
+
"read_knowledge",
|
|
122
|
+
{
|
|
123
|
+
description:
|
|
124
|
+
"What past runs of this repo learned, one line per landed milestone that said something. Read it before an architect plans a new run.",
|
|
125
|
+
inputSchema: { repo: z.string().min(1) },
|
|
126
|
+
},
|
|
127
|
+
(a) => handle(() => readKnowledge(a.repo)),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
server.registerTool(
|
|
131
|
+
"runs_summary",
|
|
132
|
+
{
|
|
133
|
+
description:
|
|
134
|
+
"Per role and rung over past runs: runs, ok, refusals, climbs from that rung, seconds and tokens; reported Claude subagent runs; and what the user's harness customizations cost per run (median first-turn input, native against isolated; with a run, over that run's repo; null below 3 runs a side or when native is not dearer; none for Codex, which reports no per-request input). Reads only.",
|
|
135
|
+
inputSchema: {
|
|
136
|
+
run: z.string().optional(),
|
|
137
|
+
repo: z.string().optional(),
|
|
138
|
+
role: z.enum(ROLES).optional(),
|
|
139
|
+
since_days: z.number().positive().optional(),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
(a) => handle(() => runsSummary({ run: a.run, repo: a.repo, role: a.role, sinceDays: a.since_days })),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { errorMessage } from "../../domain/errors.ts";
|
|
5
|
+
import { log } from "../../infra/log.ts";
|
|
6
|
+
import type { Deps } from "../../services/ports.ts";
|
|
7
|
+
import { reconcileAll } from "../../services/reconcile.ts";
|
|
8
|
+
import { defaultDeps } from "../deps.ts";
|
|
9
|
+
import { registerDispatchTools } from "./dispatch-tools.ts";
|
|
10
|
+
import { registerLaneTools } from "./lane-tools.ts";
|
|
11
|
+
import { sdkToolError, toolOf } from "./result.ts";
|
|
12
|
+
import { registerRunTools } from "./run-tools.ts";
|
|
13
|
+
import { registerSetupTools } from "./setup-tools.ts";
|
|
14
|
+
|
|
15
|
+
type Handler = (...args: unknown[]) => CallToolResult | Promise<CallToolResult>;
|
|
16
|
+
|
|
17
|
+
/** Spec §10.2: every tool call is logged with its duration and outcome (the error code), never its input. */
|
|
18
|
+
function logToolCalls(server: McpServer): void {
|
|
19
|
+
const register = server.registerTool.bind(server) as unknown as (
|
|
20
|
+
n: string,
|
|
21
|
+
c: unknown,
|
|
22
|
+
h: Handler,
|
|
23
|
+
) => unknown;
|
|
24
|
+
(server as unknown as { registerTool: typeof register }).registerTool = (name, config, handler) =>
|
|
25
|
+
register(name, config, async (...args: unknown[]) => {
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
const r = await handler(...args);
|
|
28
|
+
const code = r.isError ? (r.structuredContent as { code?: string } | undefined)?.code : undefined;
|
|
29
|
+
log(r.isError ? "warn" : "info", "tool", {
|
|
30
|
+
tool: name,
|
|
31
|
+
ms: Date.now() - started,
|
|
32
|
+
ok: !r.isError,
|
|
33
|
+
code,
|
|
34
|
+
});
|
|
35
|
+
return r;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildServer(deps: Deps = defaultDeps()): McpServer {
|
|
40
|
+
const server = new McpServer({ name: "catherd", version: deps.version });
|
|
41
|
+
logToolCalls(server);
|
|
42
|
+
// The SDK validates input before a tool's `handle` runs and reports a failure through this (private)
|
|
43
|
+
// method as plain text; test/entry/mcp.test.ts fails loudly if an SDK upgrade renames it. The wrapper
|
|
44
|
+
// above never sees such a call, so it is logged here (spec §10.2: every call), by the tool it names.
|
|
45
|
+
(server as unknown as { createToolError: typeof sdkToolError }).createToolError = (message) => {
|
|
46
|
+
const r = sdkToolError(message);
|
|
47
|
+
const code = (r.structuredContent as { code?: string } | undefined)?.code;
|
|
48
|
+
log("warn", "tool", { tool: toolOf(message), ok: false, code });
|
|
49
|
+
return r;
|
|
50
|
+
};
|
|
51
|
+
registerRunTools(server, deps);
|
|
52
|
+
registerLaneTools(server, deps);
|
|
53
|
+
registerDispatchTools(server, deps);
|
|
54
|
+
registerSetupTools(server, deps);
|
|
55
|
+
return server;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Spec §4.7: connect first, so the client never waits on a scan; then reconcile every run. */
|
|
59
|
+
export async function startMcpServer(): Promise<void> {
|
|
60
|
+
const deps = defaultDeps();
|
|
61
|
+
await buildServer(deps).connect(new StdioServerTransport());
|
|
62
|
+
try {
|
|
63
|
+
const r = await reconcileAll(deps);
|
|
64
|
+
const shown = r.warnings.length;
|
|
65
|
+
for (const w of r.warnings) console.error(`catherd: ${w}`);
|
|
66
|
+
void r.done.then(() => {
|
|
67
|
+
for (const w of r.warnings.slice(shown)) console.error(`catherd: ${w}`);
|
|
68
|
+
});
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error(`catherd: reconcile failed: ${errorMessage(e)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CatherdError } from "../../domain/errors.ts";
|
|
4
|
+
import { PROFILE_NAME, ProfilePatchSchema } from "../../domain/profile.ts";
|
|
5
|
+
import { ROLES } from "../../domain/roles.ts";
|
|
6
|
+
import { gitToplevel } from "../../infra/git.ts";
|
|
7
|
+
import type { Deps } from "../../services/ports.ts";
|
|
8
|
+
import { handle } from "./result.ts";
|
|
9
|
+
|
|
10
|
+
const PROFILE = z.string().regex(PROFILE_NAME).optional();
|
|
11
|
+
const REPO = z.string().min(1).optional();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The git toplevel of `repo` (default: this server's directory, null outside a repository). A `repo`
|
|
15
|
+
* given outside a repository is refused, as run_start does: never the global profile in its stead.
|
|
16
|
+
*/
|
|
17
|
+
const toplevel = async (repo: string | undefined): Promise<string | null> => {
|
|
18
|
+
const top = await gitToplevel(repo ?? process.cwd());
|
|
19
|
+
if (top === null && repo !== undefined)
|
|
20
|
+
throw new CatherdError("E_IO_PATH", `${repo} is not inside a git repository`, {
|
|
21
|
+
fix: "pass the path of the repository, or leave repo out for the active profile",
|
|
22
|
+
});
|
|
23
|
+
return top;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function registerSetupTools(server: McpServer, deps: Deps): void {
|
|
27
|
+
server.registerTool(
|
|
28
|
+
"catalog_query",
|
|
29
|
+
{
|
|
30
|
+
description:
|
|
31
|
+
"Models catherd can place, with capabilities, the roles they can fill, their scored rungs (backend:model#effort), any 'treat like', their cost under the billing of `repo`'s profile, and whether this account's last listing offers them (`listed: false`: it does not); `enabled: false` rungs are unscored. Scored models first. opencode's models are as listed in `repo` (default: this server's directory), as route sees them there.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
repo: z.string().min(1).optional(),
|
|
34
|
+
role: z.enum(ROLES).optional(),
|
|
35
|
+
backend: z.string().optional(),
|
|
36
|
+
text: z.string().optional(),
|
|
37
|
+
scored_only: z.boolean().default(false),
|
|
38
|
+
limit: z.number().int().min(1).max(500).default(50),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
(a) =>
|
|
42
|
+
handle(async () => {
|
|
43
|
+
// outside a git repository: the global listings and the active profile
|
|
44
|
+
const repo = (await gitToplevel(a.repo ?? process.cwd())) ?? undefined;
|
|
45
|
+
return deps.routing.catalog(
|
|
46
|
+
{ role: a.role, backend: a.backend, text: a.text, scoredOnly: a.scored_only, limit: a.limit, repo },
|
|
47
|
+
deps.profiles.forRepo(repo ?? null).billing,
|
|
48
|
+
);
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
server.registerTool(
|
|
53
|
+
"profile_get",
|
|
54
|
+
{
|
|
55
|
+
description:
|
|
56
|
+
"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.",
|
|
57
|
+
inputSchema: { name: PROFILE, repo: REPO },
|
|
58
|
+
},
|
|
59
|
+
(a) => handle(async () => deps.profiles.get(a.name, await toplevel(a.repo))),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
server.registerTool(
|
|
63
|
+
"profile_validate",
|
|
64
|
+
{
|
|
65
|
+
description:
|
|
66
|
+
"Check a profile (without a name: the profile this repo runs on, as in profile_get). errors block a save: the worker disabled, an enabled role with no usable rung, an unscored rung without a 'treat like', a failover stand-in unscored or on the same quota, a backend catherd cannot run. warnings do not: an access mode other than the role's default, a model the backend's listing lacks, a stand-in that never runs. Each has a path, a message and often a fix.",
|
|
67
|
+
inputSchema: { name: PROFILE, repo: REPO },
|
|
68
|
+
},
|
|
69
|
+
(a) => handle(async () => deps.profiles.validate(a.name, await toplevel(a.repo))),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
server.registerTool(
|
|
73
|
+
"profile_set",
|
|
74
|
+
{
|
|
75
|
+
description:
|
|
76
|
+
"Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, rungs, defaultRung), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid; otherwise it saves, rewrites the agent files and relinks them. Returns the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
|
|
77
|
+
inputSchema: { name: PROFILE, repo: REPO, patch: ProfilePatchSchema },
|
|
78
|
+
},
|
|
79
|
+
(a) => handle(async () => deps.profiles.set(a.name, a.patch, await toplevel(a.repo))),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { assertProfileName } from "../domain/profile.ts";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import type { Catalog } from "../domain/catalog.ts";
|
|
4
|
+
import { rungInfo } from "../domain/catalog.ts";
|
|
5
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
6
|
+
import { type Change, patchAt, type Profile } from "../domain/profile.ts";
|
|
7
|
+
import { inferredScores, type Issue } from "../domain/profile-rules.ts";
|
|
8
|
+
import { ROLES, type Role } from "../domain/roles.ts";
|
|
9
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
10
|
+
import { loadCatalog } from "../services/catalog-service.ts";
|
|
11
|
+
import type { Synced } from "../services/agent-links.ts";
|
|
12
|
+
import {
|
|
13
|
+
activate,
|
|
14
|
+
createProfile,
|
|
15
|
+
deleteProfile,
|
|
16
|
+
diffNamed,
|
|
17
|
+
patchProfile,
|
|
18
|
+
unbind,
|
|
19
|
+
} from "../services/profile-service.ts";
|
|
20
|
+
import {
|
|
21
|
+
activeName,
|
|
22
|
+
getProfile,
|
|
23
|
+
keyRunnable,
|
|
24
|
+
listProfiles,
|
|
25
|
+
profileExists,
|
|
26
|
+
readProjects,
|
|
27
|
+
requireProfile,
|
|
28
|
+
roleEnforcement,
|
|
29
|
+
runnableBackends,
|
|
30
|
+
validateNamed,
|
|
31
|
+
} from "../services/profile-store.ts";
|
|
32
|
+
import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
|
|
33
|
+
|
|
34
|
+
const json = JSON_ARG;
|
|
35
|
+
|
|
36
|
+
export interface StandIn {
|
|
37
|
+
from: string;
|
|
38
|
+
to: string;
|
|
39
|
+
inferred: boolean;
|
|
40
|
+
via: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Spec §7.2: each stand-in, marked inferred when its scores are catherd's guess. */
|
|
44
|
+
export function standIns(p: Profile, c: Catalog): StandIn[] {
|
|
45
|
+
return Object.entries(p.failover).map(([from, to]) => {
|
|
46
|
+
try {
|
|
47
|
+
return { from, to, ...inferredScores(c, rungInfo(c, to)) };
|
|
48
|
+
} catch {
|
|
49
|
+
return { from, to, inferred: false, via: null };
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const value = (v: unknown) => (v === null ? "none" : typeof v === "string" ? v : JSON.stringify(v));
|
|
55
|
+
export const formatChange = (c: Change): string => `${c.path}: ${value(c.before)} → ${value(c.after)}`;
|
|
56
|
+
export const formatIssue = (i: Issue, glyph: string): string[] => [
|
|
57
|
+
`${glyph} ${i.path}: ${i.message}`,
|
|
58
|
+
...(i.fix ? [` fix: ${i.fix}`] : []),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** `profile show`: every setting, each role with its access and how strongly its backend holds it (spec D10). */
|
|
62
|
+
export function formatProfile(
|
|
63
|
+
p: Profile,
|
|
64
|
+
o: {
|
|
65
|
+
active: boolean;
|
|
66
|
+
enforcement: Record<Role, "enforced" | "advisory">;
|
|
67
|
+
standIns: StandIn[];
|
|
68
|
+
/** the backends catherd can run: runnableBackends() */
|
|
69
|
+
backends: string[];
|
|
70
|
+
},
|
|
71
|
+
): string[] {
|
|
72
|
+
const width = Math.max(...ROLES.map((r) => r.length));
|
|
73
|
+
const lines = [
|
|
74
|
+
`profile ${p.name}${o.active ? " (active)" : ""}`,
|
|
75
|
+
`objective ${p.objective} · jev ${p.jev.use} · heavy slots ${p.lock.heavy} · notify ${p.notify.join(", ") || "none"}`,
|
|
76
|
+
"roles",
|
|
77
|
+
];
|
|
78
|
+
for (const role of ROLES) {
|
|
79
|
+
const rc = p.roles[role];
|
|
80
|
+
if (!rc.enabled) {
|
|
81
|
+
lines.push(` ${role.padEnd(width)} off`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const ladder = rc.rungs.map((r) => (r === rc.defaultRung ? `${r} (default)` : r)).join(" → ");
|
|
85
|
+
lines.push(
|
|
86
|
+
` ${role.padEnd(width)} ${`${rc.access}, ${o.enforcement[role]}`.padEnd(27)} ${ladder || "no rungs"}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
// only what catherd can run today: a backend without an adapter has nothing to bill or isolate
|
|
90
|
+
const runs = ([key]: [string, unknown]) => keyRunnable(key, o.backends);
|
|
91
|
+
const billing = Object.entries(p.billing).filter(runs);
|
|
92
|
+
lines.push(`billing ${billing.map(([k, m]) => `${k} ${m}`).join(" · ")}`);
|
|
93
|
+
const harness = Object.entries(p.harness).filter(runs);
|
|
94
|
+
lines.push(`harness ${harness.map(([k, h]) => `${k} ${h.isolated ? "isolated" : "native"}`).join(" · ")}`);
|
|
95
|
+
lines.push(o.standIns.length ? "failover" : "failover none");
|
|
96
|
+
for (const s of o.standIns)
|
|
97
|
+
lines.push(
|
|
98
|
+
` ${s.from} → ${s.to}${s.inferred ? ` (inferred${s.via ? `: treated like ${s.via}` : ""})` : ""}`,
|
|
99
|
+
);
|
|
100
|
+
const budget = Object.entries(p.budget).map(([k, v]) => (k === "usd" ? `$${v}` : `${v} ${k}`));
|
|
101
|
+
lines.push(`budget ${budget.join(" · ") || "no cap"}`);
|
|
102
|
+
lines.push(`timeouts idle ${p.timeouts.idleMin} min · wall ${p.timeouts.wallMin} min`);
|
|
103
|
+
lines.push(
|
|
104
|
+
`preflight ${p.preflight.confirm ? "shows its commands and asks first" : "runs the checks without asking"}`,
|
|
105
|
+
);
|
|
106
|
+
return lines;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function printSynced(r: Pick<Synced, "newSessionNeededFor">): void {
|
|
110
|
+
if (r.newSessionNeededFor.length)
|
|
111
|
+
console.log(`new Claude Code session needed for: ${r.newSessionNeededFor.join(", ")}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function printIssues(errors: Issue[], warnings: Issue[]): void {
|
|
115
|
+
for (const e of errors) for (const l of formatIssue(e, mark("fail"))) console.log(l);
|
|
116
|
+
for (const w of warnings) for (const l of formatIssue(w, mark("warn"))) console.log(l);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const refused = (errors: Issue[]) =>
|
|
120
|
+
new CatherdError(
|
|
121
|
+
"E_CONFIG_INVALID",
|
|
122
|
+
`the profile was not saved: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`,
|
|
123
|
+
{ fix: errors.find((e) => e.fix)?.fix ?? "catherd profile validate lists what to change" },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
async function repoHere(): Promise<string> {
|
|
127
|
+
const top = await gitToplevel(process.cwd());
|
|
128
|
+
if (!top)
|
|
129
|
+
throw new CatherdError("E_INPUT_INVALID", `${process.cwd()} is not inside a git repository`, {
|
|
130
|
+
fix: "run it inside the repo to bind, or leave out --repo",
|
|
131
|
+
});
|
|
132
|
+
return top;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The profile this directory runs on: its repo's binding, else the active one (outside a repo too). */
|
|
136
|
+
const activeHere = async (): Promise<string> => activeName(await gitToplevel(process.cwd()));
|
|
137
|
+
|
|
138
|
+
const list = defineCommand({
|
|
139
|
+
meta: {
|
|
140
|
+
name: "list",
|
|
141
|
+
description: "Every profile, the one this repo runs on (*), and the repos bound to each",
|
|
142
|
+
},
|
|
143
|
+
args: json,
|
|
144
|
+
async run({ args }) {
|
|
145
|
+
const active = await activeHere();
|
|
146
|
+
const bindings = Object.entries(readProjects().bindings);
|
|
147
|
+
const rows = listProfiles().map((name) => ({
|
|
148
|
+
name,
|
|
149
|
+
active: name === active,
|
|
150
|
+
repos: bindings.filter(([, p]) => p === name).map(([r]) => r),
|
|
151
|
+
}));
|
|
152
|
+
if (args.json) return printJson(rows);
|
|
153
|
+
for (const r of rows)
|
|
154
|
+
console.log(
|
|
155
|
+
`${r.active ? "*" : " "} ${r.name}${r.repos.length ? ` bound to ${r.repos.join(", ")}` : ""}`,
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const show = defineCommand({
|
|
161
|
+
meta: {
|
|
162
|
+
name: "show",
|
|
163
|
+
description: "A profile with every default filled in (without a name: the one this repo runs on)",
|
|
164
|
+
},
|
|
165
|
+
args: { name: { type: "positional", required: false, description: "profile name" }, ...json },
|
|
166
|
+
async run({ args }) {
|
|
167
|
+
const here = await activeHere();
|
|
168
|
+
const name = args.name === undefined ? here : requireProfile(args.name);
|
|
169
|
+
const p = getProfile(name);
|
|
170
|
+
const o = {
|
|
171
|
+
active: name === here,
|
|
172
|
+
enforcement: roleEnforcement(p),
|
|
173
|
+
standIns: standIns(p, loadCatalog({ timings: false })),
|
|
174
|
+
};
|
|
175
|
+
if (args.json) return printJson({ profile: p, ...o });
|
|
176
|
+
for (const l of formatProfile(p, { ...o, backends: runnableBackends() })) console.log(l);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const use = defineCommand({
|
|
181
|
+
meta: {
|
|
182
|
+
name: "use",
|
|
183
|
+
description:
|
|
184
|
+
"Make a profile active, bind it to this repo with --repo, or unbind this repo with --repo --clear",
|
|
185
|
+
},
|
|
186
|
+
args: {
|
|
187
|
+
name: { type: "positional", required: false, description: "profile name" },
|
|
188
|
+
repo: { type: "boolean", description: "bind it to the git repo you are in, instead of making it active" },
|
|
189
|
+
clear: { type: "boolean", description: "with --repo and no name: unbind the git repo you are in" },
|
|
190
|
+
},
|
|
191
|
+
async run({ args }) {
|
|
192
|
+
if (args.clear) {
|
|
193
|
+
if (!args.repo || args.name !== undefined)
|
|
194
|
+
throw new CatherdError("E_INPUT_INVALID", "--clear goes with --repo and no profile name", {
|
|
195
|
+
fix: "catherd profile use --repo --clear, inside the repo to unbind",
|
|
196
|
+
});
|
|
197
|
+
const r = unbind(await repoHere());
|
|
198
|
+
console.log(`${mark("ok")} ${r.repo} is unbound`);
|
|
199
|
+
printSynced(r);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (args.name === undefined)
|
|
203
|
+
throw new CatherdError("E_INPUT_INVALID", "which profile? name one", {
|
|
204
|
+
fix: "catherd profile use <name> [--repo], or catherd profile use --repo --clear",
|
|
205
|
+
});
|
|
206
|
+
const r = activate(args.name, args.repo ? await repoHere() : null);
|
|
207
|
+
console.log(`${mark("ok")} ${r.active} ${r.repo ? `is bound to ${r.repo}` : "is active"}`);
|
|
208
|
+
printSynced(r);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const create = defineCommand({
|
|
213
|
+
meta: { name: "new", description: "A new profile from the default one, or from --from <profile>" },
|
|
214
|
+
args: {
|
|
215
|
+
name: { type: "positional", required: true, description: "new profile name" },
|
|
216
|
+
from: { type: "string", description: "copy this profile instead of the default one" },
|
|
217
|
+
},
|
|
218
|
+
run({ args }) {
|
|
219
|
+
const r = createProfile(args.name, args.from);
|
|
220
|
+
if (!r.saved) throw refused(r.errors);
|
|
221
|
+
console.log(`${mark("ok")} created ${args.name}${args.from ? ` from ${args.from}` : ""}`);
|
|
222
|
+
printIssues([], r.warnings);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const copy = defineCommand({
|
|
227
|
+
meta: { name: "copy", description: "Copy a profile under a new name" },
|
|
228
|
+
args: {
|
|
229
|
+
from: { type: "positional", required: true, description: "profile to copy" },
|
|
230
|
+
to: { type: "positional", required: true, description: "new profile name" },
|
|
231
|
+
},
|
|
232
|
+
run({ args }) {
|
|
233
|
+
const r = createProfile(args.to, args.from);
|
|
234
|
+
if (!r.saved) throw refused(r.errors);
|
|
235
|
+
console.log(`${mark("ok")} copied ${args.from} to ${args.to}`);
|
|
236
|
+
printIssues([], r.warnings);
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const rm = defineCommand({
|
|
241
|
+
meta: { name: "rm", description: "Delete a profile and its agent files (never the active or a bound one)" },
|
|
242
|
+
args: { name: { type: "positional", required: true, description: "profile name" } },
|
|
243
|
+
run({ args }) {
|
|
244
|
+
deleteProfile(args.name);
|
|
245
|
+
console.log(`${mark("ok")} deleted ${args.name}`);
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const set = defineCommand({
|
|
250
|
+
meta: {
|
|
251
|
+
name: "set",
|
|
252
|
+
description:
|
|
253
|
+
"Set one field: e.g. roles.worker.access read-only, budget.usd 20, failover.<rung> <rung>, roles.worker.rungs a,b; null removes",
|
|
254
|
+
},
|
|
255
|
+
args: {
|
|
256
|
+
path: { type: "positional", required: true, description: "dotted path, e.g. roles.verifier.access" },
|
|
257
|
+
value: { type: "positional", required: true, description: "JSON, or a plain word; null removes the key" },
|
|
258
|
+
profile: { type: "string", description: "the profile to change (default: the one this repo runs on)" },
|
|
259
|
+
},
|
|
260
|
+
async run({ args }) {
|
|
261
|
+
// the CLI never creates a profile by setting a field of it: a typo would start a new one
|
|
262
|
+
if (args.profile !== undefined && !profileExists(assertProfileName(args.profile)))
|
|
263
|
+
throw new CatherdError("E_INPUT_INVALID", `no profile named "${args.profile}"`, {
|
|
264
|
+
fix: `catherd profile new ${args.profile}`,
|
|
265
|
+
});
|
|
266
|
+
const r = patchProfile(args.profile ?? (await activeHere()), patchAt(args.path, args.value));
|
|
267
|
+
if (!r.saved) throw refused(r.errors);
|
|
268
|
+
if (r.diff.length === 0) console.log("no change");
|
|
269
|
+
for (const c of r.diff) console.log(`${mark("ok")} ${formatChange(c)}`);
|
|
270
|
+
printIssues([], r.warnings);
|
|
271
|
+
printSynced(r);
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const diff = defineCommand({
|
|
276
|
+
meta: {
|
|
277
|
+
name: "diff",
|
|
278
|
+
description: "What differs between two profiles (the second defaults to the one this repo runs on)",
|
|
279
|
+
},
|
|
280
|
+
args: {
|
|
281
|
+
a: { type: "positional", required: true, description: "profile" },
|
|
282
|
+
b: { type: "positional", required: false, description: "profile (default: the one this repo runs on)" },
|
|
283
|
+
...json,
|
|
284
|
+
},
|
|
285
|
+
async run({ args }) {
|
|
286
|
+
const changes = diffNamed(
|
|
287
|
+
args.b === undefined ? await activeHere() : requireProfile(args.b),
|
|
288
|
+
requireProfile(args.a),
|
|
289
|
+
);
|
|
290
|
+
if (args.json) return printJson(changes);
|
|
291
|
+
if (changes.length === 0) console.log("no differences");
|
|
292
|
+
for (const c of changes) console.log(formatChange(c));
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const validate = defineCommand({
|
|
297
|
+
meta: { name: "validate", description: "Errors that block a save, and warnings that do not" },
|
|
298
|
+
args: {
|
|
299
|
+
name: {
|
|
300
|
+
type: "positional",
|
|
301
|
+
required: false,
|
|
302
|
+
description: "profile name (default: the one this repo runs on)",
|
|
303
|
+
},
|
|
304
|
+
...json,
|
|
305
|
+
},
|
|
306
|
+
async run({ args }) {
|
|
307
|
+
const repo = await gitToplevel(process.cwd());
|
|
308
|
+
const v = validateNamed(args.name === undefined ? activeName(repo) : requireProfile(args.name), repo);
|
|
309
|
+
if (args.json) printJson({ valid: v.errors.length === 0, ...v });
|
|
310
|
+
else if (v.errors.length === 0 && v.warnings.length === 0) console.log(`${mark("ok")} valid`);
|
|
311
|
+
else printIssues(v.errors, v.warnings);
|
|
312
|
+
if (v.errors.length) process.exitCode = EXIT.error;
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
/** Spec §8: `catherd profile list|show|use [--repo [--clear]]|new|copy|rm|set <path> <value>|diff|validate`. */
|
|
317
|
+
export const profileCommand = defineCommand({
|
|
318
|
+
meta: { name: "profile", description: "Profiles: which models each role runs, and how" },
|
|
319
|
+
subCommands: { list, show, use, new: create, copy, rm, set, diff, validate },
|
|
320
|
+
});
|