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,512 +0,0 @@
1
- import { TextAttributes } from "@opentui/core";
2
- import { useKeyboard, useTerminalDimensions } from "@opentui/react";
3
- import type { ReactNode } from "react";
4
- import { useState } from "react";
5
- import type { HarnessCost } from "../core/harness.ts";
6
- import { entryFor, saveTreatLike } from "../routing/catalog.ts";
7
- import { candidates } from "../routing/select.ts";
8
- import { type Backend, type Catalog, type Profile, type RungId, rungOf, splitRung } from "../types.ts";
9
- import type { BackendStatus } from "./backends.ts";
10
- import {
11
- budgetDetailRows,
12
- type BudgetField,
13
- budgetSummary,
14
- type DetailRow,
15
- failoverDetailRows,
16
- failoverOptions,
17
- failoverSummary,
18
- isTicked,
19
- openKey,
20
- readySet,
21
- type Row,
22
- rowId,
23
- roleDetailRows,
24
- sectionOf,
25
- toggle,
26
- topLevelRows,
27
- type TopRow,
28
- treatLikeOptions,
29
- } from "./matrix-model.ts";
30
- import { dot, glyph, harnessLine, shortRung, tint, type Ui } from "./theme.ts";
31
- import { DetailPane, ListLine } from "./ui.tsx";
32
-
33
- export interface MatrixProps {
34
- ui: Ui;
35
- profile: Profile;
36
- catalog: Catalog;
37
- backends: BackendStatus[];
38
- costs?: HarnessCost[];
39
- errors?: string[];
40
- reloadCatalog: () => Catalog;
41
- onChange: (p: Profile) => void;
42
- onCatalog: (c: Catalog) => void;
43
- onSubmit: () => void;
44
- onQuit: () => void;
45
- onKey?: (input: string) => void;
46
- active?: boolean;
47
- /** Controlled "?" help toggle, so the caller's `Frame` can render the hint bar it computes
48
- * with `matrixHint` outside the panel border instead of Matrix drawing its own. */
49
- onToggleHelp?: () => void;
50
- }
51
-
52
- /** The hint bar text for a Matrix at the given help state — exported so the caller's `Frame`
53
- * can render it outside the panel border while Matrix owns none of the layout itself. */
54
- export function matrixHint(ui: Ui, submitLabel: string, showHelp: boolean, extraHint = ""): string {
55
- const dotSep = ` ${glyph("dot", ui.plain)} `;
56
- return showHelp
57
- ? `/ filter${dotSep}${extraHint}`
58
- : `${glyph("keys", ui.plain)} navigate${dotSep}${glyph("arrow", ui.plain)} open${dotSep}space ticks${dotSep}enter ${submitLabel}${dotSep}? more${dotSep}q quit`;
59
- }
60
-
61
- type EffortRow = Extract<DetailRow, { kind: "effort" }>;
62
- type Mode =
63
- | "nav"
64
- | "filter"
65
- | { row: EffortRow; rung: RungId }
66
- | { failover: RungId }
67
- | { budgetField: BudgetField; buffer: string };
68
-
69
- /** "sol", "luna", "opus 5.5" — the short display name the right pane uses for a model id
70
- * (the full id still shows in the effort rows below it). */
71
- export function shortModelName(id: string): string {
72
- const base = id.replace(/^.*\//, "").replace(/^gpt-\d+-/, "");
73
- return base.replace(/^claude-([a-z]+)-(\d+)-(\d+)$/, "$1 $2.$3");
74
- }
75
-
76
- export function Matrix(props: MatrixProps) {
77
- const { ui, profile, catalog, backends, costs = [], errors = [], active = true } = props;
78
- const { height: termHeight } = useTerminalDimensions();
79
- const [leftCursor, setLeftCursor] = useState(0);
80
- const [focus, setFocus] = useState<"left" | "right">("left");
81
- const [rightCursor, setRightCursor] = useState(0);
82
- const [open, setOpen] = useState<ReadonlySet<string>>(new Set());
83
- const [filter, setFilter] = useState("");
84
- const [mode, setMode] = useState<Mode>("nav");
85
- const [note, setNote] = useState("");
86
- // "?" only does anything when the caller controls help (renders it outside the panel via
87
- // `matrixHint`) — Matrix itself draws no help text, so there is nothing to toggle otherwise.
88
- const toggleHelp = () => props.onToggleHelp?.();
89
- const ready = readySet(backends);
90
- const dotSep = ` ${glyph("dot", ui.plain)} `;
91
-
92
- const top = topLevelRows();
93
- const selTop = top[Math.min(leftCursor, top.length - 1)] as TopRow;
94
- const detail: DetailRow[] =
95
- selTop.kind === "role"
96
- ? roleDetailRows(profile, catalog, selTop.role, open, filter)
97
- : selTop.kind === "budget-summary"
98
- ? budgetDetailRows()
99
- : selTop.kind === "failover-summary"
100
- ? failoverDetailRows(profile, catalog)
101
- : [];
102
- const rightAt = Math.min(rightCursor, Math.max(0, detail.length - 1));
103
- const hereRight = detail[rightAt];
104
-
105
- const moveLeft = (idx: number) => {
106
- setLeftCursor(Math.max(0, Math.min(top.length - 1, idx)));
107
- setFocus("left");
108
- setRightCursor(0);
109
- setNote("");
110
- };
111
-
112
- const apply = (row: Row, c: Catalog) => {
113
- const t = toggle(profile, c, row, ready);
114
- if ("profile" in t) {
115
- props.onChange(t.profile);
116
- setNote("");
117
- } else if ("refused" in t) {
118
- setNote(t.refused);
119
- } else if (row.kind === "effort") {
120
- if (treatLikeOptions(c, row.role).length === 0)
121
- setNote(`No scored rung can stand in for ${t.needsTreatLike}.`);
122
- else setMode({ row, rung: t.needsTreatLike });
123
- }
124
- };
125
-
126
- const collapse = () => {
127
- const row = hereRight;
128
- const key = row && (row.kind === "model" || row.kind === "effort") ? openKey(row) : null;
129
- if (key && row && (row.kind === "effort" || open.has(key))) {
130
- const next = new Set(open);
131
- next.delete(key);
132
- setOpen(next);
133
- if (row.kind === "effort" && selTop.kind === "role") {
134
- const list = roleDetailRows(profile, catalog, selTop.role, next, filter);
135
- setRightCursor(
136
- Math.max(
137
- 0,
138
- list.findIndex((r) => r.kind === "model" && openKey(r) === key),
139
- ),
140
- );
141
- }
142
- return;
143
- }
144
- setFocus("left");
145
- };
146
-
147
- useKeyboard((key) => {
148
- if (!active) return;
149
- if (typeof mode !== "string") {
150
- if (key.name === "escape") {
151
- setMode("nav");
152
- return;
153
- }
154
- if ("budgetField" in mode) {
155
- if (key.name === "return") {
156
- const n = mode.buffer === "" ? undefined : Number(mode.buffer);
157
- const next = structuredClone(profile);
158
- const budget = { ...next.budget };
159
- // ponytail: "1.2.3" parses to NaN and clears the cap rather than rejecting the keystroke;
160
- // the digit/"." filter below already keeps the common case sane.
161
- if (n === undefined || Number.isNaN(n)) delete budget[mode.budgetField];
162
- else budget[mode.budgetField] = n;
163
- next.budget = Object.keys(budget).length > 0 ? budget : undefined;
164
- props.onChange(next);
165
- setMode("nav");
166
- } else if (key.name === "backspace") {
167
- setMode({ ...mode, buffer: mode.buffer.slice(0, -1) });
168
- } else if (key.sequence && /^[0-9.]$/.test(key.sequence)) {
169
- setMode({ ...mode, buffer: mode.buffer + key.sequence });
170
- }
171
- }
172
- return;
173
- }
174
- if (mode === "filter") {
175
- if (key.name === "escape") {
176
- setFilter("");
177
- setMode("nav");
178
- } else if (key.name === "return") setMode("nav");
179
- else if (key.name === "backspace") setFilter((f) => f.slice(0, -1));
180
- else if (!key.ctrl && !key.meta) {
181
- const ch = key.name === "space" ? " " : key.sequence;
182
- // Typed text can arrive as several keypress events inside one JS tick, before this
183
- // component re-renders, so the update must build on the queued value, not the closure's.
184
- if (ch && ch.length === 1) setFilter((f) => f + ch);
185
- }
186
- return;
187
- }
188
- if (key.name === "up") {
189
- if (focus === "left") moveLeft(leftCursor - 1);
190
- else setRightCursor(Math.max(0, rightAt - 1));
191
- } else if (key.name === "down") {
192
- if (focus === "left") moveLeft(leftCursor + 1);
193
- else setRightCursor(Math.min(Math.max(0, detail.length - 1), rightAt + 1));
194
- } else if (key.name === "right") {
195
- if (focus === "left") {
196
- if (detail.length > 0) {
197
- setFocus("right");
198
- setRightCursor(0);
199
- }
200
- } else if (hereRight?.kind === "model") {
201
- const k = openKey(hereRight);
202
- if (k) setOpen(new Set(open).add(k));
203
- }
204
- } else if (key.name === "left") {
205
- if (focus === "right") collapse();
206
- } else if (key.name === "return") props.onSubmit();
207
- else if (key.name === "space") {
208
- if (focus === "left") {
209
- if (selTop.kind === "budget-summary" || selTop.kind === "failover-summary") {
210
- setFocus("right");
211
- setRightCursor(0);
212
- } else apply(selTop, catalog);
213
- } else if (hereRight) {
214
- if (hereRight.kind === "model" && !isTicked(profile, hereRight)) {
215
- const k = openKey(hereRight);
216
- if (k) setOpen(new Set(open).add(k));
217
- } else if (hereRight.kind === "budget")
218
- setMode({ budgetField: hereRight.field, buffer: String(profile.budget?.[hereRight.field] ?? "") });
219
- else if (hereRight.kind === "failover") setMode({ failover: hereRight.rung });
220
- else apply(hereRight, catalog);
221
- }
222
- } else if (key.sequence === "/") setMode("filter");
223
- else if (key.sequence === "?") toggleHelp();
224
- else if (key.sequence === "q") props.onQuit();
225
- else if (key.sequence) props.onKey?.(key.sequence);
226
- });
227
-
228
- if (typeof mode !== "string" && "budgetField" in mode) {
229
- return (
230
- <box style={{ flexDirection: "column" }}>
231
- <text wrapMode="none" truncate>{`${mode.budgetField}: ${mode.buffer}_`}</text>
232
- <text attributes={TextAttributes.DIM}>
233
- {`digits set the cap${dotSep}backspace edits${dotSep}enter saves${dotSep}esc cancels${dotSep}empty clears it`}
234
- </text>
235
- </box>
236
- );
237
- }
238
-
239
- if (typeof mode !== "string" && "failover" in mode) {
240
- return (
241
- <box style={{ flexDirection: "column" }}>
242
- <text wrapMode="none" truncate>
243
- {`Stand-in for ${mode.failover} on a quota limit:`}
244
- </text>
245
- <select
246
- focused
247
- showDescription={false}
248
- style={{ height: 8 }}
249
- options={[
250
- { name: "(none)", description: "", value: "" },
251
- ...failoverOptions(catalog, mode.failover).map((r) => ({ name: r, description: "", value: r })),
252
- ]}
253
- onSelect={(_, option) => {
254
- if (!option) return;
255
- const next = structuredClone(profile);
256
- const failover = { ...next.failover };
257
- if (option.value) failover[mode.failover] = option.value as RungId;
258
- else delete failover[mode.failover];
259
- next.failover = Object.keys(failover).length > 0 ? failover : undefined;
260
- props.onChange(next);
261
- setMode("nav");
262
- }}
263
- />
264
- <text attributes={TextAttributes.DIM}>{`enter picks${dotSep}esc cancels`}</text>
265
- </box>
266
- );
267
- }
268
-
269
- if (typeof mode !== "string") {
270
- return (
271
- <box style={{ flexDirection: "column" }}>
272
- <text wrapMode="none" truncate>
273
- {`${mode.rung} has no scores yet. Treat it like which scored rung?`}
274
- </text>
275
- <select
276
- focused
277
- showDescription={false}
278
- style={{ height: 8 }}
279
- options={treatLikeOptions(catalog, mode.row.role).map((r) => ({
280
- name: r,
281
- description: "",
282
- value: r,
283
- }))}
284
- onSelect={(_, option) => {
285
- if (!option) return;
286
- const like = option.value as RungId;
287
- saveTreatLike(mode.rung, like);
288
- const c = props.reloadCatalog();
289
- props.onCatalog(c);
290
- setMode("nav");
291
- apply(mode.row, c);
292
- }}
293
- />
294
- <text attributes={TextAttributes.DIM}>{`enter picks${dotSep}esc cancels`}</text>
295
- </box>
296
- );
297
- }
298
-
299
- const notReady = new Map<Backend, string>(
300
- backends.filter((b) => !(b.installed && b.loggedIn)).map((b) => [b.backend, b.fix ?? ""]),
301
- );
302
-
303
- // --- left pane -----------------------------------------------------------------------------
304
- const leftText = (r: TopRow): { text: string; dim: boolean; tag: string | null } => {
305
- switch (r.kind) {
306
- case "role": {
307
- const on = profile.roles[r.role].enabled;
308
- return {
309
- text: `${dot(on ? "ready" : "missing", ui.plain)} ${r.role}`,
310
- dim: !on,
311
- tag: on ? null : "off",
312
- };
313
- }
314
- case "objective":
315
- return { text: "objective", dim: false, tag: profile.objective };
316
- case "lock":
317
- return { text: "heavy slots", dim: false, tag: String(profile.lock.heavy) };
318
- case "harness":
319
- return {
320
- text: r.backend,
321
- dim: notReady.has(r.backend),
322
- tag: profile.harness[r.backend].isolated ? "isolated" : "native",
323
- };
324
- case "budget-summary":
325
- return { text: "budget", dim: false, tag: budgetSummary(profile, ` ${glyph("dot", ui.plain)} `) };
326
- case "failover-summary":
327
- return { text: "failover", dim: false, tag: failoverSummary(profile) };
328
- case "notify": {
329
- const on = profile.notify.includes(r.moment);
330
- return { text: `${dot(on ? "ready" : "missing", ui.plain)} ${r.moment}`, dim: !on, tag: null };
331
- }
332
- }
333
- };
334
-
335
- // Rows left for the panes once the cat, the border, the padding and the hint are drawn.
336
- const rows = Math.max(12, termHeight || 24);
337
- const LEFT_HEIGHT = Math.max(4, rows - 18);
338
- const RIGHT_HEIGHT = Math.max(3, rows - 20);
339
- const start = Math.max(0, Math.min(leftCursor - Math.floor(LEFT_HEIGHT / 2), top.length - LEFT_HEIGHT));
340
- const leftWindow = top.slice(start, start + LEFT_HEIGHT);
341
- let lastSection = "";
342
- const leftLines = leftWindow.flatMap((r, i) => {
343
- const idx = start + i;
344
- const section = sectionOf(r);
345
- const heading =
346
- section !== lastSection ? (
347
- <text key={`h${section}`} fg={tint("pink", ui.depth)} attributes={TextAttributes.BOLD}>
348
- {section}
349
- </text>
350
- ) : null;
351
- lastSection = section;
352
- const { text, dim, tag } = leftText(r);
353
- return [
354
- heading,
355
- <ListLine
356
- key={rowId(r)}
357
- ui={ui}
358
- selected={idx === leftCursor && focus === "left"}
359
- dim={dim}
360
- text={text}
361
- tag={tag}
362
- />,
363
- ];
364
- });
365
-
366
- // --- right pane ------------------------------------------------------------------------------
367
- const dstart = Math.max(
368
- 0,
369
- Math.min(rightAt - Math.floor(RIGHT_HEIGHT / 2), Math.max(0, detail.length - RIGHT_HEIGHT)),
370
- );
371
- const detailText = (r: DetailRow): { text: string; dim: boolean; tag: string | null } => {
372
- switch (r.kind) {
373
- case "backend": {
374
- const fix = notReady.get(r.backend as Backend);
375
- return {
376
- text: r.backend,
377
- dim: fix !== undefined,
378
- tag: fix === undefined ? null : `not ready${dotSep}${fix}`,
379
- };
380
- }
381
- case "model": {
382
- const efforts = profile.roles[r.role].models[r.model.id] ?? [];
383
- const on = efforts.length > 0;
384
- return {
385
- text: `${glyph(open.has(openKey(r) ?? "") ? "open" : "shut", ui.plain)} ${dot(on ? "ready" : "missing", ui.plain)} ${shortModelName(r.model.id)} ${efforts.join(" ")}`.trimEnd(),
386
- dim: !ready.has(r.model.backend),
387
- tag: r.model.id.includes("/") ? (r.model.id.split("/")[0] ?? r.model.id) : r.model.id,
388
- };
389
- }
390
- case "effort": {
391
- const rung = rungOf(r.model.id, r.effort);
392
- const like = catalog.treatLike[rung];
393
- return {
394
- text: ` ${dot(isTicked(profile, r) ? "ready" : "missing", ui.plain)} ${r.effort}`,
395
- dim: !ready.has(r.model.backend),
396
- tag: like ? `like ${shortRung(like)}` : entryFor(catalog, rung) ? null : "unscored",
397
- };
398
- }
399
- case "budget": {
400
- const v = profile.budget?.[r.field];
401
- return { text: r.field, dim: false, tag: v === undefined ? "no cap" : String(v) };
402
- }
403
- case "failover": {
404
- const to = profile.failover?.[r.rung];
405
- return {
406
- text: shortRung(r.rung),
407
- dim: false,
408
- tag: to ? `${glyph("arrow", ui.plain)} ${shortRung(to)}` : "no stand-in",
409
- };
410
- }
411
- }
412
- };
413
-
414
- const header = (): { title: string; subtitle: string } => {
415
- switch (selTop.kind) {
416
- case "role": {
417
- const on = profile.roles[selTop.role].enabled;
418
- return { title: selTop.role, subtitle: on ? "capable models and efforts" : "off" };
419
- }
420
- case "objective":
421
- return {
422
- title: "objective",
423
- subtitle: profile.objective === "cost" ? "cheapest rung first" : "fastest rung first",
424
- };
425
- case "lock":
426
- return { title: "heavy slots", subtitle: "how many roles catherd's lock treats as heavy" };
427
- case "harness":
428
- return { title: selTop.backend, subtitle: "vendor harness: native config or an isolated sandbox" };
429
- case "budget-summary":
430
- return { title: "budget", subtitle: "minutes, tokens and dollars this profile allows a run" };
431
- case "failover-summary":
432
- return { title: "failover", subtitle: "a stand-in rung for a codex/opencode quota limit" };
433
- case "notify":
434
- return { title: "notify", subtitle: "when catherd pings you" };
435
- }
436
- };
437
-
438
- const { title, subtitle } = header();
439
- const detailBody: ReactNode[] = [];
440
- if (selTop.kind === "harness") {
441
- const cost = costs.find((h) => h.backend === selTop.backend);
442
- detailBody.push(
443
- <text key="h" wrapMode="word">
444
- {harnessLine(selTop.backend, profile.harness[selTop.backend].isolated, cost, ui.plain)}
445
- </text>,
446
- );
447
- } else if (selTop.kind === "notify") {
448
- for (const m of ["milestone", "finish", "blocked"] as const) {
449
- const on = profile.notify.includes(m);
450
- detailBody.push(
451
- <text
452
- key={m}
453
- wrapMode="none"
454
- truncate
455
- attributes={m === selTop.moment ? TextAttributes.BOLD : TextAttributes.NONE}
456
- >
457
- {`${dot(on ? "ready" : "missing", ui.plain)} ${m}`}
458
- </text>,
459
- );
460
- }
461
- } else {
462
- detail.slice(dstart, dstart + RIGHT_HEIGHT).forEach((r, i) => {
463
- const idx = dstart + i;
464
- const { text, dim, tag } = detailText(r);
465
- detailBody.push(
466
- <ListLine
467
- key={rowId(r)}
468
- ui={ui}
469
- selected={focus === "right" && idx === rightAt}
470
- dim={dim}
471
- text={text}
472
- tag={tag}
473
- />,
474
- );
475
- });
476
- if (selTop.kind === "role") {
477
- const ladder = candidates(profile, catalog, selTop.role).map(
478
- (r) => `${shortModelName(splitRung(r).model)} ${splitRung(r).effort}`,
479
- );
480
- if (ladder.length > 0)
481
- detailBody.push(
482
- <text key="ladder" attributes={TextAttributes.DIM} wrapMode="word">
483
- {`ladder ${dotSep}${ladder.join(` ${glyph("arrow", ui.plain)} `)}`}
484
- </text>,
485
- );
486
- }
487
- }
488
-
489
- return (
490
- <box style={{ flexDirection: "row" }}>
491
- <box style={{ flexDirection: "column", width: 22 }}>{leftLines}</box>
492
- <DetailPane ui={ui} title={title} subtitle={subtitle}>
493
- {detailBody}
494
- {note ? (
495
- <text fg={tint("pink", ui.depth)} wrapMode="none" truncate>
496
- {note}
497
- </text>
498
- ) : null}
499
- {mode === "filter" ? (
500
- <text attributes={TextAttributes.DIM} wrapMode="none" truncate>{`filter: ${filter}_`}</text>
501
- ) : filter ? (
502
- <text attributes={TextAttributes.DIM} wrapMode="none" truncate>{`filter: ${filter}`}</text>
503
- ) : null}
504
- {errors.map((e) => (
505
- <text key={e} fg={tint("pink", ui.depth)} wrapMode="none" truncate>
506
- {e}
507
- </text>
508
- ))}
509
- </DetailPane>
510
- </box>
511
- );
512
- }
@@ -1,38 +0,0 @@
1
- import { existsSync, rmSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { z } from "zod";
4
- import { readJsonFile } from "../files.ts";
5
- import { configDir } from "../paths.ts";
6
- import { saveProfileAndAgents } from "../profile/agents.ts";
7
- import { activeProfileName, setActiveProfile } from "../profile/profile.ts";
8
- import type { Catalog, Profile } from "../types.ts";
9
-
10
- // `saveTreatLike` lives in `src/routing/catalog.ts` (OVERRIDES cross-plan fix 5); this module
11
- // adds the writers INTERFACES has none for: profile delete, name validation, and save-and-activate.
12
- const ProjectsSchema = z.record(z.string(), z.string());
13
-
14
- export function deleteProfile(name: string): void {
15
- if (name === activeProfileName()) {
16
- throw new Error(
17
- `"${name}" is the active profile; save another profile first, which makes that one active`,
18
- );
19
- }
20
- const projectsFile = join(configDir(), "projects.json");
21
- const bound = Object.entries(existsSync(projectsFile) ? readJsonFile(ProjectsSchema, projectsFile) : {})
22
- .filter(([, profile]) => profile === name)
23
- .map(([repo]) => repo);
24
- if (bound.length > 0) throw new Error(`"${name}" is bound to ${bound.join(", ")}`);
25
- rmSync(join(configDir(), "profiles", `${name}.json`), { force: true });
26
- rmSync(join(configDir(), "agents", name), { recursive: true, force: true });
27
- }
28
-
29
- export function nameError(name: string, taken: string[]): string | null {
30
- if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(name)) return "Use lowercase letters, digits and dashes, up to 32.";
31
- if (taken.includes(name)) return `"${name}" already exists.`;
32
- return null;
33
- }
34
-
35
- export function saveAndActivate(p: Profile, c: Catalog): void {
36
- saveProfileAndAgents(p, c);
37
- setActiveProfile(p.name);
38
- }