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,49 @@
1
+ const PARKED = /^parked: [^;]*; /;
2
+
3
+ /**
4
+ * Spec 1.1 §8: `next` with the parked milestones in front of it, once ("parked: M2 waits on the owner;
5
+ * <next>"), whoever wrote the step: a milestone stays parked in state.md until it is answered.
6
+ */
7
+ export function withParked(next: string, parked: string[]): string {
8
+ const base = next.replace(PARKED, "");
9
+ if (parked.length === 0) return base;
10
+ return `parked: ${parked.join(", ")} ${parked.length > 1 ? "wait" : "waits"} on the owner; ${base}`;
11
+ }
12
+
13
+ export interface StateView {
14
+ title: string;
15
+ head: string;
16
+ dirty: { path: string; owner: string | null }[];
17
+ running: { name: string; rung: string; thread: string | null; since: string; brief: string }[];
18
+ lastCheck: string | null;
19
+ next: string;
20
+ /** spec 1.1 §10: the protocol's next step, derived from the run's files; always the last line */
21
+ protocol: string;
22
+ }
23
+
24
+ /** state.md: enough for a fresh session to resume from alone; the protocol's next step is always the last line. */
25
+ export function renderState(s: StateView): string {
26
+ const waiting = s.running.map((r) => r.name);
27
+ return [
28
+ `# ${s.title}`,
29
+ "",
30
+ `HEAD ${s.head}`,
31
+ "",
32
+ "Dirty:",
33
+ ...(s.dirty.length ? s.dirty.map((d) => `- ${d.path}${d.owner ? ` (${d.owner})` : ""}`) : ["- none"]),
34
+ "",
35
+ "Running:",
36
+ ...(s.running.length
37
+ ? s.running.map(
38
+ (r) => `- ${r.name} · ${r.rung} · thread ${r.thread ?? "new"} · since ${r.since} · ${r.brief}`,
39
+ )
40
+ : ["- none"]),
41
+ "",
42
+ `Last check: ${s.lastCheck ?? "none"}`,
43
+ "",
44
+ // 1.1: no tool waits; each running role's record arrives as a catherd message
45
+ `Next: ${waiting.length ? `running ${waiting.join(", ")} (results arrive as catherd messages; peek to check); then ${s.next}` : s.next}`,
46
+ `Protocol next: ${s.protocol}`,
47
+ "",
48
+ ].join("\n");
49
+ }
@@ -0,0 +1,18 @@
1
+ /** Lower-case letters and digits, every other run of characters one `-`, no `-` at either end. */
2
+ export const slug = (s: string): string =>
3
+ s
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, "-")
6
+ .replace(/^-|-$/g, "");
7
+
8
+ /** The middle value, or the mean of the two middle values for an even count; null when there are none. */
9
+ export function median(xs: readonly number[]): number | null {
10
+ if (xs.length === 0) return null;
11
+ const s = [...xs].sort((a, b) => a - b);
12
+ const m = s.length >> 1;
13
+ return s.length % 2 ? (s[m] as number) : ((s[m - 1] as number) + (s[m] as number)) / 2;
14
+ }
15
+
16
+ /** A plain object: not null and not an array. */
17
+ export const isPlain = (v: unknown): v is Record<string, unknown> =>
18
+ typeof v === "object" && v !== null && !Array.isArray(v);
@@ -0,0 +1,57 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { defineCommand } from "citty";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { assetPath } from "../infra/assets.ts";
6
+ import { CAPTURE_BACKENDS, type Captured, captureFixtures } from "../services/capture.ts";
7
+ import { exitCodeOf, mark, printError } from "./cli-kit.ts";
8
+
9
+ /**
10
+ * Spec §11.8: fixtures go to the catherd checkout's test/fixtures/adapters, next to the contract fixtures
11
+ * they feed, wherever the command runs; null for an installed package, which has no tests to feed.
12
+ */
13
+ export function defaultOut(): string | null {
14
+ const dir = assetPath("test/fixtures/adapters");
15
+ return existsSync(dir) ? dir : null;
16
+ }
17
+
18
+ export function formatCaptured(r: Captured): string {
19
+ return r.status === "captured"
20
+ ? `${mark("ok")} ${r.backend} ${r.cliVersion} ${r.name} → ${r.dir}/${r.name}.jsonl (exit ${r.exitCode})`
21
+ : `${mark("skip")} ${r.backend} ${r.name} skipped: ${r.reason}`;
22
+ }
23
+
24
+ export const captureFixturesCommand = defineCommand({
25
+ meta: {
26
+ name: "capture-fixtures",
27
+ description: "Record one cheap real run per ready backend as sanitized test fixtures",
28
+ },
29
+ args: {
30
+ backend: { type: "string", description: `only this backend (${CAPTURE_BACKENDS.join(", ")})` },
31
+ out: { type: "string", description: "fixture root (default: this checkout's test/fixtures/adapters)" },
32
+ },
33
+ async run({ args }) {
34
+ const refuse = (message: string, fix: string): void => {
35
+ const e = new CatherdError("E_INPUT_INVALID", message, { fix });
36
+ printError(e);
37
+ process.exitCode = exitCodeOf(e);
38
+ };
39
+ const out = args.out ? resolve(args.out) : defaultOut();
40
+ if (!out)
41
+ return refuse(
42
+ "no --out, and this catherd is not a source checkout",
43
+ "catherd capture-fixtures --out <dir>",
44
+ );
45
+ if (args.backend && !CAPTURE_BACKENDS.includes(args.backend))
46
+ return refuse(
47
+ `no capture cases for backend "${args.backend}"`,
48
+ `catherd capture-fixtures --backend ${CAPTURE_BACKENDS.join("|")}`,
49
+ );
50
+ const results = await captureFixtures({
51
+ outDir: out,
52
+ backends: args.backend ? [args.backend] : undefined,
53
+ });
54
+ for (const r of results) console.log(formatCaptured(r));
55
+ process.exitCode = results.some((r) => r.status === "captured") ? 0 : 1;
56
+ },
57
+ });
@@ -0,0 +1,106 @@
1
+ import { defineCommand } from "citty";
2
+ import { CatherdError, isCatherdError } from "../domain/errors.ts";
3
+ import { ROLES, type Role } from "../domain/roles.ts";
4
+ import { gitToplevel } from "../infra/git.ts";
5
+ import {
6
+ type CatalogModel,
7
+ catalogQuery,
8
+ type Refreshed,
9
+ refreshDiscovery,
10
+ saveTreatLike,
11
+ } from "../services/catalog-service.ts";
12
+ import { exitCodeOf, JSON_ARG, mark, printError } from "./cli-kit.ts";
13
+
14
+ /** One backend's refresh: its model count, else why it listed none, then its fix on a line of its own. */
15
+ export function formatRefreshed(r: Refreshed, plain = false): string {
16
+ if (!r.error) return `${mark("ok", plain)} ${r.backend}: ${r.models} models`;
17
+ return [
18
+ `${mark("warn", plain)} ${r.backend}: ${r.error}${r.fetchedAt ? ` (last listed ${r.fetchedAt})` : ""}`,
19
+ ...(r.fix ? [` fix: ${r.fix}`] : []),
20
+ ].join("\n");
21
+ }
22
+
23
+ export function formatModel(m: CatalogModel): string {
24
+ const scored = m.rungs.filter((r) => r.enabled).length;
25
+ const listed = m.listed === false ? " not offered by this account" : "";
26
+ return `${m.backend}:${m.model} ${scored}/${m.rungs.length} rungs scored roles ${m.roles.join(",") || "none"}${listed}`;
27
+ }
28
+
29
+ /** The repository the command runs in, for per-repository listings; none outside one (global). */
30
+ const hereRepo = async (): Promise<string | undefined> => (await gitToplevel(process.cwd())) ?? undefined;
31
+
32
+ function fail(e: unknown): void {
33
+ if (!isCatherdError(e)) throw e;
34
+ printError(e);
35
+ process.exitCode = exitCodeOf(e);
36
+ }
37
+
38
+ const refresh = defineCommand({
39
+ meta: { name: "refresh", description: "List every backend's models now" },
40
+ args: JSON_ARG,
41
+ async run({ args }) {
42
+ const r = await refreshDiscovery({ repo: await hereRepo() });
43
+ if (args.json) console.log(JSON.stringify(r, null, 2));
44
+ else for (const x of r) console.log(formatRefreshed(x));
45
+ process.exitCode = r.some((x) => !x.error) ? 0 : 1;
46
+ },
47
+ });
48
+
49
+ const list = defineCommand({
50
+ meta: { name: "list", description: "The models catherd can place, with their scored rungs" },
51
+ args: {
52
+ backend: { type: "string", description: "only this backend or billing key" },
53
+ role: { type: "string", description: `only models for this role (${ROLES.join(", ")})` },
54
+ text: { type: "string", description: "only ids containing this" },
55
+ scored: { type: "boolean", description: "only models with a scored rung" },
56
+ ...JSON_ARG,
57
+ },
58
+ async run({ args }) {
59
+ if (args.role && !(ROLES as readonly string[]).includes(args.role))
60
+ return fail(
61
+ new CatherdError("E_INPUT_INVALID", `no role "${args.role}"`, {
62
+ fix: `pass --role ${ROLES.join("|")}`,
63
+ }),
64
+ );
65
+ try {
66
+ const r = catalogQuery({
67
+ backend: args.backend,
68
+ role: args.role as Role | undefined,
69
+ text: args.text,
70
+ scoredOnly: args.scored === true,
71
+ limit: 10_000,
72
+ repo: await hereRepo(),
73
+ });
74
+ if (args.json) console.log(JSON.stringify(r, null, 2));
75
+ else for (const m of r.models) console.log(formatModel(m));
76
+ } catch (e) {
77
+ fail(e);
78
+ }
79
+ },
80
+ });
81
+
82
+ const treatLike = defineCommand({
83
+ meta: { name: "treat-like", description: "Score an unscored rung as a scored one" },
84
+ args: {
85
+ rung: {
86
+ type: "positional",
87
+ required: true,
88
+ description: "the unscored rung, backend:model#effort or model#effort",
89
+ },
90
+ like: { type: "positional", required: true, description: "the scored rung whose scores it borrows" },
91
+ },
92
+ async run({ args }) {
93
+ try {
94
+ const r = await saveTreatLike(args.rung, args.like);
95
+ console.log(`✓ ${r.rung} is treated like ${r.like}`);
96
+ } catch (e) {
97
+ fail(e);
98
+ }
99
+ },
100
+ });
101
+
102
+ /** Spec §8: `catherd catalog refresh|list|treat-like <rung> <like>`. */
103
+ export const catalogCommand = defineCommand({
104
+ meta: { name: "catalog", description: "The model catalog" },
105
+ subCommands: { refresh, list, "treat-like": treatLike },
106
+ });
@@ -0,0 +1,36 @@
1
+ import { type CatherdError, isCatherdError } from "../domain/errors.ts";
2
+ import { RUN_NOT_FOUND_FIX } from "../services/run-store.ts";
3
+ import { glyph, type State } from "./glyphs.ts";
4
+
5
+ /** Spec §8: 0 ok, 1 error, 2 usage, 3 not ready (doctor), 130 interrupted. */
6
+ export const EXIT = { ok: 0, error: 1, usage: 2, notReady: 3, interrupted: 130 } as const;
7
+
8
+ // oxlint-disable-next-line no-control-regex -- citty colours its messages; the one-line error must not
9
+ const ANSI = /\x1b\[[0-9;]*m/g;
10
+
11
+ /** `text` without colour codes: for output that is piped, or under NO_COLOR. */
12
+ export const stripAnsi = (text: string): string => text.replace(ANSI, "");
13
+
14
+ /** A service's fix worded for the MCP tools, as the CLI says it. */
15
+ const CLI_FIX: Record<string, string> = { [RUN_NOT_FOUND_FIX]: "catherd runs list" };
16
+
17
+ /** Spec §8: one line `error E_CODE: message`, then `fix: …` when there is one. */
18
+ export function printError(e: Pick<CatherdError, "code" | "message" | "fix">): void {
19
+ console.error(`error ${e.code}: ${stripAnsi(e.message).split("\n").join(" ")}`);
20
+ if (e.fix) console.error(`fix: ${CLI_FIX[e.fix] ?? e.fix}`);
21
+ }
22
+
23
+ /** Bad input from the command line is a usage error; any other catherd error is an error. */
24
+ export const exitCodeOf = (e: unknown): number =>
25
+ isCatherdError(e) && e.code === "E_INPUT_INVALID" ? EXIT.usage : EXIT.error;
26
+
27
+ export const printJson = (v: unknown): void => console.log(JSON.stringify(v, null, 2));
28
+
29
+ /** The `--json` flag every read command takes. */
30
+ export const JSON_ARG = { json: { type: "boolean", description: "print JSON" } } as const;
31
+
32
+ /**
33
+ * The glyph for a state, as in `✓ ready`: the dashboard's own (spec §9.3), ASCII with `--plain` only;
34
+ * NO_COLOR drops colour, never glyphs. The caller adds the word.
35
+ */
36
+ export const mark = (state: State, plain = false): string => glyph(state, plain);
@@ -0,0 +1,17 @@
1
+ import { readSessionEnv } from "../infra/claude-session.ts";
2
+ import { VERSION } from "../infra/version.ts";
3
+ import type { Deps } from "../services/ports.ts";
4
+ import { profileService } from "../services/profile-service.ts";
5
+ import { routingService } from "../services/routing-service.ts";
6
+
7
+ /** The services every entry point (MCP server, CLI commands) runs on. */
8
+ export function defaultDeps(): Deps {
9
+ return {
10
+ profiles: profileService(),
11
+ routing: routingService(),
12
+ version: VERSION,
13
+ pollMs: 250,
14
+ session: readSessionEnv(process.env),
15
+ now: Date.now,
16
+ };
17
+ }
@@ -0,0 +1,51 @@
1
+ import { defineCommand } from "citty";
2
+ import { VERSION } from "../infra/version.ts";
3
+ import { type DoctorReport, doctor } from "../services/doctor.ts";
4
+ import { probePush } from "../services/doctor-push.ts";
5
+ import type { Check } from "../services/doctor-checks.ts";
6
+ import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
7
+ import { mcpHandshake } from "./mcp/handshake.ts";
8
+
9
+ /**
10
+ * One row per check: `✓ ready Bun — 1.4.2`, then the full fix on its own line; a fix of several lines
11
+ * (one command each) keeps them under the first.
12
+ */
13
+ export function formatCheck(c: Check, plain = false): string[] {
14
+ return [
15
+ `${mark(c.state, plain)} ${c.word.padEnd(18)} ${c.label}${c.detail ? ` — ${c.detail}` : ""}`,
16
+ ...(c.fix ? c.fix.split("\n").map((l, i) => `${i ? " " : " fix: "}${l}`) : []),
17
+ ];
18
+ }
19
+
20
+ export function formatReport(r: DoctorReport, plain = false): string[] {
21
+ return [
22
+ ...r.checks.flatMap((c) => formatCheck(c, plain)),
23
+ "",
24
+ r.ready
25
+ ? `${mark("ok", plain)} ready`
26
+ : `${mark("fail", plain)} not ready: fix the rows marked ${mark("fail", plain)} above`,
27
+ ];
28
+ }
29
+
30
+ /** Spec §8, §10.3: `catherd doctor [--json]`, exit 3 when not ready. */
31
+ export const doctorCommand = defineCommand({
32
+ meta: {
33
+ name: "doctor",
34
+ description: "Readiness report: Bun, backends, Jev, the plugin, agents, the MCP server, locks",
35
+ },
36
+ args: {
37
+ ...JSON_ARG,
38
+ plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
39
+ },
40
+ async run({ args }) {
41
+ const r = await doctor({
42
+ bunVersion: Bun.version,
43
+ version: VERSION,
44
+ handshake: () => mcpHandshake(),
45
+ push: () => probePush(),
46
+ });
47
+ if (args.json) printJson(r);
48
+ else for (const l of formatReport(r, args.plain === true)) console.log(l);
49
+ process.exitCode = r.ready ? EXIT.ok : EXIT.notReady;
50
+ },
51
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Spec §9.3's glyphs, each with its ASCII twin for `--plain`: the dashboard and every CLI command draw
3
+ * state with these. Free of OpenTUI, so `mcp`, `lock` and `_supervise` can load it (spec §3.1).
4
+ */
5
+ const GLYPHS = {
6
+ ok: ["✓", "+"],
7
+ warn: ["!", "!"],
8
+ fail: ["✗", "x"],
9
+ skip: ["-", "-"],
10
+ info: ["i", "i"],
11
+ live: ["●", "*"],
12
+ waiting: ["◌", "."],
13
+ on: ["[x]", "[x]"],
14
+ off: ["[ ]", "[ ]"],
15
+ open: ["▾", "v"],
16
+ shut: ["▸", ">"],
17
+ arrow: ["→", "->"],
18
+ dot: ["·", "-"],
19
+ more: ["…", "..."],
20
+ up: ["↑", "^"],
21
+ down: ["↓", "v"],
22
+ bar: ["┃", "|"],
23
+ rule: ["─", "-"],
24
+ current: ["●", "*"],
25
+ full: ["█", "#"],
26
+ empty: ["░", "."],
27
+ } as const;
28
+ export type Glyph = keyof typeof GLYPHS;
29
+ export const GLYPH_NAMES = Object.keys(GLYPHS) as Glyph[];
30
+
31
+ export const glyph = (g: Glyph, plain: boolean): string => GLYPHS[g][plain ? 1 : 0];
32
+
33
+ /** The words state always travels with (spec §9.3: green/amber/red only for state, always with a word). */
34
+ export type State = "ok" | "warn" | "fail" | "skip" | "info";
@@ -0,0 +1,182 @@
1
+ import { defineCommand } from "citty";
2
+ import { assertProfileName } from "../domain/profile.ts";
3
+ import { errorMessage, isCatherdError } from "../domain/errors.ts";
4
+ import { configDir } from "../infra/paths.ts";
5
+ import { VERSION } from "../infra/version.ts";
6
+ import { doctor } from "../services/doctor.ts";
7
+ import { jevKey, saveJevKey, testJevKey } from "../services/jev-service.ts";
8
+ import { reinstallCommand } from "../services/doctor-checks.ts";
9
+ import { ensureGlobal, type GlobalInstallDeps, realGlobalInstall } from "../services/global-install.ts";
10
+ import { hasProfileFile, type InitResult, initSetup, moveLegacy } from "../services/setup.ts";
11
+ import { formatRefreshed } from "./catalog-command.ts";
12
+ import { mark as markOf } from "./cli-kit.ts";
13
+ import { formatReport } from "./doctor-command.ts";
14
+ import { mcpHandshake } from "./mcp/handshake.ts";
15
+ import type { State } from "./glyphs.ts";
16
+ import { mascot } from "./tui/theme.ts";
17
+ import { type Prompter, prompter } from "./prompt.ts";
18
+
19
+ /** What `init` prints last (spec §9.1): the plugin install commands, to paste into a terminal. */
20
+ export const PLUGIN_STEPS = [
21
+ "Install the Claude Code plugin:",
22
+ " claude plugin marketplace add 47vigen/catherd",
23
+ " claude plugin install catherd@catherd",
24
+ "Then start a new Claude Code session, so it loads the plugin and the catherd agents.",
25
+ ];
26
+
27
+ /** Spec §9.3: the full mascot greets `init` (and the TUI's empty states), nowhere else. */
28
+ export function welcomeLines(version: string): string[] {
29
+ const [ears, face, paws] = mascot("good");
30
+ return [ears, `${face} catherd ${version}`, `${paws} herds your coding agents`, ""];
31
+ }
32
+
33
+ /**
34
+ * The Jev key step. It never stops `init` (Ruling 11): a key that cannot be saved, say because
35
+ * credentials.json is unparsable, is reported with its fix and setup goes on.
36
+ */
37
+ export async function jevStep(
38
+ ask: Prompter | null,
39
+ d: { testJevKey?: (key: string) => Promise<boolean>; saveJevKey?: (key: string) => void } = {},
40
+ plain = false,
41
+ ): Promise<void> {
42
+ const mark = (state: State) => markOf(state, plain);
43
+ if (process.env.TYPESAFE_API_KEY?.trim()) {
44
+ ask?.skip?.();
45
+ return console.log(`${mark("ok")} Jev: using TYPESAFE_API_KEY`);
46
+ }
47
+ if (jevKey()) {
48
+ ask?.skip?.();
49
+ return console.log(`${mark("ok")} Jev: using the saved key`);
50
+ }
51
+ const key = ask ? await ask.secret("TypeSafe API key for Jev (optional; Enter skips): ") : "";
52
+ if (!key)
53
+ return console.log(
54
+ `- Jev: no key; routing uses each lane's Kind and Difficulty (add one later with catherd init)`,
55
+ );
56
+ if (!(await (d.testJevKey ?? testJevKey)(key).catch(() => false)))
57
+ return console.log(`${mark("warn")} Jev: the key did not answer, so it was not saved`);
58
+ try {
59
+ (d.saveJevKey ?? saveJevKey)(key);
60
+ console.log(`${mark("ok")} Jev: the key answers; saved with mode 600`);
61
+ } catch (e) {
62
+ const message = errorMessage(e).split("\n").join(" ");
63
+ console.log(`${mark("warn")} Jev: could not save the key: ${message}`);
64
+ if (isCatherdError(e) && e.fix) console.log(` fix: ${e.fix}`);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Spec 1.1 §12: installs the global CLI at this version (the plugin's launcher then needs no bunx), and
70
+ * says "installing catherd…" before the resolve. It never stops `init`: a failure is a `!` line and its fix.
71
+ */
72
+ export async function globalStep(
73
+ version: string,
74
+ o: { skip: boolean; deps?: GlobalInstallDeps; plain?: boolean },
75
+ ): Promise<void> {
76
+ const mark = (state: State) => markOf(state, o.plain === true);
77
+ if (o.skip)
78
+ return console.log(`- catherd: not installed globally (--no-global); the plugin starts it with bunx`);
79
+ const r = await ensureGlobal(version, o.deps ?? realGlobalInstall, () =>
80
+ console.log(`installing catherd… (${reinstallCommand(version)})`),
81
+ );
82
+ if (r.state === "current") return console.log(`${mark("ok")} catherd ${version} is installed globally`);
83
+ if (r.state === "installed")
84
+ return console.log(
85
+ `${mark("ok")} catherd ${version} installed globally; the plugin starts it without bunx`,
86
+ );
87
+ if (r.state === "shadowed") {
88
+ const found = r.onPath === null ? "no catherd is on PATH" : `the catherd first on PATH is ${r.onPath}`;
89
+ console.log(
90
+ `${mark("warn")} catherd ${version} installed globally, but ${found}, so the plugin starts it with bunx`,
91
+ );
92
+ return console.log(
93
+ ` fix: put bun's global bin folder (bun pm bin -g) first on PATH, then run catherd init again`,
94
+ );
95
+ }
96
+ console.log(`${mark("warn")} could not install catherd globally: ${r.reason}`);
97
+ console.log(` fix: ${reinstallCommand(version)}`);
98
+ }
99
+
100
+ /** What happened to the profile: written, kept, or (when the defaults do not validate here) why not. */
101
+ export function profileLines(r: InitResult, plain = false): string[] {
102
+ const mark = (state: State) => markOf(state, plain);
103
+ const out: string[] = [];
104
+ if (r.errors.length) {
105
+ out.push(
106
+ `${mark("warn")} profile ${r.profile}: the default profile does not validate here, so it was not written`,
107
+ );
108
+ for (const e of r.errors) {
109
+ out.push(`${mark("warn")} ${e.path}: ${e.message}`);
110
+ if (e.fix) out.push(` fix: ${e.fix}`);
111
+ }
112
+ }
113
+ if (!r.active)
114
+ out.push(`${mark("warn")} no profile was made active: fix the rows above, then run catherd init again`);
115
+ else
116
+ out.push(
117
+ `${mark("ok")} profile ${r.profile} ${r.created ? "written from the defaults" : "kept as it was"}, and active`,
118
+ );
119
+ return out;
120
+ }
121
+
122
+ /** Spec §8 `catherd init [--no-input]`: first-run setup without the TUI (plan 6 adds the TUI wizard). */
123
+ export const initCommand = defineCommand({
124
+ meta: {
125
+ name: "init",
126
+ description:
127
+ "First run: the global catherd command, the Jev key, the default profile, its agents, and a readiness report. Piped, it reads the answers from stdin one per line, a line per question even when this machine skips it (the Jev key, the profile, whether to replace it), and waits for stdin to close; --no-input asks nothing",
128
+ },
129
+ args: {
130
+ // citty reads --no-input as input: false, whatever the flag is named; naming it no-input shows it as is
131
+ "no-input": { type: "boolean", description: "ask nothing: keep what exists, else write the defaults" },
132
+ // read as global: false, like no-input above
133
+ "no-global": {
134
+ type: "boolean",
135
+ description: "do not install the global catherd command (bun add -g catherd-cli@<this version>)",
136
+ },
137
+ profile: { type: "string", description: "the profile to set up and make active (default: default)" },
138
+ plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
139
+ },
140
+ async run({ args }) {
141
+ // a bad --profile is refused before any question is asked
142
+ if (args.profile !== undefined) assertProfileName(args.profile);
143
+ const ask = (args as { input?: boolean }).input === false ? null : await prompter();
144
+ const plain = args.plain === true;
145
+ const mark = (state: State) => markOf(state, plain);
146
+ try {
147
+ if (process.stdout.isTTY) for (const line of welcomeLines(VERSION)) console.log(line);
148
+ console.log(`catherd ${VERSION}: setting up in ${configDir()}`);
149
+ await globalStep(VERSION, { skip: (args as { global?: boolean }).global === false, plain });
150
+ await jevStep(ask, {}, plain);
151
+ if (args.profile !== undefined) ask?.skip?.();
152
+ const name = assertProfileName(
153
+ args.profile ?? (ask ? (await ask.ask("Profile to set up [default]: ")) || "default" : "default"),
154
+ );
155
+ // 0.x files go first, so a legacy profile about to be moved aside is never asked about
156
+ const moved = moveLegacy();
157
+ const exists = hasProfileFile(name);
158
+ if (!exists) ask?.skip?.();
159
+ const overwrite =
160
+ ask !== null &&
161
+ exists &&
162
+ /^y(es)?$/i.test(await ask.ask(`Replace profile ${name} with the default profile? [y/N] `));
163
+ const r = await initSetup({ profile: name, overwrite });
164
+ for (const f of [...moved, ...r.moved]) console.log(`${mark("ok")} moved a 0.x file aside: ${f}`);
165
+ for (const l of profileLines(r, plain)) console.log(l);
166
+ if (r.synced?.linked.length)
167
+ console.log(`${mark("ok")} Claude agents linked: ${r.synced.linked.join(", ")}`);
168
+ for (const x of r.refreshed) console.log(formatRefreshed(x, plain));
169
+ console.log("");
170
+ const report = await doctor({
171
+ bunVersion: Bun.version,
172
+ version: VERSION,
173
+ handshake: () => mcpHandshake(),
174
+ });
175
+ for (const l of formatReport(report, plain)) console.log(l);
176
+ console.log("");
177
+ for (const l of PLUGIN_STEPS) console.log(l);
178
+ } finally {
179
+ ask?.close();
180
+ }
181
+ },
182
+ });