catherd-cli 0.2.1 → 1.1.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 (207) hide show
  1. package/README.md +141 -33
  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 +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,148 @@
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 type { Handshake } from "./doctor.ts";
8
+ import { activeName } from "./profile-store.ts";
9
+
10
+ // The rows of `catherd doctor` and the checks that stand alone; doctor.ts assembles the report.
11
+
12
+ /** `info`: worth knowing, nothing to fix (spec 1.1 §13: the shipped defaults' access) */
13
+ type CheckState = "ok" | "warn" | "fail" | "skip" | "info";
14
+
15
+ /** One row of `catherd doctor` (spec §10.3): its state, one word, the detail and the full fix. */
16
+ export interface Check {
17
+ id: string;
18
+ label: string;
19
+ state: CheckState;
20
+ word: string;
21
+ detail: string;
22
+ fix?: string;
23
+ }
24
+
25
+ export const PLUGIN_INSTALL =
26
+ "claude plugin marketplace add 47vigen/catherd && claude plugin install catherd@catherd";
27
+ const PLUGIN_UPDATE = "claude plugin marketplace update catherd && claude plugin update catherd@catherd";
28
+
29
+ export const errText = (e: unknown): string => errorMessage(e).split("\n")[0] as string;
30
+ export const fixOf = (e: unknown) => (isCatherdError(e) ? e.fix : undefined);
31
+
32
+ /** A check whose reads can throw (a corrupt or newer-schema file): the throw becomes its `fail` row. */
33
+ export function guarded(id: string, label: string, fallbackFix: string, check: () => Check): Check {
34
+ try {
35
+ return check();
36
+ } catch (e) {
37
+ return { id, label, state: "fail", word: "unreadable", detail: errText(e), fix: fixOf(e) ?? fallbackFix };
38
+ }
39
+ }
40
+
41
+ export function pluginCheck(version: string): Check {
42
+ const base = { id: "plugin", label: "Claude Code plugin" };
43
+ const file = join(claudeHome(), "plugins", "installed_plugins.json");
44
+ let installed: string | null = null;
45
+ try {
46
+ const j = JSON.parse(readFileSync(file, "utf8")) as { plugins?: Record<string, { version?: string }[]> };
47
+ const entry = Object.entries(j.plugins ?? {}).find(([k]) => k.startsWith("catherd@"))?.[1]?.[0];
48
+ installed = entry ? (entry.version ?? "unknown") : null;
49
+ } catch {
50
+ installed = null;
51
+ }
52
+ if (installed === null)
53
+ return {
54
+ ...base,
55
+ state: "fail",
56
+ word: "missing",
57
+ detail: "not installed in Claude Code",
58
+ fix: PLUGIN_INSTALL,
59
+ };
60
+ if (installed !== version)
61
+ return {
62
+ ...base,
63
+ state: "fail",
64
+ word: "stale",
65
+ detail: `plugin ${installed}, catherd ${version}`,
66
+ fix: PLUGIN_UPDATE,
67
+ };
68
+ return { ...base, state: "ok", word: "ready", detail: installed };
69
+ }
70
+
71
+ /** What makes the plugin's launcher run this catherd without a resolve: a global install at this version. */
72
+ export const reinstallCommand = (version: string): string => `bun add -g catherd-cli@${version}`;
73
+
74
+ const MISSING_MODULE = /Cannot find (?:module|package) ['"]?([^'"\s]+)/;
75
+ const NO_BUNX = /bunx: (?:command )?not found/;
76
+
77
+ /**
78
+ * Spec 1.1 §12: the `mcp` row. The handshake starts the server the way the plugin does; a module it cannot
79
+ * load (a half-cleaned bunx cache, a broken global install) and a launcher that finds neither catherd nor
80
+ * bunx both say "reinstall: <command>".
81
+ */
82
+ export function mcpCheck(h: Handshake, version: string): Check {
83
+ const base = { id: "mcp", label: "MCP server" };
84
+ if (h.ok && h.tools.includes("status"))
85
+ return { ...base, state: "ok", word: "ready", detail: `answers tools/list with ${h.tools.length} tools` };
86
+ const said = `${h.error ?? ""}\n${h.stderr ?? ""}`;
87
+ const reinstall = reinstallCommand(version);
88
+ const missing = MISSING_MODULE.exec(said)?.[1];
89
+ if (missing)
90
+ return {
91
+ ...base,
92
+ state: "fail",
93
+ word: "broken install",
94
+ detail: `cannot load ${missing}; reinstall: ${reinstall}`,
95
+ fix: reinstall,
96
+ };
97
+ if (NO_BUNX.test(said))
98
+ return {
99
+ ...base,
100
+ state: "fail",
101
+ word: "missing",
102
+ detail: `no catherd ${version} on PATH and no bunx to fetch it; reinstall: ${reinstall}`,
103
+ fix: reinstall,
104
+ };
105
+ return {
106
+ ...base,
107
+ state: "fail",
108
+ word: "no answer",
109
+ detail: h.error ?? "tools/list has no status tool",
110
+ fix: "run catherd mcp to see why it does not start",
111
+ };
112
+ }
113
+
114
+ export function locksCheck(): Check {
115
+ const base = { id: "locks", label: "heavy-lock dir" };
116
+ try {
117
+ ensurePrivateDir(locksDir());
118
+ const probe = join(locksDir(), `.doctor-${process.pid}`);
119
+ writeFileSync(probe, "", { mode: PRIVATE_FILE });
120
+ rmSync(probe, { force: true });
121
+ return { ...base, state: "ok", word: "ready", detail: locksDir() };
122
+ } catch (e) {
123
+ return {
124
+ ...base,
125
+ state: "fail",
126
+ word: "not writable",
127
+ detail: `${locksDir()}: ${errText(e)}`,
128
+ fix: `chmod -R u+w ${locksDir()}`,
129
+ };
130
+ }
131
+ }
132
+
133
+ export function agentsCheck(): Check {
134
+ const base = { id: "agents", label: "Claude agents" };
135
+ const links = agentLinkState();
136
+ const broken = [...links.missing, ...links.stale];
137
+ if (broken.length)
138
+ return {
139
+ ...base,
140
+ state: "fail",
141
+ word: links.missing.length ? "missing" : "stale",
142
+ detail: broken.join(", "),
143
+ fix: `catherd profile use ${activeName()}`,
144
+ };
145
+ return links.ok.length
146
+ ? { ...base, state: "ok", word: "ready", detail: `${links.ok.length} linked` }
147
+ : { ...base, state: "skip", word: "none", detail: "no profile uses a native Claude rung" };
148
+ }
@@ -0,0 +1,109 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { envelope } from "../domain/notice.ts";
4
+ import { readSessionEnv, sessionFileFor } from "../infra/claude-session.ts";
5
+ import { claudeHome } from "../infra/paths.ts";
6
+ import { sendToInbox } from "../infra/peer-inbox.ts";
7
+ import type { Check } from "./doctor-checks.ts";
8
+
9
+ /**
10
+ * Spec §3.9 `doctor`'s `push` row: run from inside a Claude Code session (its Bash tool), send that session one
11
+ * `later` message and look for the enqueue line its transcript gets when the message is accepted
12
+ * (docs/research/2026-09-28-cross-session-messaging.md §7).
13
+ */
14
+
15
+ export type PushOutcome = "ok" | "no-session" | "held" | "unconfirmed" | "failed";
16
+
17
+ export interface PushProbe {
18
+ outcome: PushOutcome;
19
+ detail: string;
20
+ }
21
+
22
+ /** How long the probe looks for the enqueue line; tests shorten it. */
23
+ export const pushLimits = { waitMs: 5_000, pollMs: 100 };
24
+
25
+ /** The session's transcript, found by globbing `projects/*` (a git worktree logs under its main checkout's slug). */
26
+ function transcriptOf(sessionId: string): string | null {
27
+ const projects = join(claudeHome(), "projects");
28
+ if (!existsSync(projects)) return null;
29
+ for (const d of readdirSync(projects)) {
30
+ const f = join(projects, d, `${sessionId}.jsonl`);
31
+ if (existsSync(f)) return f;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ const enqueued = (file: string, nonce: string): boolean => {
37
+ try {
38
+ return readFileSync(file, "utf8")
39
+ .split("\n")
40
+ .some((l) => l.includes('"operation":"enqueue"') && l.includes(nonce));
41
+ } catch {
42
+ return false;
43
+ }
44
+ };
45
+
46
+ export async function probePush(env: Record<string, string | undefined> = process.env): Promise<PushProbe> {
47
+ const s = readSessionEnv(env);
48
+ if (!s?.socketPath)
49
+ return { outcome: "no-session", detail: "run catherd doctor from a Claude Code session to test push" };
50
+ const sessionId = sessionFileFor(s)?.sessionId ?? s.sessionId;
51
+ const nonce = crypto.randomUUID().slice(0, 8);
52
+ const r = await sendToInbox(s, envelope(`catherd doctor: push test ${nonce}, no action needed`), "later");
53
+ // a socket that is gone or refuses is a shell that outlived its session, not a protocol change
54
+ if (r.outcome === "no-session" || r.outcome === "refused")
55
+ return {
56
+ outcome: "no-session",
57
+ detail: `this session's inbox is not reachable (${r.reason ?? r.outcome}); run catherd doctor from a live Claude Code session to test push`,
58
+ };
59
+ if (r.outcome !== "sent")
60
+ return {
61
+ outcome: "failed",
62
+ detail: `catherd cannot notify this Claude Code version; peek still works (${r.reason ?? r.outcome})`,
63
+ };
64
+ const deadline = Date.now() + pushLimits.waitMs;
65
+ let file: string | null = null;
66
+ for (;;) {
67
+ file = sessionId ? transcriptOf(sessionId) : null;
68
+ if (file && enqueued(file, nonce))
69
+ return { outcome: "ok", detail: "a test message reached this session" };
70
+ if (Date.now() > deadline) break;
71
+ await Bun.sleep(pushLimits.pollMs);
72
+ }
73
+ return file
74
+ ? {
75
+ outcome: "held",
76
+ detail: `the test message was sent but not accepted within ${pushLimits.waitMs / 1000} s: a crossSessionInbound setting holds or refuses it`,
77
+ }
78
+ : {
79
+ outcome: "unconfirmed",
80
+ detail: "sent; no transcript of this session was found to confirm it arrived",
81
+ };
82
+ }
83
+
84
+ /** The probe as a `doctor` row. */
85
+ export function pushCheck(p: PushProbe): Check {
86
+ const base = { id: "push", label: "push to Claude Code", detail: p.detail };
87
+ switch (p.outcome) {
88
+ case "ok":
89
+ return { ...base, state: "ok", word: "ready" };
90
+ case "no-session":
91
+ return { ...base, state: "skip", word: "no session" };
92
+ case "held":
93
+ return {
94
+ ...base,
95
+ state: "warn",
96
+ word: "held",
97
+ fix: `set "crossSessionInbound": "accept" in ${join(claudeHome(), "settings.json")}, or remove a project setting that sets it to hold or refuse`,
98
+ };
99
+ case "unconfirmed":
100
+ return { ...base, state: "warn", word: "not confirmed" };
101
+ case "failed":
102
+ return {
103
+ ...base,
104
+ state: "fail",
105
+ word: "failed",
106
+ fix: "update catherd (and Claude Code); meanwhile peek(run) shows each role and each unread record",
107
+ };
108
+ }
109
+ }
@@ -0,0 +1,338 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { adapterFor } from "../adapters/registry.ts";
3
+ import "../adapters/all.ts";
4
+ import { BUILTIN_ROLES, type Profile } from "../domain/profile.ts";
5
+ import { DEFAULT_ACCESS, 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 { linkedProfiles } from "./agent-links.ts";
9
+ import { accessChecks } from "./doctor-access.ts";
10
+ import { backendChecks, usedBackends } from "./doctor-backends.ts";
11
+ import { type PushProbe, pushCheck } from "./doctor-push.ts";
12
+ import {
13
+ agentsCheck,
14
+ type Check,
15
+ errText,
16
+ fixOf,
17
+ guarded,
18
+ locksCheck,
19
+ mcpCheck,
20
+ pluginCheck,
21
+ } from "./doctor-checks.ts";
22
+ import { credentialsPath, jevKey, savedJevKey, testJevKey } from "./jev-service.ts";
23
+ import {
24
+ activeName,
25
+ enforcementOf,
26
+ getProfile,
27
+ profileExists,
28
+ readConfig,
29
+ readProjects,
30
+ validateNamed,
31
+ } from "./profile-store.ts";
32
+ import { listRuns } from "./run-store.ts";
33
+
34
+ export interface DoctorReport {
35
+ /** false when any check failed: `catherd doctor` exits 3 */
36
+ ready: boolean;
37
+ version: string;
38
+ checks: Check[];
39
+ }
40
+
41
+ export interface Handshake {
42
+ ok: boolean;
43
+ tools: string[];
44
+ error?: string;
45
+ /** the tail of what the server (or its launcher) wrote to stderr */
46
+ stderr?: string;
47
+ }
48
+
49
+ export interface DoctorDeps {
50
+ bunVersion: string;
51
+ /** the package version, which the Claude Code plugin must pin */
52
+ version: string;
53
+ /** starts the MCP server as the plugin does (its launcher) over stdio and asks it for tools/list */
54
+ handshake: () => Promise<Handshake>;
55
+ /** spec §3.9: sends this Claude Code session a test message; without it (the dashboard) there is no `push` row */
56
+ push?: () => Promise<PushProbe>;
57
+ jev?: JevTransport;
58
+ }
59
+
60
+ /**
61
+ * Spec §10.3: the readiness report. Reads and probes; it writes discovery and a lock probe itself, and the
62
+ * handshake starts `catherd mcp`, which reconciles runs as it starts. A check that throws becomes a `fail` row.
63
+ */
64
+ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
65
+ const checks: Check[] = [];
66
+ checks.push(
67
+ bunTooOld(d.bunVersion)
68
+ ? {
69
+ id: "bun",
70
+ label: "Bun",
71
+ state: "fail",
72
+ word: "too old",
73
+ detail: `${d.bunVersion}, needs ${MIN_BUN}`,
74
+ fix: "bun upgrade",
75
+ }
76
+ : { id: "bun", label: "Bun", state: "ok", word: "ready", detail: d.bunVersion },
77
+ );
78
+
79
+ let profiles: Profile[] = [];
80
+ let linked: string[] = [];
81
+ let active: Profile | null = null;
82
+ try {
83
+ readConfig();
84
+ readProjects();
85
+ active = getProfile(activeName());
86
+ linked = linkedProfiles();
87
+ checks.push({
88
+ id: "config",
89
+ label: "config",
90
+ state: "ok",
91
+ word: "ready",
92
+ detail: `active profile ${active.name}`,
93
+ });
94
+ } catch (e) {
95
+ checks.push({
96
+ id: "config",
97
+ label: "config",
98
+ state: "fail",
99
+ word: "invalid",
100
+ detail: errText(e),
101
+ fix: fixOf(e) ?? "catherd init",
102
+ });
103
+ }
104
+ // one unreadable linked profile must not hide the others' backends: its own row below reports it
105
+ profiles = linked.flatMap((n) => {
106
+ try {
107
+ return [getProfile(n)];
108
+ } catch {
109
+ return [];
110
+ }
111
+ });
112
+ // a repo bound to a profile whose file is gone: every command run in that repo fails to load it
113
+ try {
114
+ for (const [repo, name] of Object.entries(readProjects().bindings))
115
+ if (!profileExists(name))
116
+ checks.push({
117
+ id: `binding:${repo}`,
118
+ label: `binding ${repo}`,
119
+ state: "fail",
120
+ word: "missing",
121
+ detail: `bound to profile ${name}, which does not exist`,
122
+ fix: `cd ${repo} && catherd profile use --repo --clear`,
123
+ });
124
+ } catch {
125
+ // the config row above already reports an unreadable projects.json
126
+ }
127
+ // every linked profile: the active one (row `profile`) and each repo-bound one (row `profile:<name>`).
128
+ // Each fix names its profile: without one, the CLI acts on the profile of the repo doctor runs in.
129
+ const names = active ? [active.name, ...linked.filter((n) => n !== active?.name)] : [];
130
+ for (const name of names) {
131
+ const id = name === active?.name ? "profile" : `profile:${name}`;
132
+ const validate = `catherd profile validate ${name}`;
133
+ const named = (fix: string) =>
134
+ fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name} `);
135
+ checks.push(
136
+ guarded(id, `profile ${name}`, validate, () => {
137
+ const v = validateNamed(name);
138
+ const first = v.errors[0] ?? v.warnings[0];
139
+ return {
140
+ id,
141
+ label: `profile ${name}`,
142
+ state: v.errors.length ? "fail" : v.warnings.length ? "warn" : "ok",
143
+ word: v.errors.length ? "invalid" : v.warnings.length ? "warning" : "ready",
144
+ detail: first
145
+ ? `${first.path}: ${first.message}${v.errors.length + v.warnings.length > 1 ? " (and more)" : ""}`
146
+ : "valid",
147
+ ...(first ? { fix: first.fix ? named(first.fix) : validate } : {}),
148
+ };
149
+ }),
150
+ );
151
+ }
152
+
153
+ const used = usedBackends(profiles);
154
+ const installed = new Set<string>();
155
+ checks.push(...(await backendChecks(used, profiles, installed)));
156
+
157
+ if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
158
+ checks.push({
159
+ id: "jev",
160
+ label: "Jev",
161
+ state: "skip",
162
+ word: "off",
163
+ detail: profiles.length > 1 ? "off in every linked profile" : "off in the profile",
164
+ });
165
+ else {
166
+ const key = jevKey();
167
+ if (!key)
168
+ checks.push({
169
+ id: "jev",
170
+ label: "Jev",
171
+ state: "warn",
172
+ word: "no key",
173
+ detail: "optional: routing uses each lane's Kind and Difficulty instead",
174
+ fix: "catherd init, or export TYPESAFE_API_KEY=<key>",
175
+ });
176
+ else {
177
+ const ok = await testJevKey(key, d.jev).catch(() => false);
178
+ checks.push(
179
+ ok
180
+ ? { id: "jev", label: "Jev", state: "ok", word: "ready", detail: "the key answers" }
181
+ : {
182
+ id: "jev",
183
+ label: "Jev",
184
+ state: "warn",
185
+ word: "no answer",
186
+ detail: "the key did not answer; routing falls back to the lanes",
187
+ fix: "catherd init to enter a new key",
188
+ },
189
+ );
190
+ }
191
+ }
192
+
193
+ checks.push(pluginCheck(d.version));
194
+
195
+ checks.push(
196
+ active
197
+ ? guarded("agents", "Claude agents", `catherd profile use ${activeName()}`, agentsCheck)
198
+ : {
199
+ id: "agents",
200
+ label: "Claude agents",
201
+ state: "skip",
202
+ word: "not checked",
203
+ detail: "the config cannot be read",
204
+ },
205
+ );
206
+
207
+ const h = await d
208
+ .handshake()
209
+ .catch((e: unknown): Handshake => ({ ok: false, tools: [], error: errText(e) }));
210
+ checks.push(mcpCheck(h, d.version));
211
+
212
+ if (d.push)
213
+ checks.push(
214
+ pushCheck(await d.push().catch((e: unknown): PushProbe => ({ outcome: "failed", detail: errText(e) }))),
215
+ );
216
+
217
+ checks.push(locksCheck());
218
+ // spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
219
+ checks.push(...(await accessChecks(profiles, installed)));
220
+
221
+ // spec 1.1 §13: what the shipped defaults do is info; a warning only for what a profile changed
222
+ const full = { changed: [] as string[], shipped: [] as string[] };
223
+ const advisory = { changed: [] as string[], shipped: [] as string[] };
224
+ const backendOf = (r: string) => r.slice(0, r.indexOf(":"));
225
+ for (const p of profiles)
226
+ for (const role of ROLES) {
227
+ const rc = p.roles[role];
228
+ if (!rc.enabled) continue;
229
+ const asShipped = rc.access === DEFAULT_ACCESS[role];
230
+ if (rc.access === "full") (asShipped ? full.shipped : full.changed).push(`${role} (${p.name})`);
231
+ const soft = [
232
+ ...new Set(rc.rungs.filter((r) => enforcementOf(r, rc.access) === "advisory").map(backendOf)),
233
+ ];
234
+ const shippedOn = new Set(BUILTIN_ROLES[role].rungs.map(backendOf));
235
+ const ours = soft.filter((b) => asShipped && shippedOn.has(b));
236
+ const theirs = soft.filter((b) => !ours.includes(b));
237
+ if (ours.length) advisory.shipped.push(`${role} on ${ours.join(", ")} (${p.name})`);
238
+ if (theirs.length) advisory.changed.push(`${role} on ${theirs.join(", ")} (${p.name})`);
239
+ }
240
+ const accessRow = (
241
+ id: string,
242
+ label: string,
243
+ rows: { changed: string[]; shipped: string[] },
244
+ what: string,
245
+ ): Check | null => {
246
+ if (!rows.changed.length && !rows.shipped.length) return null;
247
+ const shipped = rows.shipped.join(", ");
248
+ return rows.changed.length
249
+ ? {
250
+ id,
251
+ label,
252
+ state: "warn",
253
+ word: "warning",
254
+ detail: `${what}: ${rows.changed.join(", ")}${shipped ? `; as shipped: ${shipped}` : ""}`,
255
+ }
256
+ : { id, label, state: "info", word: "default", detail: `${what}: ${shipped}` };
257
+ };
258
+ for (const row of [
259
+ accessRow("access:full", "full access", full, "no sandbox for"),
260
+ accessRow("access:advisory", "advisory access", advisory, "the backend asks but cannot force"),
261
+ ])
262
+ if (row) checks.push(row);
263
+ for (const id of used.keys()) {
264
+ const note = adapterFor(id)?.isolationNote;
265
+ if (note)
266
+ checks.push({
267
+ id: `isolation:${id}`,
268
+ label: `${id} isolation`,
269
+ state: "warn",
270
+ word: "weak",
271
+ detail: note,
272
+ });
273
+ }
274
+
275
+ const creds = credentialsPath();
276
+ if (existsSync(creds)) {
277
+ const mode = statSync(creds).mode & 0o777;
278
+ // Jev reads an unreadable file as no key (it is optional): here is where the user learns why
279
+ const unreadable = savedJevKey().problem;
280
+ checks.push(
281
+ mode & 0o077
282
+ ? {
283
+ id: "credentials",
284
+ label: "credentials.json",
285
+ state: "fail",
286
+ word: "readable by others",
287
+ detail: `mode ${mode.toString(8)}`,
288
+ fix: `chmod 600 ${creds}`,
289
+ }
290
+ : unreadable
291
+ ? {
292
+ id: "credentials",
293
+ label: "credentials.json",
294
+ state: "warn",
295
+ word: "unreadable",
296
+ detail: unreadable.message,
297
+ fix: unreadable.fix ?? `fix or delete ${creds}, then catherd init`,
298
+ }
299
+ : { id: "credentials", label: "credentials.json", state: "ok", word: "ready", detail: "mode 600" },
300
+ );
301
+ }
302
+
303
+ // the plugin's launcher needs bunx only when no global catherd is installed
304
+ const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH ?? "" });
305
+ if (!onPath("bunx") && !onPath("catherd"))
306
+ checks.push({
307
+ id: "bunx",
308
+ label: "bunx",
309
+ state: "warn",
310
+ word: "missing",
311
+ detail: "the plugin's MCP launcher runs bunx when no global catherd is installed",
312
+ fix: "put Bun's bin folder (~/.bun/bin) on PATH",
313
+ });
314
+ let corrupt: ReturnType<typeof listRuns>["corrupt"] = [];
315
+ try {
316
+ corrupt = listRuns().corrupt;
317
+ } catch (e) {
318
+ checks.push({
319
+ id: "runs",
320
+ label: "run data",
321
+ state: "fail",
322
+ word: "unreadable",
323
+ detail: errText(e),
324
+ fix: fixOf(e) ?? "catherd runs list --verbose",
325
+ });
326
+ }
327
+ if (corrupt.length)
328
+ checks.push({
329
+ id: "runs",
330
+ label: "run data",
331
+ state: "warn",
332
+ word: "unreadable",
333
+ detail: corrupt.map((c) => `${c.id}: ${c.reason}`).join("; "),
334
+ fix: `fix or delete ${corrupt[0]?.dir}`,
335
+ });
336
+
337
+ return { ready: !checks.some((c) => c.state === "fail"), version: d.version, checks };
338
+ }