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,88 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4
+ import { errorMessage } from "../../domain/errors.ts";
5
+ import { log } from "../../infra/log.ts";
6
+ import { startNotifier } from "../../services/notifier.ts";
7
+ import type { Deps } from "../../services/ports.ts";
8
+ import { reconcileAll } from "../../services/reconcile.ts";
9
+ import { defaultDeps } from "../deps.ts";
10
+ import { registerDispatchTools } from "./dispatch-tools.ts";
11
+ import { registerLaneTools } from "./lane-tools.ts";
12
+ import { registerProtocolTools } from "./protocol-tools.ts";
13
+ import { sdkToolError, toolOf } from "./result.ts";
14
+ import { registerRunTools } from "./run-tools.ts";
15
+ import { registerSetupTools } from "./setup-tools.ts";
16
+
17
+ type Handler = (...args: unknown[]) => CallToolResult | Promise<CallToolResult>;
18
+
19
+ /** Spec §10.2: every tool call is logged with its duration and outcome (the error code), never its input. */
20
+ function logToolCalls(server: McpServer): void {
21
+ const register = server.registerTool.bind(server) as unknown as (
22
+ n: string,
23
+ c: unknown,
24
+ h: Handler,
25
+ ) => unknown;
26
+ (server as unknown as { registerTool: typeof register }).registerTool = (name, config, handler) =>
27
+ register(name, config, async (...args: unknown[]) => {
28
+ const started = Date.now();
29
+ const r = await handler(...args);
30
+ const code = r.isError ? (r.structuredContent as { code?: string } | undefined)?.code : undefined;
31
+ log(r.isError ? "warn" : "info", "tool", {
32
+ tool: name,
33
+ ms: Date.now() - started,
34
+ ok: !r.isError,
35
+ code,
36
+ });
37
+ return r;
38
+ });
39
+ }
40
+
41
+ export function buildServer(deps: Deps = defaultDeps()): McpServer {
42
+ const server = new McpServer({ name: "catherd", version: deps.version });
43
+ logToolCalls(server);
44
+ // The SDK validates input before a tool's `handle` runs and reports a failure through this (private)
45
+ // method as plain text; test/entry/mcp.test.ts fails loudly if an SDK upgrade renames it. The wrapper
46
+ // above never sees such a call, so it is logged here (spec §10.2: every call), by the tool it names.
47
+ (server as unknown as { createToolError: typeof sdkToolError }).createToolError = (message) => {
48
+ const r = sdkToolError(message);
49
+ const code = (r.structuredContent as { code?: string } | undefined)?.code;
50
+ log("warn", "tool", { tool: toolOf(message), ok: false, code });
51
+ return r;
52
+ };
53
+ registerRunTools(server, deps);
54
+ registerLaneTools(server, deps);
55
+ registerDispatchTools(server, deps);
56
+ registerSetupTools(server, deps);
57
+ registerProtocolTools(server, deps);
58
+ return server;
59
+ }
60
+
61
+ /**
62
+ * Spec §4.7: connect first, so the client never waits on a scan; then reconcile every run. Spec §3.4: the notifier
63
+ * starts before reconcile, so what reconcile settles is announced, and scans for the rest once it is done.
64
+ */
65
+ export async function startMcpServer(): Promise<void> {
66
+ const deps = defaultDeps();
67
+ // which of the session's variables this server got (never their values): the live check of spec §3.9
68
+ log("info", "session", {
69
+ sessionId: Boolean(deps.session?.sessionId),
70
+ hostSessionId: Boolean(deps.session?.hostSessionId),
71
+ socket: Boolean(deps.session?.socketPath),
72
+ token: Boolean(deps.session?.token),
73
+ });
74
+ const notifier = startNotifier(deps);
75
+ await buildServer(deps).connect(new StdioServerTransport());
76
+ try {
77
+ const r = await reconcileAll(deps);
78
+ const shown = r.warnings.length;
79
+ for (const w of r.warnings) console.error(`catherd: ${w}`);
80
+ void r.done.then(() => {
81
+ for (const w of r.warnings.slice(shown)) console.error(`catherd: ${w}`);
82
+ });
83
+ } catch (e) {
84
+ console.error(`catherd: reconcile failed: ${errorMessage(e)}`);
85
+ }
86
+ // whatever finished unread while no server ran, or finished under another server (spec §3.4)
87
+ void notifier.scan();
88
+ }
@@ -0,0 +1,81 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { CatherdError } from "../../domain/errors.ts";
4
+ import { PROFILE_NAME, ProfilePatchSchema } from "../../domain/profile.ts";
5
+ import { ROLES } from "../../domain/roles.ts";
6
+ import { gitToplevel } from "../../infra/git.ts";
7
+ import type { Deps } from "../../services/ports.ts";
8
+ import { handle } from "./result.ts";
9
+
10
+ const PROFILE = z.string().regex(PROFILE_NAME).optional();
11
+ const REPO = z.string().min(1).optional();
12
+
13
+ /**
14
+ * The git toplevel of `repo` (default: this server's directory, null outside a repository). A `repo`
15
+ * given outside a repository is refused, as run_start does: never the global profile in its stead.
16
+ */
17
+ const toplevel = async (repo: string | undefined): Promise<string | null> => {
18
+ const top = await gitToplevel(repo ?? process.cwd());
19
+ if (top === null && repo !== undefined)
20
+ throw new CatherdError("E_IO_PATH", `${repo} is not inside a git repository`, {
21
+ fix: "pass the path of the repository, or leave repo out for the active profile",
22
+ });
23
+ return top;
24
+ };
25
+
26
+ export function registerSetupTools(server: McpServer, deps: Deps): void {
27
+ server.registerTool(
28
+ "catalog_query",
29
+ {
30
+ description:
31
+ "Models catherd can place, with capabilities, the roles they can fill, their scored rungs (backend:model#effort), any 'treat like', their cost under the billing of `repo`'s profile, and whether this account's last listing offers them (`listed: false`: it does not); `enabled: false` rungs are unscored. Scored models first. opencode's models are as listed in `repo` (default: this server's directory), as route sees them there.",
32
+ inputSchema: {
33
+ repo: z.string().min(1).optional(),
34
+ role: z.enum(ROLES).optional(),
35
+ backend: z.string().optional(),
36
+ text: z.string().optional(),
37
+ scored_only: z.boolean().default(false),
38
+ limit: z.number().int().min(1).max(500).default(50),
39
+ },
40
+ },
41
+ (a) =>
42
+ handle(async () => {
43
+ // outside a git repository: the global listings and the active profile
44
+ const repo = (await gitToplevel(a.repo ?? process.cwd())) ?? undefined;
45
+ return deps.routing.catalog(
46
+ { role: a.role, backend: a.backend, text: a.text, scoredOnly: a.scored_only, limit: a.limit, repo },
47
+ deps.profiles.forRepo(repo ?? null).billing,
48
+ );
49
+ }),
50
+ );
51
+
52
+ server.registerTool(
53
+ "profile_get",
54
+ {
55
+ description:
56
+ "A profile (without a name: the profile this repo runs on, i.e. the one bound to `repo`, default this server's directory, else the active one) as the run engine reads it, with every default filled in: per role its access mode, rungs and default rung, and whether its backend enforces the access (enforcement); billing, jev, harness isolation, failover, budget, timeouts, preflight, lock and notify. Also `active` (the global active profile), `here` (the profile this repo runs on) and every name.",
57
+ inputSchema: { name: PROFILE, repo: REPO },
58
+ },
59
+ (a) => handle(async () => deps.profiles.get(a.name, await toplevel(a.repo))),
60
+ );
61
+
62
+ server.registerTool(
63
+ "profile_validate",
64
+ {
65
+ description:
66
+ "Check a profile (without a name: the profile this repo runs on, as in profile_get). errors block a save: the worker disabled, an enabled role with no usable rung, an unscored rung without a 'treat like', a failover stand-in unscored or on the same quota, a backend catherd cannot run. warnings do not: an access mode other than the role's default, a model the backend's listing lacks, a stand-in that never runs. Each has a path, a message and often a fix.",
67
+ inputSchema: { name: PROFILE, repo: REPO },
68
+ },
69
+ (a) => handle(async () => deps.profiles.validate(a.name, await toplevel(a.repo))),
70
+ );
71
+
72
+ server.registerTool(
73
+ "profile_set",
74
+ {
75
+ description:
76
+ "Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, network, rungs, defaultRung), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid; otherwise it saves, rewrites the agent files and relinks them. Returns the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
77
+ inputSchema: { name: PROFILE, repo: REPO, patch: ProfilePatchSchema },
78
+ },
79
+ (a) => handle(async () => deps.profiles.set(a.name, a.patch, await toplevel(a.repo))),
80
+ );
81
+ }
@@ -0,0 +1,318 @@
1
+ import { assertProfileName } from "../domain/profile.ts";
2
+ import { defineCommand } from "citty";
3
+ import type { Catalog } from "../domain/catalog.ts";
4
+ import { rungInfo } from "../domain/catalog.ts";
5
+ import { CatherdError } from "../domain/errors.ts";
6
+ import { type Change, patchAt, type Profile } from "../domain/profile.ts";
7
+ import { inferredScores, type Issue } from "../domain/profile-rules.ts";
8
+ import { ROLES, type Role } from "../domain/roles.ts";
9
+ import { gitToplevel } from "../infra/git.ts";
10
+ import { loadCatalog } from "../services/catalog-service.ts";
11
+ import type { Synced } from "../services/agent-links.ts";
12
+ import {
13
+ activate,
14
+ createProfile,
15
+ deleteProfile,
16
+ diffNamed,
17
+ patchProfile,
18
+ unbind,
19
+ } from "../services/profile-service.ts";
20
+ import {
21
+ activeName,
22
+ getProfile,
23
+ keyRunnable,
24
+ listProfiles,
25
+ profileExists,
26
+ readProjects,
27
+ requireProfile,
28
+ roleEnforcement,
29
+ runnableBackends,
30
+ validateNamed,
31
+ } from "../services/profile-store.ts";
32
+ import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
33
+
34
+ const json = JSON_ARG;
35
+
36
+ export interface StandIn {
37
+ from: string;
38
+ to: string;
39
+ inferred: boolean;
40
+ via: string | null;
41
+ }
42
+
43
+ /** Spec §7.2: each stand-in; `via` names the rung whose scores it borrows (spec 1.1 §11). */
44
+ export function standIns(p: Profile, c: Catalog): StandIn[] {
45
+ return Object.entries(p.failover).map(([from, to]) => {
46
+ try {
47
+ return { from, to, ...inferredScores(c, rungInfo(c, to)) };
48
+ } catch {
49
+ return { from, to, inferred: false, via: null };
50
+ }
51
+ });
52
+ }
53
+
54
+ const value = (v: unknown) => (v === null ? "none" : typeof v === "string" ? v : JSON.stringify(v));
55
+ export const formatChange = (c: Change): string => `${c.path}: ${value(c.before)} → ${value(c.after)}`;
56
+ export const formatIssue = (i: Issue, glyph: string): string[] => [
57
+ `${glyph} ${i.path}: ${i.message}`,
58
+ ...(i.fix ? [` fix: ${i.fix}`] : []),
59
+ ];
60
+
61
+ /** `profile show`: every setting, each role with its access and how strongly its backend holds it (spec D10). */
62
+ export function formatProfile(
63
+ p: Profile,
64
+ o: {
65
+ active: boolean;
66
+ enforcement: Record<Role, "enforced" | "advisory">;
67
+ standIns: StandIn[];
68
+ /** the backends catherd can run: runnableBackends() */
69
+ backends: string[];
70
+ },
71
+ ): string[] {
72
+ const width = Math.max(...ROLES.map((r) => r.length));
73
+ const lines = [
74
+ `profile ${p.name}${o.active ? " (active)" : ""}`,
75
+ `objective ${p.objective} · jev ${p.jev.use} · heavy slots ${p.lock.heavy} · notify ${p.notify.join(", ") || "none"}`,
76
+ "roles",
77
+ ];
78
+ for (const role of ROLES) {
79
+ const rc = p.roles[role];
80
+ if (!rc.enabled) {
81
+ lines.push(` ${role.padEnd(width)} off`);
82
+ continue;
83
+ }
84
+ const ladder = rc.rungs.map((r) => (r === rc.defaultRung ? `${r} (default)` : r)).join(" → ");
85
+ lines.push(
86
+ ` ${role.padEnd(width)} ${`${rc.access}${rc.network === false ? " (no network)" : ""}, ${o.enforcement[role]}`.padEnd(27)} ${ladder || "no rungs"}`,
87
+ );
88
+ }
89
+ // only what catherd can run today: a backend without an adapter has nothing to bill or isolate
90
+ const runs = ([key]: [string, unknown]) => keyRunnable(key, o.backends);
91
+ const billing = Object.entries(p.billing).filter(runs);
92
+ lines.push(`billing ${billing.map(([k, m]) => `${k} ${m}`).join(" · ")}`);
93
+ const harness = Object.entries(p.harness).filter(runs);
94
+ lines.push(`harness ${harness.map(([k, h]) => `${k} ${h.isolated ? "isolated" : "native"}`).join(" · ")}`);
95
+ lines.push(o.standIns.length ? "failover" : "failover none");
96
+ for (const s of o.standIns)
97
+ lines.push(` ${s.from} → ${s.to}${s.via ? ` (scores borrowed from ${s.via})` : ""}`);
98
+ const budget = Object.entries(p.budget).map(([k, v]) => (k === "usd" ? `$${v}` : `${v} ${k}`));
99
+ lines.push(`budget ${budget.join(" · ") || "no cap"}`);
100
+ lines.push(`timeouts idle ${p.timeouts.idleMin} min · wall ${p.timeouts.wallMin} min`);
101
+ lines.push(
102
+ `preflight ${p.preflight.confirm ? "shows its commands and asks first" : "runs the checks without asking"}`,
103
+ );
104
+ return lines;
105
+ }
106
+
107
+ function printSynced(r: Pick<Synced, "newSessionNeededFor">): void {
108
+ if (r.newSessionNeededFor.length)
109
+ console.log(`new Claude Code session needed for: ${r.newSessionNeededFor.join(", ")}`);
110
+ }
111
+
112
+ function printIssues(errors: Issue[], warnings: Issue[]): void {
113
+ for (const e of errors) for (const l of formatIssue(e, mark("fail"))) console.log(l);
114
+ for (const w of warnings) for (const l of formatIssue(w, mark("warn"))) console.log(l);
115
+ }
116
+
117
+ const refused = (errors: Issue[]) =>
118
+ new CatherdError(
119
+ "E_CONFIG_INVALID",
120
+ `the profile was not saved: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`,
121
+ { fix: errors.find((e) => e.fix)?.fix ?? "catherd profile validate lists what to change" },
122
+ );
123
+
124
+ async function repoHere(): Promise<string> {
125
+ const top = await gitToplevel(process.cwd());
126
+ if (!top)
127
+ throw new CatherdError("E_INPUT_INVALID", `${process.cwd()} is not inside a git repository`, {
128
+ fix: "run it inside the repo to bind, or leave out --repo",
129
+ });
130
+ return top;
131
+ }
132
+
133
+ /** The profile this directory runs on: its repo's binding, else the active one (outside a repo too). */
134
+ const activeHere = async (): Promise<string> => activeName(await gitToplevel(process.cwd()));
135
+
136
+ const list = defineCommand({
137
+ meta: {
138
+ name: "list",
139
+ description: "Every profile, the one this repo runs on (*), and the repos bound to each",
140
+ },
141
+ args: json,
142
+ async run({ args }) {
143
+ const active = await activeHere();
144
+ const bindings = Object.entries(readProjects().bindings);
145
+ const rows = listProfiles().map((name) => ({
146
+ name,
147
+ active: name === active,
148
+ repos: bindings.filter(([, p]) => p === name).map(([r]) => r),
149
+ }));
150
+ if (args.json) return printJson(rows);
151
+ for (const r of rows)
152
+ console.log(
153
+ `${r.active ? "*" : " "} ${r.name}${r.repos.length ? ` bound to ${r.repos.join(", ")}` : ""}`,
154
+ );
155
+ },
156
+ });
157
+
158
+ const show = defineCommand({
159
+ meta: {
160
+ name: "show",
161
+ description: "A profile with every default filled in (without a name: the one this repo runs on)",
162
+ },
163
+ args: { name: { type: "positional", required: false, description: "profile name" }, ...json },
164
+ async run({ args }) {
165
+ const here = await activeHere();
166
+ const name = args.name === undefined ? here : requireProfile(args.name);
167
+ const p = getProfile(name);
168
+ const o = {
169
+ active: name === here,
170
+ enforcement: roleEnforcement(p),
171
+ standIns: standIns(p, loadCatalog({ timings: false })),
172
+ };
173
+ if (args.json) return printJson({ profile: p, ...o });
174
+ for (const l of formatProfile(p, { ...o, backends: runnableBackends() })) console.log(l);
175
+ },
176
+ });
177
+
178
+ const use = defineCommand({
179
+ meta: {
180
+ name: "use",
181
+ description:
182
+ "Make a profile active, bind it to this repo with --repo, or unbind this repo with --repo --clear",
183
+ },
184
+ args: {
185
+ name: { type: "positional", required: false, description: "profile name" },
186
+ repo: { type: "boolean", description: "bind it to the git repo you are in, instead of making it active" },
187
+ clear: { type: "boolean", description: "with --repo and no name: unbind the git repo you are in" },
188
+ },
189
+ async run({ args }) {
190
+ if (args.clear) {
191
+ if (!args.repo || args.name !== undefined)
192
+ throw new CatherdError("E_INPUT_INVALID", "--clear goes with --repo and no profile name", {
193
+ fix: "catherd profile use --repo --clear, inside the repo to unbind",
194
+ });
195
+ const r = unbind(await repoHere());
196
+ console.log(`${mark("ok")} ${r.repo} is unbound`);
197
+ printSynced(r);
198
+ return;
199
+ }
200
+ if (args.name === undefined)
201
+ throw new CatherdError("E_INPUT_INVALID", "which profile? name one", {
202
+ fix: "catherd profile use <name> [--repo], or catherd profile use --repo --clear",
203
+ });
204
+ const r = activate(args.name, args.repo ? await repoHere() : null);
205
+ console.log(`${mark("ok")} ${r.active} ${r.repo ? `is bound to ${r.repo}` : "is active"}`);
206
+ printSynced(r);
207
+ },
208
+ });
209
+
210
+ const create = defineCommand({
211
+ meta: { name: "new", description: "A new profile from the default one, or from --from <profile>" },
212
+ args: {
213
+ name: { type: "positional", required: true, description: "new profile name" },
214
+ from: { type: "string", description: "copy this profile instead of the default one" },
215
+ },
216
+ run({ args }) {
217
+ const r = createProfile(args.name, args.from);
218
+ if (!r.saved) throw refused(r.errors);
219
+ console.log(`${mark("ok")} created ${args.name}${args.from ? ` from ${args.from}` : ""}`);
220
+ printIssues([], r.warnings);
221
+ },
222
+ });
223
+
224
+ const copy = defineCommand({
225
+ meta: { name: "copy", description: "Copy a profile under a new name" },
226
+ args: {
227
+ from: { type: "positional", required: true, description: "profile to copy" },
228
+ to: { type: "positional", required: true, description: "new profile name" },
229
+ },
230
+ run({ args }) {
231
+ const r = createProfile(args.to, args.from);
232
+ if (!r.saved) throw refused(r.errors);
233
+ console.log(`${mark("ok")} copied ${args.from} to ${args.to}`);
234
+ printIssues([], r.warnings);
235
+ },
236
+ });
237
+
238
+ const rm = defineCommand({
239
+ meta: { name: "rm", description: "Delete a profile and its agent files (never the active or a bound one)" },
240
+ args: { name: { type: "positional", required: true, description: "profile name" } },
241
+ run({ args }) {
242
+ deleteProfile(args.name);
243
+ console.log(`${mark("ok")} deleted ${args.name}`);
244
+ },
245
+ });
246
+
247
+ const set = defineCommand({
248
+ meta: {
249
+ name: "set",
250
+ description:
251
+ "Set one field: e.g. roles.worker.access read-only, budget.usd 20, failover.<rung> <rung>, roles.worker.rungs a,b; null removes",
252
+ },
253
+ args: {
254
+ path: { type: "positional", required: true, description: "dotted path, e.g. roles.verifier.access" },
255
+ value: { type: "positional", required: true, description: "JSON, or a plain word; null removes the key" },
256
+ profile: { type: "string", description: "the profile to change (default: the one this repo runs on)" },
257
+ },
258
+ async run({ args }) {
259
+ // the CLI never creates a profile by setting a field of it: a typo would start a new one
260
+ if (args.profile !== undefined && !profileExists(assertProfileName(args.profile)))
261
+ throw new CatherdError("E_INPUT_INVALID", `no profile named "${args.profile}"`, {
262
+ fix: `catherd profile new ${args.profile}`,
263
+ });
264
+ const r = patchProfile(args.profile ?? (await activeHere()), patchAt(args.path, args.value));
265
+ if (!r.saved) throw refused(r.errors);
266
+ if (r.diff.length === 0) console.log("no change");
267
+ for (const c of r.diff) console.log(`${mark("ok")} ${formatChange(c)}`);
268
+ printIssues([], r.warnings);
269
+ printSynced(r);
270
+ },
271
+ });
272
+
273
+ const diff = defineCommand({
274
+ meta: {
275
+ name: "diff",
276
+ description: "What differs between two profiles (the second defaults to the one this repo runs on)",
277
+ },
278
+ args: {
279
+ a: { type: "positional", required: true, description: "profile" },
280
+ b: { type: "positional", required: false, description: "profile (default: the one this repo runs on)" },
281
+ ...json,
282
+ },
283
+ async run({ args }) {
284
+ const changes = diffNamed(
285
+ args.b === undefined ? await activeHere() : requireProfile(args.b),
286
+ requireProfile(args.a),
287
+ );
288
+ if (args.json) return printJson(changes);
289
+ if (changes.length === 0) console.log("no differences");
290
+ for (const c of changes) console.log(formatChange(c));
291
+ },
292
+ });
293
+
294
+ const validate = defineCommand({
295
+ meta: { name: "validate", description: "Errors that block a save, and warnings that do not" },
296
+ args: {
297
+ name: {
298
+ type: "positional",
299
+ required: false,
300
+ description: "profile name (default: the one this repo runs on)",
301
+ },
302
+ ...json,
303
+ },
304
+ async run({ args }) {
305
+ const repo = await gitToplevel(process.cwd());
306
+ const v = validateNamed(args.name === undefined ? activeName(repo) : requireProfile(args.name), repo);
307
+ if (args.json) printJson({ valid: v.errors.length === 0, ...v });
308
+ else if (v.errors.length === 0 && v.warnings.length === 0) console.log(`${mark("ok")} valid`);
309
+ else printIssues(v.errors, v.warnings);
310
+ if (v.errors.length) process.exitCode = EXIT.error;
311
+ },
312
+ });
313
+
314
+ /** Spec §8: `catherd profile list|show|use [--repo [--clear]]|new|copy|rm|set <path> <value>|diff|validate`. */
315
+ export const profileCommand = defineCommand({
316
+ meta: { name: "profile", description: "Profiles: which models each role runs, and how" },
317
+ subCommands: { list, show, use, new: create, copy, rm, set, diff, validate },
318
+ });
@@ -0,0 +1,103 @@
1
+ import { createInterface, type Interface } from "node:readline/promises";
2
+ import { EXIT } from "./cli-kit.ts";
3
+
4
+ /** Plain questions on stdin; `secret` does not echo what is typed. */
5
+ export interface Prompter {
6
+ ask(question: string): Promise<string>;
7
+ secret(question: string): Promise<string>;
8
+ /** A question this machine does not need asked: piped, its line is still consumed, so answers keep their place */
9
+ skip?(): void;
10
+ close(): void;
11
+ }
12
+
13
+ /** A terminal's hidden input, one character at a time; Ctrl-C exits 130 (spec §8). */
14
+ function readSecret(question: string): Promise<string> {
15
+ process.stdout.write(question);
16
+ const stdin = process.stdin;
17
+ return new Promise((resolve) => {
18
+ let value = "";
19
+ const done = () => {
20
+ stdin.off("data", onData);
21
+ stdin.setRawMode(false);
22
+ stdin.pause();
23
+ process.stdout.write("\n");
24
+ resolve(value.trim());
25
+ };
26
+ const onData = (chunk: Buffer) => {
27
+ for (const ch of chunk.toString("utf8")) {
28
+ if (ch === "\r" || ch === "\n") return done();
29
+ if (ch === "\u0003") {
30
+ stdin.setRawMode(false);
31
+ process.exit(EXIT.interrupted);
32
+ }
33
+ if (ch === "\u007f") {
34
+ if (value) process.stdout.write("\b \b");
35
+ value = value.slice(0, -1);
36
+ } else {
37
+ value += ch;
38
+ process.stdout.write("*");
39
+ }
40
+ }
41
+ };
42
+ stdin.setRawMode(true);
43
+ stdin.resume();
44
+ stdin.on("data", onData);
45
+ });
46
+ }
47
+
48
+ /** What `terminalAsk` needs of a readline interface. */
49
+ interface Line {
50
+ question(q: string): Promise<string>;
51
+ on(event: "SIGINT", listener: () => void): unknown;
52
+ }
53
+ const isAbort = (e: unknown) =>
54
+ e instanceof Error && (e.name === "AbortError" || (e as { code?: string }).code === "ABORT_ERR");
55
+
56
+ /**
57
+ * A terminal question on `rl`, trimmed. Ctrl-C exits 130 (spec §8), as the secret prompt does: readline
58
+ * emits SIGINT, or the pending question rejects with an AbortError, depending on the runtime.
59
+ */
60
+ export function terminalAsk(
61
+ rl: Line,
62
+ exit: (code: number) => never = (code) => process.exit(code),
63
+ ): (q: string) => Promise<string> {
64
+ rl.on("SIGINT", () => exit(EXIT.interrupted));
65
+ return async (q) => {
66
+ try {
67
+ return (await rl.question(q)).trim();
68
+ } catch (e) {
69
+ if (isAbort(e)) exit(EXIT.interrupted);
70
+ throw e;
71
+ }
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Questions for `catherd init`. On a terminal they are asked one by one; piped, stdin is read once and each
77
+ * question takes its own line (an empty or missing line takes the default), whether or not this machine
78
+ * asks it, so a script's answers never shift onto another question.
79
+ */
80
+ export async function prompter(): Promise<Prompter> {
81
+ if (!process.stdin.isTTY) {
82
+ const lines = (await Bun.stdin.text()).split("\n");
83
+ const next = async (q: string) => {
84
+ const answer = (lines.shift() ?? "").trim();
85
+ process.stdout.write(`${q}\n`);
86
+ return answer;
87
+ };
88
+ return { ask: next, secret: next, skip: () => void lines.shift(), close() {} };
89
+ }
90
+ let rl: Interface | null = null;
91
+ let ask: ((q: string) => Promise<string>) | null = null;
92
+ return {
93
+ ask(q) {
94
+ rl ??= createInterface({ input: process.stdin, output: process.stdout });
95
+ ask ??= terminalAsk(rl);
96
+ return ask(q);
97
+ },
98
+ secret: (q) => readSecret(q),
99
+ close() {
100
+ rl?.close();
101
+ },
102
+ };
103
+ }