catherd-cli 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +114 -30
  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 +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -26
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -84
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -1,57 +0,0 @@
1
- export interface BackendStatus {
2
- backend: "codex" | "opencode";
3
- installed: boolean;
4
- version: string | null;
5
- loggedIn: boolean;
6
- fix: string | null;
7
- }
8
-
9
- interface Result {
10
- exitCode: number;
11
- stdout: string;
12
- }
13
-
14
- const PROBES = {
15
- codex: {
16
- install: "npm i -g @openai/codex",
17
- login: "codex login",
18
- status: ["login", "status"],
19
- // `codex login status` prints to stderr; its exit code is the answer.
20
- loggedIn: (r: Result) => r.exitCode === 0,
21
- },
22
- opencode: {
23
- install: "npm i -g opencode-ai",
24
- login: "opencode auth login",
25
- status: ["auth", "list"],
26
- loggedIn: (r: Result) => r.exitCode === 0 && r.stdout.trim().length > 0,
27
- },
28
- } as const;
29
-
30
- async function run(cmd: string, args: string[]): Promise<Result> {
31
- // Bun.spawn's own PATH lookup can stay stale across calls when PATH changes mid-process
32
- // (as tests do), so the binary is resolved by hand against the current PATH each time.
33
- const bin = Bun.which(cmd, { PATH: process.env.PATH ?? "" });
34
- if (!bin) return { exitCode: 127, stdout: "" };
35
- try {
36
- const proc = Bun.spawn([bin, ...args], { stdout: "pipe", stderr: "pipe" });
37
- const [stdout] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
38
- const exitCode = await proc.exited;
39
- return { exitCode, stdout };
40
- } catch {
41
- return { exitCode: 127, stdout: "" };
42
- }
43
- }
44
-
45
- async function probe(backend: "codex" | "opencode"): Promise<BackendStatus> {
46
- const p = PROBES[backend];
47
- const v = await run(backend, ["--version"]);
48
- if (v.exitCode !== 0) return { backend, installed: false, version: null, loggedIn: false, fix: p.install };
49
- const version = v.stdout.trim().split(/\s+/).at(-1)?.replace(/^v/, "") ?? null;
50
- const s = await run(backend, [...p.status]);
51
- const loggedIn = p.loggedIn(s);
52
- return { backend, installed: true, version, loggedIn, fix: loggedIn ? null : p.login };
53
- }
54
-
55
- export function detectBackends(): Promise<BackendStatus[]> {
56
- return Promise.all([probe("codex"), probe("opencode")]);
57
- }
@@ -1,53 +0,0 @@
1
- import { createCliRenderer } from "@opentui/core";
2
- import { createRoot } from "@opentui/react";
3
- import { defineCommand } from "citty";
4
- import { createElement, type ReactNode } from "react";
5
- import { Dashboard } from "./dashboard.tsx";
6
- import { Init } from "./init.tsx";
7
- import { detectUi } from "./theme.ts";
8
- import { Watch } from "./watch.tsx";
9
-
10
- const uiArgs = {
11
- plain: { type: "boolean", description: "ASCII only: a text fallback for every glyph" },
12
- "reduced-motion": { type: "boolean", description: "No animation" },
13
- } as const;
14
-
15
- export const isBare = (rawArgs: string[]): boolean => rawArgs.every((a) => a.startsWith("-"));
16
-
17
- export async function mount(el: ReactNode, stdin: { isTTY?: boolean } = process.stdin): Promise<number> {
18
- if (!stdin.isTTY) {
19
- console.error(
20
- "catherd: this screen needs an interactive terminal. Scripts can use the MCP tools (status, profile_get).",
21
- );
22
- return 1;
23
- }
24
- const renderer = await createCliRenderer({ exitOnCtrlC: true });
25
- createRoot(renderer).render(el);
26
- await new Promise<void>((resolve) => renderer.once("destroy", () => resolve()));
27
- return Number(process.exitCode ?? 0);
28
- }
29
-
30
- /** The main command's run. citty runs it after any subcommand too, hence the isBare check.
31
- * Bare `catherd` opens the dashboard; Profile/Watch/Setup from there render Editor/Watch/Init
32
- * inline, in the same mounted tree — see dashboard.tsx. */
33
- export async function editorRun({ rawArgs }: { rawArgs: string[] }): Promise<void> {
34
- if (!isBare(rawArgs)) return;
35
- process.exitCode = await mount(createElement(Dashboard, { ui: detectUi(rawArgs) }));
36
- }
37
-
38
- export const initCommand = defineCommand({
39
- meta: { name: "init", description: "First run: the Jev key, your backends, and a profile" },
40
- args: uiArgs,
41
- async run({ rawArgs }) {
42
- const code = await mount(createElement(Init, { ui: detectUi(rawArgs) }));
43
- process.exitCode = process.exitCode || code;
44
- },
45
- });
46
-
47
- export const watchCommand = defineCommand({
48
- meta: { name: "watch", description: "Live view of running and recent runs" },
49
- args: uiArgs,
50
- async run({ rawArgs }) {
51
- process.exitCode = await mount(createElement(Watch, { ui: detectUi(rawArgs) }));
52
- },
53
- });
@@ -1,245 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { TextAttributes } from "@opentui/core";
3
- import { useKeyboard, useRenderer } from "@opentui/react";
4
- import { useEffect, useState } from "react";
5
- import { countLinkedAgents } from "../profile/agents.ts";
6
- import { activeProfileName } from "../profile/profile.ts";
7
- import { loadCatalog } from "../routing/catalog.ts";
8
- import { jevKey } from "../routing/jev.ts";
9
- import type { Catalog } from "../types.ts";
10
- import { type BackendStatus, detectBackends } from "./backends.ts";
11
- import { Editor } from "./editor.tsx";
12
- import { Init } from "./init.tsx";
13
- import { dot, type DotState, dotTint, glyph, type Mood, tint, type Ui } from "./theme.ts";
14
- import { DetailPane, Frame, ListLine, Wordmark } from "./ui.tsx";
15
- import { Watch } from "./watch.tsx";
16
-
17
- export interface DashboardDeps {
18
- detectBackends: () => Promise<BackendStatus[]>;
19
- jevKey: () => string | null;
20
- loadCatalog: () => Catalog;
21
- countLinkedAgents: () => number;
22
- activeProfileName: () => string;
23
- version: string;
24
- }
25
-
26
- function readVersion(): string {
27
- try {
28
- const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as {
29
- version: string;
30
- };
31
- return pkg.version;
32
- } catch {
33
- return "0.0.0";
34
- }
35
- }
36
-
37
- const dashboardDeps: DashboardDeps = {
38
- detectBackends,
39
- jevKey,
40
- loadCatalog,
41
- countLinkedAgents,
42
- activeProfileName,
43
- version: readVersion(),
44
- };
45
-
46
- type Screen = "dashboard" | "profile" | "watch" | "setup";
47
-
48
- interface Action {
49
- screen: Screen | "refresh" | "exit";
50
- label: string;
51
- subtitle: string;
52
- description: string;
53
- }
54
-
55
- const ACTIONS: Action[] = [
56
- {
57
- screen: "profile",
58
- label: "Profile",
59
- subtitle: "roles, routing, budget",
60
- description: "Edit which models fill each role, and how catherd escalates between them.",
61
- },
62
- {
63
- screen: "watch",
64
- label: "Watch runs",
65
- subtitle: "live and recent runs",
66
- description: "See what is running now, and how past runs went.",
67
- },
68
- {
69
- screen: "setup",
70
- label: "Setup",
71
- subtitle: "Jev key & backends",
72
- description: "Re-run first-time setup: the Jev key, Codex/opencode, and a starting profile.",
73
- },
74
- {
75
- screen: "refresh",
76
- label: "Refresh catalog",
77
- subtitle: "reload models & scores",
78
- description: "Re-read the model catalog from disk, in case it changed underneath catherd.",
79
- },
80
- {
81
- screen: "exit",
82
- label: "Exit",
83
- subtitle: "quit catherd",
84
- description: "Leave catherd without changing anything.",
85
- },
86
- ];
87
-
88
- interface Status {
89
- backends: BackendStatus[];
90
- jev: boolean;
91
- agents: number;
92
- active: string;
93
- }
94
-
95
- function backendState(b: BackendStatus | undefined, plain: boolean): { state: DotState; text: string } {
96
- if (!b || !b.installed) return { state: "missing", text: "not installed" };
97
- if (!b.loggedIn) {
98
- return {
99
- state: "warn",
100
- text: b.version ? `${b.version} ${glyph("dot", plain)} not logged in` : "not logged in",
101
- };
102
- }
103
- return { state: "ready", text: b.version ?? "ready" };
104
- }
105
-
106
- function StatusRow({ ui, state, name, value }: { ui: Ui; state: DotState; name: string; value: string }) {
107
- return (
108
- <text wrapMode="none" truncate>
109
- <span fg={dotTint(state, ui.depth)}>{dot(state, ui.plain)}</span> {name.padEnd(16)}
110
- <span attributes={TextAttributes.DIM}>{value}</span>
111
- </text>
112
- );
113
- }
114
-
115
- function mood(status: Status | null): Mood {
116
- if (!status) return "working";
117
- const trouble = status.backends.some((b) => !b.installed || !b.loggedIn) || !status.jev;
118
- return trouble ? "waiting" : "good";
119
- }
120
-
121
- /** The bare `catherd` screen: an actions menu on the left, and the selected action's detail
122
- * plus a live status snapshot on the right. Selecting Profile/Watch/Setup swaps this same
123
- * mounted tree for that screen, in place — no new process, no re-mount of the renderer. */
124
- export function Dashboard({ ui, deps = {} }: { ui: Ui; deps?: Partial<DashboardDeps> }) {
125
- const d = { ...dashboardDeps, ...deps };
126
- const renderer = useRenderer();
127
- const [cursor, setCursor] = useState(0);
128
- const [status, setStatus] = useState<Status | null>(null);
129
- const [screen, setScreen] = useState<Screen>("dashboard");
130
- const [note, setNote] = useState("");
131
-
132
- const refresh = () => {
133
- d.detectBackends().then(
134
- (backends) =>
135
- setStatus({
136
- backends,
137
- jev: d.jevKey() !== null,
138
- agents: d.countLinkedAgents(),
139
- active: d.activeProfileName(),
140
- }),
141
- () => setStatus({ backends: [], jev: false, agents: 0, active: d.activeProfileName() }),
142
- );
143
- };
144
-
145
- // spec: no new network calls on every keystroke — backends are probed once on mount, and
146
- // again only when the user asks for it with `r`.
147
- useEffect(refresh, []);
148
-
149
- const activate = () => {
150
- const a = ACTIONS[cursor];
151
- if (!a) return;
152
- if (a.screen === "exit") renderer.destroy();
153
- else if (a.screen === "refresh") {
154
- d.loadCatalog();
155
- setNote("Catalog reloaded.");
156
- } else setScreen(a.screen);
157
- };
158
-
159
- useKeyboard((key) => {
160
- if (screen !== "dashboard") return;
161
- if (key.name === "up") setCursor((v) => Math.max(0, v - 1));
162
- else if (key.name === "down") setCursor((v) => Math.min(ACTIONS.length - 1, v + 1));
163
- else if (key.name === "return") activate();
164
- else if (key.sequence === "r") refresh();
165
- else if (key.sequence === "q") renderer.destroy();
166
- });
167
-
168
- if (screen === "profile") return <Editor ui={ui} />;
169
- if (screen === "watch") return <Watch ui={ui} />;
170
- if (screen === "setup") return <Init ui={ui} />;
171
-
172
- const a = ACTIONS[cursor] as Action;
173
- const codex = status
174
- ? backendState(
175
- status.backends.find((b) => b.backend === "codex"),
176
- ui.plain,
177
- )
178
- : null;
179
- const opencode = status
180
- ? backendState(
181
- status.backends.find((b) => b.backend === "opencode"),
182
- ui.plain,
183
- )
184
- : null;
185
-
186
- return (
187
- <Frame
188
- ui={ui}
189
- title="catherd"
190
- hint={`${glyph("keys", ui.plain)} navigate Enter open r refresh q exit`}
191
- above={<Wordmark ui={ui} mood={mood(status)} suffix={d.version} />}
192
- >
193
- <box style={{ flexDirection: "row" }}>
194
- <box style={{ flexDirection: "column", width: 22 }}>
195
- <text fg={tint("pink", ui.depth)} attributes={TextAttributes.BOLD}>
196
- ACTIONS
197
- </text>
198
- {ACTIONS.map((act, i) => (
199
- <ListLine key={act.label} ui={ui} selected={i === cursor} text={act.label} />
200
- ))}
201
- </box>
202
- <DetailPane ui={ui} heading="SELECTED ACTION" title={a.label} subtitle={a.subtitle}>
203
- <text attributes={TextAttributes.DIM} wrapMode="word">
204
- {a.description}
205
- </text>
206
- <text> </text>
207
- <text fg={tint("pink", ui.depth)} attributes={TextAttributes.BOLD}>
208
- CURRENT SETUP
209
- </text>
210
- {status ? (
211
- <>
212
- <StatusRow
213
- ui={ui}
214
- state={status.jev ? "ready" : "missing"}
215
- name="Jev key"
216
- value={status.jev ? "set" : "missing"}
217
- />
218
- <StatusRow ui={ui} state={codex?.state ?? "missing"} name="Codex" value={codex?.text ?? ""} />
219
- <StatusRow
220
- ui={ui}
221
- state={opencode?.state ?? "missing"}
222
- name="opencode"
223
- value={opencode?.text ?? ""}
224
- />
225
- <StatusRow
226
- ui={ui}
227
- state={status.agents > 0 ? "ready" : "warn"}
228
- name="Claude agents"
229
- value={`${status.agents} linked`}
230
- />
231
- <StatusRow ui={ui} state="ready" name="Active profile" value={status.active} />
232
- </>
233
- ) : (
234
- <text attributes={TextAttributes.DIM}>checking…</text>
235
- )}
236
- {note ? (
237
- <text fg={tint("pink", ui.depth)} wrapMode="none" truncate>
238
- {note}
239
- </text>
240
- ) : null}
241
- </DetailPane>
242
- </box>
243
- </Frame>
244
- );
245
- }
@@ -1,214 +0,0 @@
1
- import { useKeyboard, useRenderer } from "@opentui/react";
2
- import { useEffect, useState } from "react";
3
- import { type HarnessCost, harnessCosts } from "../core/harness.ts";
4
- import { listRuns } from "../core/runstore.ts";
5
- import {
6
- activeProfileName,
7
- defaultProfile,
8
- listProfiles,
9
- loadProfile,
10
- validateProfile,
11
- } from "../profile/profile.ts";
12
- import { loadCatalog } from "../routing/catalog.ts";
13
- import type { Catalog, Profile } from "../types.ts";
14
- import { type BackendStatus, detectBackends } from "./backends.ts";
15
- import { matrixHint, Matrix } from "./matrix.tsx";
16
- import { deleteProfile, nameError, saveAndActivate } from "./profiles.ts";
17
- import { glyph, tint, type Ui } from "./theme.ts";
18
- import { CatSpinner, Frame } from "./ui.tsx";
19
-
20
- export interface EditorDeps {
21
- loadCatalog: () => Catalog;
22
- detectBackends: () => Promise<BackendStatus[]>;
23
- harnessCosts: () => HarnessCost[];
24
- save: (p: Profile, c: Catalog) => void;
25
- }
26
-
27
- const editorDeps: EditorDeps = {
28
- loadCatalog,
29
- detectBackends,
30
- harnessCosts: () => harnessCosts(listRuns().map((r) => r.dir)),
31
- save: saveAndActivate,
32
- };
33
-
34
- type Ask = "new" | "copy" | "delete" | "quit" | null;
35
-
36
- const extraHint = (plain: boolean) =>
37
- `p next profile ${glyph("dot", plain)} n new ${glyph("dot", plain)} c copy ${glyph("dot", plain)} x delete`;
38
-
39
- function openBook(): { profiles: Profile[]; idx: number } {
40
- const names = listProfiles();
41
- const profiles = names.length > 0 ? names.map((n) => loadProfile(n)) : [defaultProfile()];
42
- const active = activeProfileName();
43
- return {
44
- profiles,
45
- idx: Math.max(
46
- 0,
47
- profiles.findIndex((p) => p.name === active),
48
- ),
49
- };
50
- }
51
-
52
- export function Editor({ ui, deps = {} }: { ui: Ui; deps?: Partial<EditorDeps> }) {
53
- const d = { ...editorDeps, ...deps };
54
- const renderer = useRenderer();
55
- const exit = () => renderer.destroy();
56
- const [catalog, setCatalog] = useState(d.loadCatalog);
57
- const [costs] = useState(d.harnessCosts);
58
- const [backends, setBackends] = useState<BackendStatus[] | null>(null);
59
- const [book, setBook] = useState(openBook);
60
- const [active, setActive] = useState(activeProfileName);
61
- const [dirty, setDirty] = useState<ReadonlySet<string>>(new Set());
62
- const [errors, setErrors] = useState<string[]>([]);
63
- const [note, setNote] = useState("");
64
- const [ask, setAsk] = useState<Ask>(null);
65
- const [showHelp, setShowHelp] = useState(false);
66
- const profile = book.profiles[book.idx] as Profile;
67
- const pink = tint("pink", ui.depth);
68
-
69
- useEffect(() => {
70
- d.detectBackends().then(setBackends, () => setBackends([]));
71
- }, []);
72
-
73
- const mark = (name: string, on: boolean) => {
74
- const next = new Set(dirty);
75
- if (on) next.add(name);
76
- else next.delete(name);
77
- setDirty(next);
78
- };
79
-
80
- const put = (p: Profile) => {
81
- setBook({ profiles: book.profiles.map((x, i) => (i === book.idx ? p : x)), idx: book.idx });
82
- mark(p.name, true);
83
- setErrors([]);
84
- setNote("");
85
- };
86
-
87
- const save = () => {
88
- const errs = validateProfile(profile, catalog);
89
- setErrors(errs);
90
- if (errs.length > 0) return;
91
- d.save(profile, catalog);
92
- mark(profile.name, false);
93
- setActive(profile.name);
94
- setNote(`Saved ${profile.name}; it is active now. New Claude Code sessions get its agents.`);
95
- };
96
-
97
- const add = (name: string) => {
98
- const err = nameError(
99
- name,
100
- book.profiles.map((p) => p.name),
101
- );
102
- setAsk(null);
103
- if (err) {
104
- setNote(err);
105
- return;
106
- }
107
- const base = ask === "new" ? defaultProfile() : structuredClone(profile);
108
- setBook({ profiles: [...book.profiles, { ...base, name }], idx: book.profiles.length });
109
- mark(name, true);
110
- setNote("");
111
- };
112
-
113
- const remove = () => {
114
- if (book.profiles.length === 1) {
115
- setNote("Keep at least one profile.");
116
- return;
117
- }
118
- try {
119
- if (listProfiles().includes(profile.name)) deleteProfile(profile.name);
120
- } catch (e) {
121
- setNote((e as Error).message);
122
- return;
123
- }
124
- mark(profile.name, false);
125
- setBook({ profiles: book.profiles.filter((_, i) => i !== book.idx), idx: 0 });
126
- setNote(`Deleted ${profile.name}.`);
127
- };
128
-
129
- const onKey = (input: string) => {
130
- setNote("");
131
- if (input === "p") {
132
- setBook({ ...book, idx: (book.idx + 1) % book.profiles.length });
133
- setErrors([]);
134
- } else if (input === "n") setAsk("new");
135
- else if (input === "c") setAsk("copy");
136
- else if (input === "x") setAsk("delete");
137
- };
138
-
139
- useKeyboard((key) => {
140
- if (ask === "new" || ask === "copy") {
141
- if (key.name === "escape") setAsk(null);
142
- return;
143
- }
144
- if (ask === "quit") {
145
- if (key.sequence === "q") exit();
146
- setAsk(null);
147
- return;
148
- }
149
- if (ask === "delete") {
150
- if (key.sequence === "y") remove();
151
- setAsk(null);
152
- }
153
- });
154
-
155
- const sep = ` ${glyph("dot", ui.plain)} `;
156
- const title = [
157
- `profile ${profile.name}`,
158
- `${book.idx + 1}/${book.profiles.length}`,
159
- profile.name === active ? "active" : null,
160
- dirty.has(profile.name) ? "unsaved" : null,
161
- ]
162
- .filter(Boolean)
163
- .join(sep);
164
-
165
- return (
166
- <Frame ui={ui} title={title} hint={matrixHint(ui, "save", showHelp, extraHint(ui.plain))}>
167
- {backends === null ? (
168
- <CatSpinner ui={ui} label="sniffing out codex and opencode" />
169
- ) : (
170
- <Matrix
171
- key={profile.name}
172
- ui={ui}
173
- profile={profile}
174
- catalog={catalog}
175
- backends={backends}
176
- costs={costs}
177
- errors={errors}
178
- reloadCatalog={d.loadCatalog}
179
- onChange={put}
180
- onCatalog={setCatalog}
181
- onSubmit={save}
182
- onQuit={() => (dirty.size > 0 ? setAsk("quit") : exit())}
183
- onKey={onKey}
184
- active={ask === null}
185
- onToggleHelp={() => setShowHelp((v) => !v)}
186
- />
187
- )}
188
- {ask === "new" || ask === "copy" ? (
189
- <box style={{ flexDirection: "row" }}>
190
- <text>
191
- {ask === "new" ? "Name for the new profile: " : `Name for the copy of ${profile.name}: `}
192
- </text>
193
- {/* React 19's DOMAttributes adds a DOM `onSubmit(event: SubmitEvent)` overload to every
194
- intrinsic element, including OpenTUI's non-DOM <input>; `as any` sidesteps that
195
- phantom overload rather than fighting it with a full-signature cast. */}
196
- <input focused onSubmit={add as any} />
197
- </box>
198
- ) : null}
199
- {ask === "delete" ? (
200
- <text fg={pink}>{`Delete profile ${profile.name}? y deletes it, any other key keeps it.`}</text>
201
- ) : null}
202
- {ask === "quit" ? (
203
- <text fg={pink} wrapMode="none" truncate>
204
- {`Unsaved changes in ${[...dirty].join(", ")}. q quits without saving, any other key stays.`}
205
- </text>
206
- ) : null}
207
- {note ? (
208
- <text wrapMode="none" truncate>
209
- {note}
210
- </text>
211
- ) : null}
212
- </Frame>
213
- );
214
- }