catherd-cli 0.2.0 → 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.
Files changed (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -25
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -69
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -0,0 +1,316 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CatherdError } from "../../domain/errors.ts";
5
+ import type { Rung } from "../../domain/ids.ts";
6
+ import type { Access, RunStatus, Tokens } from "../../domain/record.ts";
7
+ import {
8
+ type BackendAdapter,
9
+ compareVersions,
10
+ type DiscoveredModel,
11
+ type EventDelta,
12
+ extractVersion,
13
+ type FinishedRun,
14
+ type Outcome,
15
+ type Probe,
16
+ type RunRequest,
17
+ type SpawnPlan,
18
+ type Spent,
19
+ } from "../backend.ts";
20
+ import { jsonOf, runCli } from "../cli.ts";
21
+ import { configDir } from "../../infra/paths.ts";
22
+ import { discovered, readDiscovery } from "../discovery.ts";
23
+ import { installAgents, isolatedConfigRoot, OPENCODE_AGENT, userConfigRoot } from "./agents.ts";
24
+ import {
25
+ eventName,
26
+ foldOpencodeEvents,
27
+ isV1Error,
28
+ OPENCODE_LIMIT,
29
+ OPENCODE_LIMIT_TYPES,
30
+ OPENCODE_TOO_OLD,
31
+ opencodeTokens,
32
+ parseOpencodeLine,
33
+ } from "./events.ts";
34
+
35
+ /** Spec §6.3: v2 only (`@opencode/cli` 2.0.16 or newer). */
36
+ const OPENCODE_MIN_VERSION = "2.0.16";
37
+ export const OPENCODE_INSTALL = "curl -fsSL https://opencode.ai/v2/install | bash";
38
+ /** Spec §6.3: discovery is limited to OpenCode Zen and Go. */
39
+ const OPENCODE_PROVIDERS = ["opencode", "opencode-go"];
40
+ const THREAD = /^ses_[A-Za-z0-9]{20,40}$/;
41
+ const DAY_MS = 24 * 60 * 60_000;
42
+
43
+ /** Limits for `opencode` queries; tests shorten them. */
44
+ export const opencodeShell = { timeoutMs: 15_000, retryDelayMs: 1_500 };
45
+
46
+ /** `opencode api <method> <path>`: the JSON the service answered, or null when it failed or said nothing. */
47
+ async function opencodeApi(method: "GET" | "POST", path: string): Promise<Record<string, any> | null> {
48
+ const r = await runCli("opencode", ["api", method, path], opencodeShell);
49
+ const j = r?.ok ? jsonOf(r.out) : null;
50
+ return j && typeof j === "object" ? (j as Record<string, any>) : null;
51
+ }
52
+
53
+ /**
54
+ * An isolated worker's XDG_CONFIG_HOME holds only catherd's opencode config, and every process the worker
55
+ * starts inherits it: point catherd (`catherd lock`), gh and git back at the user's own config.
56
+ */
57
+ function isolatedEnv(): Record<string, string> {
58
+ const user = userConfigRoot();
59
+ const env: Record<string, string> = {
60
+ XDG_CONFIG_HOME: isolatedConfigRoot(),
61
+ CATHERD_CONFIG_DIR: configDir(),
62
+ };
63
+ if (!process.env.GH_CONFIG_DIR) env.GH_CONFIG_DIR = join(user, "gh");
64
+ const gitXdg = join(user, "git", "config");
65
+ if (!process.env.GIT_CONFIG_GLOBAL && !existsSync(join(homedir(), ".gitconfig")) && existsSync(gitXdg))
66
+ env.GIT_CONFIG_GLOBAL = gitXdg;
67
+ return env;
68
+ }
69
+
70
+ function plan(r: RunRequest): SpawnPlan {
71
+ if (r.thread !== null && !THREAD.test(r.thread))
72
+ throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not an opencode session id`, {
73
+ fix: "pass the thread from the earlier RunRecord",
74
+ });
75
+ return {
76
+ cmd: "opencode",
77
+ args: [
78
+ "run",
79
+ "--format",
80
+ "json",
81
+ "--auto",
82
+ "--agent",
83
+ OPENCODE_AGENT[r.access],
84
+ "-m",
85
+ // "default" means the model's own default variant: no `#` (spec §5.1)
86
+ r.rung.effort === "default" ? r.rung.model : `${r.rung.model}#${r.rung.effort}`,
87
+ // v2's background service ignores the client's config env; a standalone server reads it
88
+ ...(r.isolated ? ["--standalone"] : []),
89
+ ...(r.thread === null ? [] : ["-s", r.thread]),
90
+ ],
91
+ env: r.isolated ? isolatedEnv() : {},
92
+ cwd: r.repo,
93
+ stdinPath: r.briefPath,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * The Zen and Go models enabled in `repo`; without one, in the home directory, where v2 resolves a
99
+ * request that names no location (research §2.4).
100
+ */
101
+ async function listModels(repo?: string): Promise<DiscoveredModel[]> {
102
+ const where = repo === undefined ? "" : `?${new URLSearchParams({ "location[directory]": repo })}`;
103
+ let data: Record<string, any>[] = [];
104
+ for (let attempt = 0; attempt < 2 && data.length === 0; attempt++) {
105
+ // the first listing after the service (re)starts can be empty (research §1.4)
106
+ if (attempt > 0) await Bun.sleep(opencodeShell.retryDelayMs);
107
+ const r = await opencodeApi("GET", `/api/model${where}`);
108
+ data = Array.isArray(r?.data) ? r.data : [];
109
+ }
110
+ return data
111
+ .filter(
112
+ (m) => OPENCODE_PROVIDERS.includes(m.providerID) && m.enabled !== false && typeof m.id === "string",
113
+ )
114
+ .map((m) => ({
115
+ id: `${m.providerID}/${m.id}`,
116
+ efforts: ((m.variants ?? []) as { id?: unknown }[])
117
+ .map((v) => v.id)
118
+ .filter((v): v is string => typeof v === "string"),
119
+ context: typeof m.limit?.context === "number" ? m.limit.context : null,
120
+ imageIn: Array.isArray(m.capabilities?.input) && m.capabilities.input.includes("image"),
121
+ }));
122
+ }
123
+
124
+ /**
125
+ * Spec §6.3: install the catherd agents, and refuse a Zen or Go model or variant opencode does not list
126
+ * in the repository (its config can enable or disable models), from a listing cached per repository.
127
+ */
128
+ async function prepare(req: { rung: Rung; access: Access; isolated: boolean; repo: string }): Promise<void> {
129
+ const root = req.isolated ? isolatedConfigRoot() : userConfigRoot();
130
+ // the background service only sees a new agent file after a reload (verified on 2.0.16)
131
+ if (installAgents(root) && !req.isolated) await runCli("opencode", ["reload"], opencodeShell);
132
+ const { model, effort } = req.rung;
133
+ if (!OPENCODE_PROVIDERS.includes(model.split("/")[0] ?? "")) return;
134
+ const models = await discovered("opencode", () => listModels(req.repo), {
135
+ maxAgeMs: DAY_MS,
136
+ need: model,
137
+ repo: req.repo,
138
+ });
139
+ if (models.length === 0) return; // opencode listed nothing: let the run itself say what is wrong
140
+ const m = models.find((x) => x.id === model);
141
+ if (!m)
142
+ throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `opencode does not list ${model}`, {
143
+ fix: model.startsWith("opencode-go/")
144
+ ? "subscribe to OpenCode Go and run: opencode auth login opencode-go --method key"
145
+ : "run: opencode auth login opencode --method key, or pick a model catalog_query lists",
146
+ });
147
+ if (effort !== "default" && !m.efforts.includes(effort))
148
+ throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `${model} has no variant "${effort}"`, {
149
+ fix: `use one of: ${["default", ...m.efforts].join(", ")}`,
150
+ });
151
+ }
152
+
153
+ function finalize(run: FinishedRun): Outcome {
154
+ const f = foldOpencodeEvents(run.eventLines);
155
+ const stopped = run.exit.reason;
156
+ const tooOld = f.tooOld || OPENCODE_TOO_OLD.some((r) => r.test(run.stderr));
157
+ const status: RunStatus =
158
+ stopped === "cancelled"
159
+ ? "cancelled"
160
+ : stopped === "idle-timeout" || stopped === "wall-timeout"
161
+ ? "timeout"
162
+ : tooOld
163
+ ? "cli-too-old"
164
+ : f.limit
165
+ ? "limit"
166
+ : f.error !== null || run.exit.code !== 0
167
+ ? "failed"
168
+ : "ok";
169
+ const lastErr = run.stderr.trim().split("\n").at(-1) ?? "";
170
+ const message = f.declined
171
+ ? `permission rejected: ${f.declined}`
172
+ : (f.error?.message ?? (lastErr || `${stopped}, exit ${run.exit.code ?? run.exit.signal}`));
173
+ return {
174
+ status,
175
+ thread: f.thread ?? run.request.thread,
176
+ tokens: f.tokens,
177
+ costUsd: f.costUsd,
178
+ images: [],
179
+ error: status === "ok" ? null : { code: status, message },
180
+ reply: f.reply,
181
+ };
182
+ }
183
+
184
+ function parse(line: string): EventDelta {
185
+ const e = parseOpencodeLine(line);
186
+ if (!e) return {};
187
+ const d: EventDelta = { lastEvent: eventName(e) };
188
+ if (typeof e.sessionID === "string") d.thread = e.sessionID;
189
+ if (e.type === "step_finish") {
190
+ d.tokens = opencodeTokens(e.part?.tokens);
191
+ d.requestInput = d.tokens.input;
192
+ if (typeof e.part?.cost === "number") d.costUsd = e.part.cost;
193
+ }
194
+ if (e.type === "error") {
195
+ if (isV1Error(e)) d.tooOld = true;
196
+ else if (OPENCODE_LIMIT_TYPES.includes(e.error?.type)) d.limit = true;
197
+ d.failure = String(e.error?.message ?? e.error?.data?.message ?? "error");
198
+ }
199
+ return d;
200
+ }
201
+
202
+ /**
203
+ * A usage limit the latest assistant message is retrying on or failed with (v2 keeps `retry: {attempt, at,
204
+ * error}` on it), or null. Messages come newest first, after an `idle` marker once the session stopped;
205
+ * older messages keep their errors, so only the newest assistant message counts, and with `sinceMs` only
206
+ * if this run wrote it.
207
+ */
208
+ function limitRetry(messages: unknown, sinceMs?: number): string | null {
209
+ if (!Array.isArray(messages)) return null;
210
+ const m = messages.find((x) => x?.type === "assistant") as Record<string, any> | undefined;
211
+ const err = m?.retry?.error ?? m?.error;
212
+ if (!OPENCODE_LIMIT_TYPES.includes(err?.type)) return null;
213
+ if (sinceMs !== undefined && !(m?.time?.created >= sinceMs)) return null;
214
+ return String(err.message ?? err.type);
215
+ }
216
+
217
+ const minus = (a: Tokens, b: Tokens): Tokens => ({
218
+ input: Math.max(0, a.input - b.input),
219
+ cached: Math.max(0, a.cached - b.cached),
220
+ output: Math.max(0, a.output - b.output),
221
+ });
222
+
223
+ /**
224
+ * Spec §6.3: totals from `GET /api/session/<id>` (the stream often drops its last step_finish), minus
225
+ * what earlier records on the session counted; a run stopped while retrying on a usage limit is a limit.
226
+ */
227
+ async function settle(o: Outcome, run: FinishedRun, prior: Spent): Promise<Outcome> {
228
+ if (o.thread === null) return o;
229
+ let out = o;
230
+ const s = (await opencodeApi("GET", `/api/session/${o.thread}`))?.data;
231
+ if (s?.tokens)
232
+ out = {
233
+ ...out,
234
+ tokens: minus(opencodeTokens(s.tokens), prior.tokens),
235
+ costUsd: typeof s.cost === "number" ? Math.max(0, s.cost - prior.costUsd) : out.costUsd,
236
+ };
237
+ if (out.status === "timeout" || out.status === "failed") {
238
+ const messages = (await opencodeApi("GET", `/api/session/${o.thread}/message`))?.data;
239
+ const why = limitRetry(messages, run.startedAtMs);
240
+ if (why) out = { ...out, status: "limit", error: { code: "limit", message: why } };
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * Busy while the service lists the session as running, unless it is only waiting out a usage limit.
247
+ * Empty or failed API output counts as not busy (spec §6.3), so a hung run still times out.
248
+ */
249
+ async function isBusy(thread: string, sinceMs?: number): Promise<boolean> {
250
+ const active = (await opencodeApi("GET", "/api/session/active"))?.data;
251
+ if (!active || typeof active !== "object" || !(thread in active)) return false;
252
+ const messages = (await opencodeApi("GET", `/api/session/${thread}/message`))?.data;
253
+ return Array.isArray(messages) && limitRetry(messages, sinceMs) === null;
254
+ }
255
+
256
+ /** Killing the v2 client does not stop its session; the service must be told (research §2.4). */
257
+ async function interrupt(thread: string): Promise<void> {
258
+ await opencodeApi("POST", `/api/session/${thread}/interrupt`);
259
+ }
260
+
261
+ /** Spec §4.5: Go `X` → Zen `X` when Zen lists `X` with the same variant (in `repo`, when given). */
262
+ function failoverFor(rung: Rung, repo?: string): Rung | null {
263
+ if (!rung.model.startsWith("opencode-go/")) return null;
264
+ const zen = `opencode/${rung.model.slice("opencode-go/".length)}`;
265
+ const m = readDiscovery("opencode", repo)?.models.find((x) => x.id === zen);
266
+ if (!m || (rung.effort !== "default" && !m.efforts.includes(rung.effort))) return null;
267
+ return { backend: "opencode", model: zen, effort: rung.effort };
268
+ }
269
+
270
+ async function probe(): Promise<Probe> {
271
+ const v = await runCli("opencode", ["--version"], opencodeShell);
272
+ if (!v)
273
+ return {
274
+ installed: false,
275
+ version: null,
276
+ versionOk: false,
277
+ loggedIn: null,
278
+ problems: [{ code: "E_BACKEND_MISSING", message: "opencode is not on PATH", fix: OPENCODE_INSTALL }],
279
+ };
280
+ const version = extractVersion(v.out);
281
+ const versionOk = version !== null && compareVersions(version, OPENCODE_MIN_VERSION) >= 0;
282
+ const auth = jsonOf((await runCli("opencode", ["auth", "list", "--format", "json"], opencodeShell))?.out);
283
+ // Not logged in is no problem: the free Zen models run without a key.
284
+ const loggedIn = Array.isArray(auth)
285
+ ? auth.some((p) => OPENCODE_PROVIDERS.includes(p?.id) && (p?.connections?.length ?? 0) > 0)
286
+ : null;
287
+ const problems: Probe["problems"] = [];
288
+ if (!versionOk)
289
+ problems.push({
290
+ code: "E_BACKEND_TOO_OLD",
291
+ message: version?.startsWith("1.")
292
+ ? `opencode ${version} is v1; catherd needs opencode v2 (${OPENCODE_MIN_VERSION} or newer)`
293
+ : `opencode ${version ?? "?"} is older than ${OPENCODE_MIN_VERSION}`,
294
+ fix: OPENCODE_INSTALL,
295
+ });
296
+ return { installed: true, version, versionOk, loggedIn, problems };
297
+ }
298
+
299
+ export const opencodeAdapter: BackendAdapter = {
300
+ id: "opencode",
301
+ minVersion: OPENCODE_MIN_VERSION,
302
+ probe,
303
+ listModels,
304
+ prepare,
305
+ plan,
306
+ parse,
307
+ finalize,
308
+ settle,
309
+ enforcement: { "read-only": "advisory", "workspace-write": "advisory", full: "advisory" },
310
+ errors: { limit: OPENCODE_LIMIT, tooOld: OPENCODE_TOO_OLD },
311
+ resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
312
+ interrupt: (thread) => interrupt(thread),
313
+ isBusy: (thread, _cwd, sinceMs) => isBusy(thread, sinceMs),
314
+ failoverFor,
315
+ graceAfterFinalMs: null,
316
+ };
@@ -0,0 +1,14 @@
1
+ import type { BackendAdapter } from "./backend.ts";
2
+
3
+ const adapters = new Map<string, BackendAdapter>();
4
+
5
+ export function registerAdapter(a: BackendAdapter): void {
6
+ adapters.set(a.id, a);
7
+ }
8
+
9
+ export const adapterFor = (id: string): BackendAdapter | null => adapters.get(id) ?? null;
10
+
11
+ /** Tests only: forget an adapter registered for the test. */
12
+ export function unregisterAdapter(id: string): void {
13
+ adapters.delete(id);
14
+ }
package/src/cli.ts CHANGED
@@ -1,28 +1,168 @@
1
1
  #!/usr/bin/env bun
2
- import { readFileSync, realpathSync } from "node:fs";
3
- import { fileURLToPath } from "node:url";
4
- import { defineCommand, runMain } from "citty";
5
- import { lockCommand } from "./core/lock.ts";
6
- import { mcpCommand } from "./mcp/commands.ts";
7
- import { catalogCommand } from "./routing/commands.ts";
8
- import { editorRun, initCommand, watchCommand } from "./tui/commands.ts";
9
-
10
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
11
- version: string;
2
+ import type { ArgsDef } from "citty";
3
+ import { errorMessage } from "./domain/errors.ts";
4
+ import { runtimeRefusal } from "./domain/runtime.ts";
5
+
6
+ // Spec §3.1: refuse an old Bun before anything else loads.
7
+ const refusal = runtimeRefusal(typeof Bun === "undefined" ? undefined : Bun.version);
8
+ if (refusal) {
9
+ for (const line of refusal) console.error(line);
10
+ process.exit(1);
11
+ }
12
+
13
+ const { realpathSync } = await import("node:fs");
14
+ const { fileURLToPath } = await import("node:url");
15
+ const { defineCommand, renderUsage, runCommand } = await import("citty");
16
+ const { CatherdError, isCatherdError } = await import("./domain/errors.ts");
17
+ const { EXIT, exitCodeOf, printError, stripAnsi } = await import("./entry/cli-kit.ts");
18
+ const { VERSION } = await import("./infra/version.ts");
19
+
20
+ type Command = ReturnType<typeof defineCommand>;
21
+
22
+ /** The dashboard's flags, typed as any command's args so `main` stays a plain `Command`. */
23
+ const dashboardArgs: ArgsDef = {
24
+ plain: {
25
+ type: "boolean",
26
+ description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
27
+ },
28
+ "reduced-motion": { type: "boolean", description: "the dashboard without animation" },
12
29
  };
13
30
 
14
- export const main = defineCommand({
15
- meta: { name: "catherd", version: pkg.version, description: "Herds coding agents." },
31
+ /**
32
+ * Spec §8. Every subcommand loads lazily, so `mcp`, `lock` and `_supervise` never load OpenTUI or React
33
+ * (spec §3.1); a bare `catherd` opens the TUI.
34
+ */
35
+ export const main: Command = defineCommand({
36
+ meta: {
37
+ name: "catherd",
38
+ version: VERSION,
39
+ description: "Herds coding agents",
40
+ },
41
+ args: dashboardArgs,
16
42
  subCommands: {
17
- lock: lockCommand,
18
- catalog: catalogCommand,
19
- mcp: mcpCommand,
20
- init: initCommand,
21
- watch: watchCommand,
43
+ init: () => import("./entry/init-command.ts").then((m) => m.initCommand),
44
+ status: () => import("./entry/runs-command.ts").then((m) => m.statusCommand),
45
+ watch: () => import("./entry/runs-command.ts").then((m) => m.watchCommand),
46
+ runs: () => import("./entry/runs-command.ts").then((m) => m.runsCommand),
47
+ profile: () => import("./entry/profile-command.ts").then((m) => m.profileCommand),
48
+ doctor: () => import("./entry/doctor-command.ts").then((m) => m.doctorCommand),
49
+ catalog: () => import("./entry/catalog-command.ts").then((m) => m.catalogCommand),
50
+ lock: () => import("./entry/lock-command.ts").then((m) => m.lockCommand),
51
+ "capture-fixtures": () =>
52
+ import("./entry/capture-fixtures-command.ts").then((m) => m.captureFixturesCommand),
53
+ mcp: () => import("./entry/mcp/command.ts").then((m) => m.mcpCommand),
54
+ _supervise: () => import("./entry/supervise-command.ts").then((m) => m.superviseCommand),
55
+ },
56
+ // citty runs this after any subcommand too; only a bare `catherd` loads and opens the dashboard
57
+ async run(ctx) {
58
+ if (!ctx.rawArgs.every((a) => a.startsWith("-"))) return;
59
+ await (await import("./entry/tui/run.tsx")).tuiRun(ctx);
22
60
  },
23
- run: editorRun,
24
61
  });
25
62
 
63
+ const resolve = async <T>(v: T | Promise<T> | (() => T | Promise<T>)): Promise<T> =>
64
+ typeof v === "function" ? await (v as () => T | Promise<T>)() : await v;
65
+
66
+ /** The deepest command `argv` names and its parent: what `--help` and a usage error describe. */
67
+ async function commandFor(argv: string[]): Promise<[Command, Command | undefined, string[]]> {
68
+ let cmd = main;
69
+ let parent: Command | undefined;
70
+ const path: string[] = [];
71
+ for (const a of argv) {
72
+ if (a.startsWith("-")) continue;
73
+ const subs = cmd.subCommands ? await resolve(cmd.subCommands) : undefined;
74
+ const next = subs?.[a];
75
+ if (!next) break;
76
+ parent = cmd;
77
+ cmd = (await resolve(next)) as Command;
78
+ path.push(a);
79
+ }
80
+ return [cmd, parent, path];
81
+ }
82
+
83
+ /** runCli takes `--verbose` before any command, so every command's help lists it. */
84
+ const VERBOSE_ARG: ArgsDef = {
85
+ verbose: { type: "boolean", description: "log at debug level (CATHERD_LOG=debug)" },
86
+ };
87
+
88
+ /**
89
+ * `--help` for the command `path` names (spec §8): `catherd <path>` with the version at every depth, the
90
+ * usage line a command spells out itself, and no colour codes when piped or under NO_COLOR.
91
+ */
92
+ async function helpText(cmd: Command, path: string[]): Promise<string> {
93
+ const shown = { ...cmd, args: { ...(await resolve(cmd.args ?? {})), ...VERBOSE_ARG } };
94
+ const parent = path.length
95
+ ? defineCommand({ meta: { name: ["catherd", ...path.slice(0, -1)].join(" "), version: VERSION } })
96
+ : undefined;
97
+ let text = await renderUsage(shown, parent);
98
+ const usage = path[0] === "lock" ? (await import("./entry/lock-command.ts")).LOCK_USAGE : undefined;
99
+ if (usage) text = text.replace(/^(\S*USAGE\S*) .*$/m, (_, head: string) => `${head} ${usage}`);
100
+ return process.stdout.isTTY && !process.env.NO_COLOR ? text : stripAnsi(text);
101
+ }
102
+
103
+ /** citty's "Unknown command x." worded as catherd's own messages: lower case, no full stop (audit N5). */
104
+ const cittyWording = (m: string): string => `${m.charAt(0).toLowerCase()}${m.slice(1)}`.replace(/\.$/, "");
105
+
106
+ const isCliError = (e: unknown): e is Error & { code: string } => e instanceof Error && e.name === "CLIError";
107
+
108
+ /** Runs `catherd <argv>` and returns its exit code (spec §8). */
109
+ export async function runCli(argv: string[]): Promise<number> {
110
+ // catherd's own flags are those before `--`; everything after belongs to the command `lock` runs
111
+ const dashdash = argv.indexOf("--");
112
+ const head = dashdash < 0 ? argv : argv.slice(0, dashdash);
113
+ if (head.includes("--verbose")) process.env.CATHERD_LOG = "debug";
114
+ const own = head.filter((a) => a !== "--verbose");
115
+ const rawArgs = [...own, ...(dashdash < 0 ? [] : argv.slice(dashdash))];
116
+ if (own.length === 1 && (own[0] === "--version" || own[0] === "-v")) {
117
+ console.log(VERSION);
118
+ return EXIT.ok;
119
+ }
120
+ if (own.includes("--help") || own.includes("-h")) {
121
+ const [cmd, , path] = await commandFor(own);
122
+ console.log(`${await helpText(cmd, path)}\n`);
123
+ return EXIT.ok;
124
+ }
125
+ const command = own.find((a) => !a.startsWith("-"));
126
+ // a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
127
+ const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
128
+ // citty negates a boolean as `--no-<name>`
129
+ const known = (name: string) =>
130
+ Object.hasOwn(dashboardArgs, name) ||
131
+ (name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
132
+ const unknown = command === undefined ? own.find((a) => !known(flag(a))) : undefined;
133
+ if (unknown !== undefined) {
134
+ printError(new CatherdError("E_INPUT_INVALID", `unknown option ${unknown}`, { fix: "catherd --help" }));
135
+ return EXIT.usage;
136
+ }
137
+ // `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
138
+ // the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
139
+ if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
140
+ try {
141
+ await runCommand(main, { rawArgs });
142
+ return Number(process.exitCode ?? EXIT.ok);
143
+ } catch (e) {
144
+ if (isCliError(e)) {
145
+ const [, , path] = await commandFor(own);
146
+ printError(
147
+ new CatherdError("E_INPUT_INVALID", cittyWording(stripAnsi(e.message)), {
148
+ fix: `catherd ${[...path, "--help"].join(" ")}`,
149
+ }),
150
+ );
151
+ return EXIT.usage;
152
+ }
153
+ if (isCatherdError(e)) {
154
+ printError(e);
155
+ return exitCodeOf(e);
156
+ }
157
+ printError({
158
+ code: "E_IO_UNEXPECTED",
159
+ message: errorMessage(e),
160
+ fix: "this is a catherd bug: report it with this message and the output of the same command with --verbose",
161
+ });
162
+ return EXIT.error;
163
+ }
164
+ }
165
+
26
166
  if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
27
- void runMain(main);
167
+ process.exitCode = await runCli(process.argv.slice(2));
28
168
  }
@@ -0,0 +1,60 @@
1
+ import { parseRung, tryParseRung } from "./ids.ts";
2
+ import { agentName, type Profile } from "./profile.ts";
3
+ import type { Access } from "./record.ts";
4
+ import { nativeDisallowedTools, rolePrompt } from "./role-prompts.ts";
5
+ import { ROLES, type Role } from "./roles.ts";
6
+
7
+ export interface AgentFile {
8
+ /** the agent's name, also its file name without `.md` */
9
+ name: string;
10
+ role: Role;
11
+ rung: string;
12
+ text: string;
13
+ }
14
+
15
+ /** One native subagent's file: Claude Code reads `name`, `model`, `effort` and the tool list. */
16
+ export function renderAgent(o: {
17
+ profile: string;
18
+ role: Role;
19
+ rung: string;
20
+ access: Access;
21
+ version: string;
22
+ }): string {
23
+ const { model, effort } = parseRung(o.rung);
24
+ return [
25
+ "---",
26
+ `name: ${agentName(o.profile, o.role, o.rung)}`,
27
+ `description: Internal ${o.role} role of the catherd orchestrator (profile ${o.profile}), on ${model}${effort === "default" ? "" : ` at ${effort} effort`}. Dispatched only by the catherd skill while a run is in flight. Never for a plain request, even one that names this role.`,
28
+ `model: ${model}`,
29
+ ...(effort === "default" ? [] : [`effort: ${effort}`]),
30
+ `disallowedTools: ${nativeDisallowedTools(o.access).join(", ")}`,
31
+ "---",
32
+ "",
33
+ rolePrompt(o.role, o.version),
34
+ "",
35
+ ].join("\n");
36
+ }
37
+
38
+ /**
39
+ * Spec §7.3: an agent file per enabled role and native `claude:` rung, counting a native stand-in for one
40
+ * of the role's rungs, since dispatch hands those to the orchestrator as an Agent to start.
41
+ */
42
+ export function agentFiles(p: Profile, version: string): AgentFile[] {
43
+ const out = new Map<string, AgentFile>();
44
+ for (const role of ROLES) {
45
+ const rc = p.roles[role];
46
+ if (!rc.enabled) continue;
47
+ const rungs = [...rc.rungs, ...rc.rungs.flatMap((r) => p.failover[r] ?? [])];
48
+ for (const rung of rungs) {
49
+ if (tryParseRung(rung)?.backend !== "claude") continue;
50
+ const name = agentName(p.name, role, rung);
51
+ out.set(name, {
52
+ name,
53
+ role,
54
+ rung,
55
+ text: renderAgent({ profile: p.name, role, rung, access: rc.access, version }),
56
+ });
57
+ }
58
+ }
59
+ return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
60
+ }
@@ -0,0 +1,45 @@
1
+ export interface Budget {
2
+ minutes?: number;
3
+ tokens?: number;
4
+ usd?: number;
5
+ }
6
+
7
+ export interface Spend {
8
+ minutes: number;
9
+ tokens: number;
10
+ usd: number;
11
+ }
12
+
13
+ export interface BudgetStatus {
14
+ /** the highest spent/cap ratio over the caps the profile sets; ≥ 1 means spent */
15
+ fraction: number;
16
+ minutes?: { spent: number; cap: number };
17
+ tokens?: { spent: number; cap: number };
18
+ usd?: { spent: number; cap: number };
19
+ }
20
+
21
+ /** Spec §4.6: from this fraction on, `route` starts at the cheapest rung that clears the bar. */
22
+ export const BUDGET_CHEAP_AT = 0.8;
23
+
24
+ /** null when the profile sets no cap. */
25
+ export function budgetStatus(spend: Spend, budget: Budget): BudgetStatus | null {
26
+ const b: BudgetStatus = { fraction: 0 };
27
+ let capped = false;
28
+ for (const key of ["minutes", "tokens", "usd"] as const) {
29
+ const cap = budget[key];
30
+ if (cap === undefined) continue;
31
+ capped = true;
32
+ const spent = spend[key];
33
+ b[key] = { spent, cap };
34
+ b.fraction = Math.max(b.fraction, cap > 0 ? spent / cap : spent > 0 ? 1 : 0);
35
+ }
36
+ return capped ? b : null;
37
+ }
38
+
39
+ export function formatBudget(b: BudgetStatus): string {
40
+ const parts: string[] = [];
41
+ if (b.minutes) parts.push(`${Math.round(b.minutes.spent)}/${b.minutes.cap} min`);
42
+ if (b.tokens) parts.push(`${b.tokens.spent}/${b.tokens.cap} tokens`);
43
+ if (b.usd) parts.push(`$${b.usd.spent.toFixed(2)}/$${b.usd.cap.toFixed(2)}`);
44
+ return `${parts.join(" · ")} (${Math.round(b.fraction * 100)}%)`;
45
+ }