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.
- package/README.md +141 -33
- 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 +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -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 +282 -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 +333 -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 +60 -0
- package/src/domain/failover.ts +119 -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 +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- 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 +49 -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 +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -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 +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -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 +160 -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 +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -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 +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -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 +197 -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/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -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/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -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 +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -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
|
@@ -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
|
+
}
|