catherd-cli 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -30
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +227 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +316 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +58 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -26
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +33 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +133 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +196 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -0,0 +1,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
|
+
}
|