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.
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 -26
  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 -84
  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,185 @@
1
+ import type { Probe } from "../adapters/backend.ts";
2
+ import { adapterFor } from "../adapters/registry.ts";
3
+ import "../adapters/all.ts";
4
+ import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
5
+ import type { Profile } from "../domain/profile.ts";
6
+ import { ROLES, type Role } from "../domain/roles.ts";
7
+ import { refreshDiscovery } from "./catalog-service.ts";
8
+ import { type Check, errText, fixOf } from "./doctor-checks.ts";
9
+
10
+ // The backend rows of `catherd doctor` (spec §10.3) and the backends the linked profiles use.
11
+
12
+ /** A rung's backend; null for a malformed rung, which validate reports. */
13
+ const backendOf = (rung: string): string | null => tryParseRung(rung)?.backend ?? null;
14
+
15
+ /** Every backend a linked profile runs a role on ("role"), or only fails over to ("failover"). */
16
+ export function usedBackends(profiles: Profile[]): Map<string, "role" | "failover"> {
17
+ const used = new Map<string, "role" | "failover">();
18
+ const note = (rung: string, how: "role" | "failover") => {
19
+ const b = backendOf(rung);
20
+ if (b && used.get(b) !== "role") used.set(b, how);
21
+ };
22
+ for (const p of profiles) {
23
+ for (const role of ROLES) if (p.roles[role].enabled) for (const r of p.roles[role].rungs) note(r, "role");
24
+ for (const to of Object.values(p.failover)) note(to, "failover");
25
+ }
26
+ return used;
27
+ }
28
+
29
+ /**
30
+ * Spec §10.3: the backends that run an enabled workspace-write role, or stand in for one of its rungs,
31
+ * whose sandbox must let such a worker take the heavy lock.
32
+ */
33
+ export function workspaceWriteBackends(profiles: Profile[]): Set<string> {
34
+ const out = new Set<string>();
35
+ for (const p of profiles)
36
+ for (const role of ROLES) {
37
+ const rc = p.roles[role];
38
+ if (!rc.enabled || rc.access !== "workspace-write") continue;
39
+ for (const r of [...rc.rungs, ...rc.rungs.flatMap((x) => p.failover[x] ?? [])]) {
40
+ const b = backendOf(r);
41
+ if (b) out.add(b);
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ const PROBLEM_WORD: Record<string, string> = {
48
+ E_BACKEND_MISSING: "missing",
49
+ E_BACKEND_TOO_OLD: "too old",
50
+ E_BACKEND_NOT_LOGGED_IN: "not logged in",
51
+ };
52
+
53
+ /**
54
+ * Spec §7.2 keeps the default profile Codex-first. When a backend a role runs on is missing and another is
55
+ * ready, its fix also offers to move those roles there, onto a rung the shipped catalog scores.
56
+ */
57
+ const MOVE_TO: Record<string, string> = {
58
+ "claude-code": "claude-code:claude-opus-5-5#medium",
59
+ opencode: "opencode:opencode-go/gpt-6-luna#high",
60
+ codex: "codex:gpt-6-sol#medium",
61
+ };
62
+
63
+ /** `w` as one shell word: bare when plainly safe, else single-quoted (zsh can glob a bare `#`). */
64
+ const shellWord = (w: string): string => (/^[\w./:=@,+-]+$/.test(w) ? w : `'${w.replaceAll("'", `'\\''`)}'`);
65
+
66
+ /**
67
+ * Install `id`, or move the roles that run on it to `to`: one runnable `catherd profile set` per line, for
68
+ * each linked profile, any default rung the new ladder would not hold reset first. The artist draws, which only Codex does, so
69
+ * off Codex it is turned off instead.
70
+ */
71
+ export function moveRolesFix(install: string, id: string, to: string, profiles: Profile[]): string {
72
+ const onIt = (r: string | undefined) => r !== undefined && backendOf(r) === id;
73
+ const set = (p: Profile, path: string, v: string) =>
74
+ `catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}`;
75
+ const stuck = new Set<Role>();
76
+ const commands: string[] = [];
77
+ for (const p of profiles)
78
+ for (const role of ROLES) {
79
+ const rc = p.roles[role];
80
+ if (!rc.enabled || !rc.rungs.some(onIt)) continue;
81
+ if (role === "artist" && to !== "codex") {
82
+ stuck.add(role);
83
+ commands.push(set(p, `roles.${role}.enabled`, "false"));
84
+ continue;
85
+ }
86
+ const target = MOVE_TO[to] as string;
87
+ // the new ladder holds only `target`, so any other default would fail validation: reset it first
88
+ if (rc.defaultRung !== undefined && rc.defaultRung !== null && rc.defaultRung !== target)
89
+ commands.push(set(p, `roles.${role}.defaultRung`, "null"));
90
+ commands.push(set(p, `roles.${role}.rungs`, target));
91
+ }
92
+ if (!commands.length) return install;
93
+ const off = stuck.size ? ` (${[...stuck].join(", ")} needs ${id}, so it is turned off)` : "";
94
+ return [
95
+ install,
96
+ `or move its roles to ${to}: /catherd-setup in Claude Code, or run${off}`,
97
+ ...commands,
98
+ ].join("\n");
99
+ }
100
+
101
+ export async function backendChecks(
102
+ used: Map<string, "role" | "failover">,
103
+ profiles: Profile[],
104
+ ): Promise<Check[]> {
105
+ const checks: Check[] = [];
106
+ const ready: string[] = [];
107
+ for (const id of ADAPTER_IDS) {
108
+ const a = adapterFor(id);
109
+ if (!a) continue;
110
+ const probe: Probe = await a.probe().catch((e: unknown) => ({
111
+ installed: false,
112
+ version: null,
113
+ versionOk: false,
114
+ loggedIn: null,
115
+ problems: [
116
+ { code: "E_IO_UNEXPECTED" as const, message: errText(e), fix: `run ${id} --version to see why` },
117
+ ],
118
+ }));
119
+ const problem = probe.problems[0];
120
+ const use = used.get(id);
121
+ if (!problem) {
122
+ ready.push(id);
123
+ const detail = [probe.version, probe.login && `${probe.login} login`].filter(Boolean).join(" · ");
124
+ // a login billed apart from what a profile says (a plan, or per token) ranks that backend's cost wrongly;
125
+ // only a profile that routes something to this backend is ranked by it
126
+ const billed =
127
+ probe.billing &&
128
+ profiles.find((p) => p.billing[id] && p.billing[id] !== probe.billing && usedBackends([p]).has(id));
129
+ checks.push(
130
+ billed
131
+ ? {
132
+ id: `backend:${id}`,
133
+ label: id,
134
+ state: "warn",
135
+ word: "billing",
136
+ detail: `${detail} · profile ${billed.name} bills ${id} as ${billed.billing[id]}, but this login is ${probe.billing}`,
137
+ fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}`,
138
+ }
139
+ : { id: `backend:${id}`, label: id, state: "ok", word: "ready", detail },
140
+ );
141
+ continue;
142
+ }
143
+ checks.push({
144
+ id: `backend:${id}`,
145
+ label: id,
146
+ state: use === "role" ? "fail" : use === "failover" ? "warn" : "skip",
147
+ word: PROBLEM_WORD[problem.code] ?? "not ready",
148
+ detail: `${problem.message}${use === "failover" ? " (a failover stand-in uses it)" : use ? "" : " (no profile uses it)"}`,
149
+ fix: problem.fix,
150
+ });
151
+ }
152
+ const to = Object.keys(MOVE_TO).find((b) => ready.includes(b));
153
+ for (const c of checks) {
154
+ const id = c.id.slice("backend:".length);
155
+ if (to && c.state === "fail" && c.word === PROBLEM_WORD.E_BACKEND_MISSING && c.fix && id !== to)
156
+ c.fix = moveRolesFix(c.fix, id, to, profiles);
157
+ }
158
+ // spec §5.2: doctor refreshes discovery; a listing that fails keeps the last one
159
+ let refreshed: Awaited<ReturnType<typeof refreshDiscovery>> = [];
160
+ try {
161
+ refreshed = await refreshDiscovery({ backends: ready });
162
+ } catch (e) {
163
+ checks.push({
164
+ id: "discovery",
165
+ label: "model discovery",
166
+ state: "fail",
167
+ word: "unreadable",
168
+ detail: errText(e),
169
+ fix: fixOf(e) ?? "catherd catalog refresh --verbose",
170
+ });
171
+ }
172
+ for (const r of refreshed) {
173
+ const c = checks.find((x) => x.id === `backend:${r.backend}`);
174
+ if (!c) continue;
175
+ if (r.error)
176
+ Object.assign(c, {
177
+ state: "warn",
178
+ word: "no listing",
179
+ detail: `${c.detail} · ${r.error}`,
180
+ fix: "catherd catalog refresh",
181
+ });
182
+ else c.detail = `${c.detail} · ${r.models} models`;
183
+ }
184
+ return checks;
185
+ }
@@ -0,0 +1,103 @@
1
+ import { readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { errorMessage, isCatherdError } from "../domain/errors.ts";
4
+ import { claudeHome, locksDir } from "../infra/paths.ts";
5
+ import { ensurePrivateDir, PRIVATE_FILE } from "../infra/store.ts";
6
+ import { agentLinkState } from "./agent-links.ts";
7
+ import { activeName } from "./profile-store.ts";
8
+
9
+ // The rows of `catherd doctor` and the checks that stand alone; doctor.ts assembles the report.
10
+
11
+ type CheckState = "ok" | "warn" | "fail" | "skip";
12
+
13
+ /** One row of `catherd doctor` (spec §10.3): its state, one word, the detail and the full fix. */
14
+ export interface Check {
15
+ id: string;
16
+ label: string;
17
+ state: CheckState;
18
+ word: string;
19
+ detail: string;
20
+ fix?: string;
21
+ }
22
+
23
+ export const PLUGIN_INSTALL =
24
+ "claude plugin marketplace add 47vigen/catherd && claude plugin install catherd@catherd";
25
+ const PLUGIN_UPDATE = "claude plugin marketplace update catherd && claude plugin update catherd@catherd";
26
+
27
+ export const errText = (e: unknown): string => errorMessage(e).split("\n")[0] as string;
28
+ export const fixOf = (e: unknown) => (isCatherdError(e) ? e.fix : undefined);
29
+
30
+ /** A check whose reads can throw (a corrupt or newer-schema file): the throw becomes its `fail` row. */
31
+ export function guarded(id: string, label: string, fallbackFix: string, check: () => Check): Check {
32
+ try {
33
+ return check();
34
+ } catch (e) {
35
+ return { id, label, state: "fail", word: "unreadable", detail: errText(e), fix: fixOf(e) ?? fallbackFix };
36
+ }
37
+ }
38
+
39
+ export function pluginCheck(version: string): Check {
40
+ const base = { id: "plugin", label: "Claude Code plugin" };
41
+ const file = join(claudeHome(), "plugins", "installed_plugins.json");
42
+ let installed: string | null = null;
43
+ try {
44
+ const j = JSON.parse(readFileSync(file, "utf8")) as { plugins?: Record<string, { version?: string }[]> };
45
+ const entry = Object.entries(j.plugins ?? {}).find(([k]) => k.startsWith("catherd@"))?.[1]?.[0];
46
+ installed = entry ? (entry.version ?? "unknown") : null;
47
+ } catch {
48
+ installed = null;
49
+ }
50
+ if (installed === null)
51
+ return {
52
+ ...base,
53
+ state: "fail",
54
+ word: "missing",
55
+ detail: "not installed in Claude Code",
56
+ fix: PLUGIN_INSTALL,
57
+ };
58
+ if (installed !== version)
59
+ return {
60
+ ...base,
61
+ state: "fail",
62
+ word: "stale",
63
+ detail: `plugin ${installed}, catherd ${version}`,
64
+ fix: PLUGIN_UPDATE,
65
+ };
66
+ return { ...base, state: "ok", word: "ready", detail: installed };
67
+ }
68
+
69
+ export function locksCheck(): Check {
70
+ const base = { id: "locks", label: "heavy-lock dir" };
71
+ try {
72
+ ensurePrivateDir(locksDir());
73
+ const probe = join(locksDir(), `.doctor-${process.pid}`);
74
+ writeFileSync(probe, "", { mode: PRIVATE_FILE });
75
+ rmSync(probe, { force: true });
76
+ return { ...base, state: "ok", word: "ready", detail: locksDir() };
77
+ } catch (e) {
78
+ return {
79
+ ...base,
80
+ state: "fail",
81
+ word: "not writable",
82
+ detail: `${locksDir()}: ${errText(e)}`,
83
+ fix: `chmod -R u+w ${locksDir()}`,
84
+ };
85
+ }
86
+ }
87
+
88
+ export function agentsCheck(): Check {
89
+ const base = { id: "agents", label: "Claude agents" };
90
+ const links = agentLinkState();
91
+ const broken = [...links.missing, ...links.stale];
92
+ if (broken.length)
93
+ return {
94
+ ...base,
95
+ state: "fail",
96
+ word: links.missing.length ? "missing" : "stale",
97
+ detail: broken.join(", "),
98
+ fix: `catherd profile use ${activeName()}`,
99
+ };
100
+ return links.ok.length
101
+ ? { ...base, state: "ok", word: "ready", detail: `${links.ok.length} linked` }
102
+ : { ...base, state: "skip", word: "none", detail: "no profile uses a native Claude rung" };
103
+ }
@@ -0,0 +1,358 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { adapterFor } from "../adapters/registry.ts";
3
+ import "../adapters/all.ts";
4
+ import type { Profile } from "../domain/profile.ts";
5
+ import { ROLES } from "../domain/roles.ts";
6
+ import { bunTooOld, MIN_BUN } from "../domain/runtime.ts";
7
+ import type { JevTransport } from "../infra/jev-client.ts";
8
+ import { locksDir } from "../infra/paths.ts";
9
+ import { linkedProfiles } from "./agent-links.ts";
10
+ import { backendChecks, usedBackends, workspaceWriteBackends } from "./doctor-backends.ts";
11
+ import {
12
+ agentsCheck,
13
+ type Check,
14
+ errText,
15
+ fixOf,
16
+ guarded,
17
+ locksCheck,
18
+ pluginCheck,
19
+ } from "./doctor-checks.ts";
20
+ import { credentialsPath, jevKey, savedJevKey, testJevKey } from "./jev-service.ts";
21
+ import {
22
+ activeName,
23
+ enforcementOf,
24
+ getProfile,
25
+ profileExists,
26
+ readConfig,
27
+ readProjects,
28
+ validateNamed,
29
+ } from "./profile-store.ts";
30
+ import { listRuns } from "./run-store.ts";
31
+
32
+ export interface DoctorReport {
33
+ /** false when any check failed: `catherd doctor` exits 3 */
34
+ ready: boolean;
35
+ version: string;
36
+ checks: Check[];
37
+ }
38
+
39
+ export interface Handshake {
40
+ ok: boolean;
41
+ tools: string[];
42
+ error?: string;
43
+ }
44
+
45
+ export interface DoctorDeps {
46
+ bunVersion: string;
47
+ /** the package version, which the Claude Code plugin must pin */
48
+ version: string;
49
+ /** starts `catherd mcp` over stdio and asks it for tools/list */
50
+ handshake: () => Promise<Handshake>;
51
+ jev?: JevTransport;
52
+ }
53
+
54
+ /**
55
+ * Spec §10.3: the readiness report. Reads and probes; it writes discovery and a lock probe itself, and the
56
+ * handshake starts `catherd mcp`, which reconciles runs as it starts. A check that throws becomes a `fail` row.
57
+ */
58
+ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
59
+ const checks: Check[] = [];
60
+ checks.push(
61
+ bunTooOld(d.bunVersion)
62
+ ? {
63
+ id: "bun",
64
+ label: "Bun",
65
+ state: "fail",
66
+ word: "too old",
67
+ detail: `${d.bunVersion}, needs ${MIN_BUN}`,
68
+ fix: "bun upgrade",
69
+ }
70
+ : { id: "bun", label: "Bun", state: "ok", word: "ready", detail: d.bunVersion },
71
+ );
72
+
73
+ let profiles: Profile[] = [];
74
+ let linked: string[] = [];
75
+ let active: Profile | null = null;
76
+ try {
77
+ readConfig();
78
+ readProjects();
79
+ active = getProfile(activeName());
80
+ linked = linkedProfiles();
81
+ checks.push({
82
+ id: "config",
83
+ label: "config",
84
+ state: "ok",
85
+ word: "ready",
86
+ detail: `active profile ${active.name}`,
87
+ });
88
+ } catch (e) {
89
+ checks.push({
90
+ id: "config",
91
+ label: "config",
92
+ state: "fail",
93
+ word: "invalid",
94
+ detail: errText(e),
95
+ fix: fixOf(e) ?? "catherd init",
96
+ });
97
+ }
98
+ // one unreadable linked profile must not hide the others' backends: its own row below reports it
99
+ profiles = linked.flatMap((n) => {
100
+ try {
101
+ return [getProfile(n)];
102
+ } catch {
103
+ return [];
104
+ }
105
+ });
106
+ // a repo bound to a profile whose file is gone: every command run in that repo fails to load it
107
+ try {
108
+ for (const [repo, name] of Object.entries(readProjects().bindings))
109
+ if (!profileExists(name))
110
+ checks.push({
111
+ id: `binding:${repo}`,
112
+ label: `binding ${repo}`,
113
+ state: "fail",
114
+ word: "missing",
115
+ detail: `bound to profile ${name}, which does not exist`,
116
+ fix: `cd ${repo} && catherd profile use --repo --clear`,
117
+ });
118
+ } catch {
119
+ // the config row above already reports an unreadable projects.json
120
+ }
121
+ // every linked profile: the active one (row `profile`) and each repo-bound one (row `profile:<name>`).
122
+ // Each fix names its profile: without one, the CLI acts on the profile of the repo doctor runs in.
123
+ const names = active ? [active.name, ...linked.filter((n) => n !== active?.name)] : [];
124
+ for (const name of names) {
125
+ const id = name === active?.name ? "profile" : `profile:${name}`;
126
+ const validate = `catherd profile validate ${name}`;
127
+ const named = (fix: string) =>
128
+ fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name} `);
129
+ checks.push(
130
+ guarded(id, `profile ${name}`, validate, () => {
131
+ const v = validateNamed(name);
132
+ const first = v.errors[0] ?? v.warnings[0];
133
+ return {
134
+ id,
135
+ label: `profile ${name}`,
136
+ state: v.errors.length ? "fail" : v.warnings.length ? "warn" : "ok",
137
+ word: v.errors.length ? "invalid" : v.warnings.length ? "warning" : "ready",
138
+ detail: first
139
+ ? `${first.path}: ${first.message}${v.errors.length + v.warnings.length > 1 ? " (and more)" : ""}`
140
+ : "valid",
141
+ ...(first ? { fix: first.fix ? named(first.fix) : validate } : {}),
142
+ };
143
+ }),
144
+ );
145
+ }
146
+
147
+ const used = usedBackends(profiles);
148
+ checks.push(...(await backendChecks(used, profiles)));
149
+
150
+ if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
151
+ checks.push({
152
+ id: "jev",
153
+ label: "Jev",
154
+ state: "skip",
155
+ word: "off",
156
+ detail: profiles.length > 1 ? "off in every linked profile" : "off in the profile",
157
+ });
158
+ else {
159
+ const key = jevKey();
160
+ if (!key)
161
+ checks.push({
162
+ id: "jev",
163
+ label: "Jev",
164
+ state: "warn",
165
+ word: "no key",
166
+ detail: "optional: routing uses each lane's Kind and Difficulty instead",
167
+ fix: "catherd init, or export TYPESAFE_API_KEY=<key>",
168
+ });
169
+ else {
170
+ const ok = await testJevKey(key, d.jev).catch(() => false);
171
+ checks.push(
172
+ ok
173
+ ? { id: "jev", label: "Jev", state: "ok", word: "ready", detail: "the key answers" }
174
+ : {
175
+ id: "jev",
176
+ label: "Jev",
177
+ state: "warn",
178
+ word: "no answer",
179
+ detail: "the key did not answer; routing falls back to the lanes",
180
+ fix: "catherd init to enter a new key",
181
+ },
182
+ );
183
+ }
184
+ }
185
+
186
+ checks.push(pluginCheck(d.version));
187
+
188
+ checks.push(
189
+ active
190
+ ? guarded("agents", "Claude agents", `catherd profile use ${activeName()}`, agentsCheck)
191
+ : {
192
+ id: "agents",
193
+ label: "Claude agents",
194
+ state: "skip",
195
+ word: "not checked",
196
+ detail: "the config cannot be read",
197
+ },
198
+ );
199
+
200
+ const h = await d
201
+ .handshake()
202
+ .catch((e: unknown): Handshake => ({ ok: false, tools: [], error: errText(e) }));
203
+ checks.push(
204
+ h.ok && h.tools.includes("status")
205
+ ? {
206
+ id: "mcp",
207
+ label: "MCP server",
208
+ state: "ok",
209
+ word: "ready",
210
+ detail: `answers tools/list with ${h.tools.length} tools`,
211
+ }
212
+ : {
213
+ id: "mcp",
214
+ label: "MCP server",
215
+ state: "fail",
216
+ word: "no answer",
217
+ detail: h.error ?? "tools/list has no status tool",
218
+ fix: "run catherd mcp to see why it does not start",
219
+ },
220
+ );
221
+
222
+ checks.push(locksCheck());
223
+ for (const id of workspaceWriteBackends(profiles)) {
224
+ const a = adapterFor(id);
225
+ if (!a?.canWrite) continue;
226
+ const r = await a.canWrite(locksDir()).catch(() => null);
227
+ const base = { id: `sandbox:${id}`, label: `heavy-lock dir from ${id}'s sandbox` };
228
+ checks.push(
229
+ r === null
230
+ ? {
231
+ ...base,
232
+ state: "skip",
233
+ word: "not tested",
234
+ detail: `no ${id} sandbox to test with on this machine`,
235
+ }
236
+ : r.ok
237
+ ? {
238
+ ...base,
239
+ state: "ok",
240
+ word: "ready",
241
+ detail: "a workspace-write worker can take the heavy lock",
242
+ }
243
+ : {
244
+ ...base,
245
+ state: "warn",
246
+ word: "not writable",
247
+ detail: `a workspace-write ${id} worker cannot write ${locksDir()}, so catherd lock fails inside it`,
248
+ ...(r.fix ? { fix: r.fix } : {}),
249
+ },
250
+ );
251
+ }
252
+
253
+ const full: string[] = [];
254
+ const advisory: string[] = [];
255
+ for (const p of profiles)
256
+ for (const role of ROLES) {
257
+ const rc = p.roles[role];
258
+ if (!rc.enabled) continue;
259
+ if (rc.access === "full") full.push(`${role} (${p.name})`);
260
+ const soft = [
261
+ ...new Set(
262
+ rc.rungs
263
+ .filter((r) => enforcementOf(r, rc.access) === "advisory")
264
+ .map((r) => r.slice(0, r.indexOf(":"))),
265
+ ),
266
+ ];
267
+ if (soft.length) advisory.push(`${role} on ${soft.join(", ")} (${p.name})`);
268
+ }
269
+ if (full.length)
270
+ checks.push({
271
+ id: "access:full",
272
+ label: "full access",
273
+ state: "warn",
274
+ word: "warning",
275
+ detail: `no sandbox for: ${full.join(", ")}`,
276
+ });
277
+ if (advisory.length)
278
+ checks.push({
279
+ id: "access:advisory",
280
+ label: "advisory access",
281
+ state: "warn",
282
+ word: "warning",
283
+ detail: `the backend asks but cannot force: ${advisory.join(", ")}`,
284
+ });
285
+ for (const id of used.keys()) {
286
+ const note = adapterFor(id)?.isolationNote;
287
+ if (note)
288
+ checks.push({
289
+ id: `isolation:${id}`,
290
+ label: `${id} isolation`,
291
+ state: "warn",
292
+ word: "weak",
293
+ detail: note,
294
+ });
295
+ }
296
+
297
+ const creds = credentialsPath();
298
+ if (existsSync(creds)) {
299
+ const mode = statSync(creds).mode & 0o777;
300
+ // Jev reads an unreadable file as no key (it is optional): here is where the user learns why
301
+ const unreadable = savedJevKey().problem;
302
+ checks.push(
303
+ mode & 0o077
304
+ ? {
305
+ id: "credentials",
306
+ label: "credentials.json",
307
+ state: "fail",
308
+ word: "readable by others",
309
+ detail: `mode ${mode.toString(8)}`,
310
+ fix: `chmod 600 ${creds}`,
311
+ }
312
+ : unreadable
313
+ ? {
314
+ id: "credentials",
315
+ label: "credentials.json",
316
+ state: "warn",
317
+ word: "unreadable",
318
+ detail: unreadable.message,
319
+ fix: unreadable.fix ?? `fix or delete ${creds}, then catherd init`,
320
+ }
321
+ : { id: "credentials", label: "credentials.json", state: "ok", word: "ready", detail: "mode 600" },
322
+ );
323
+ }
324
+
325
+ if (!Bun.which("bunx", { PATH: process.env.PATH ?? "" }))
326
+ checks.push({
327
+ id: "bunx",
328
+ label: "bunx",
329
+ state: "warn",
330
+ word: "missing",
331
+ detail: "the plugin starts the MCP server with bunx",
332
+ fix: "put Bun's bin folder (~/.bun/bin) on PATH",
333
+ });
334
+ let corrupt: ReturnType<typeof listRuns>["corrupt"] = [];
335
+ try {
336
+ corrupt = listRuns().corrupt;
337
+ } catch (e) {
338
+ checks.push({
339
+ id: "runs",
340
+ label: "run data",
341
+ state: "fail",
342
+ word: "unreadable",
343
+ detail: errText(e),
344
+ fix: fixOf(e) ?? "catherd runs list --verbose",
345
+ });
346
+ }
347
+ if (corrupt.length)
348
+ checks.push({
349
+ id: "runs",
350
+ label: "run data",
351
+ state: "warn",
352
+ word: "unreadable",
353
+ detail: corrupt.map((c) => `${c.id}: ${c.reason}`).join("; "),
354
+ fix: `fix or delete ${corrupt[0]?.dir}`,
355
+ });
356
+
357
+ return { ready: !checks.some((c) => c.state === "fail"), version: d.version, checks };
358
+ }