catherd-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/catalog/catalog.json +112 -0
- package/catalog/models-dev.json +56453 -0
- package/package.json +52 -0
- package/plugin/.claude-plugin/plugin.json +13 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/commands/catherd-setup.md +8 -0
- package/plugin/commands/catherd.md +10 -0
- package/plugin/skills/catherd/SKILL.md +301 -0
- package/plugin/skills/catherd-setup/SKILL.md +65 -0
- package/src/cli.ts +28 -0
- package/src/core/codex-events.ts +49 -0
- package/src/core/codex.ts +166 -0
- package/src/core/harness.ts +89 -0
- package/src/core/lanes.ts +67 -0
- package/src/core/lock.ts +111 -0
- package/src/core/opencode.ts +207 -0
- package/src/core/reconcile.ts +26 -0
- package/src/core/reply.ts +54 -0
- package/src/core/runstore.ts +163 -0
- package/src/core/spawn.ts +89 -0
- package/src/core/status.ts +193 -0
- package/src/files.ts +19 -0
- package/src/mcp/commands.ts +7 -0
- package/src/mcp/dispatch.ts +154 -0
- package/src/mcp/lane-tools.ts +200 -0
- package/src/mcp/out.ts +6 -0
- package/src/mcp/preflight.ts +69 -0
- package/src/mcp/run-tools.ts +121 -0
- package/src/mcp/runs.ts +118 -0
- package/src/mcp/server.ts +26 -0
- package/src/mcp/setup-tools.ts +175 -0
- package/src/paths.ts +20 -0
- package/src/profile/agents.ts +118 -0
- package/src/profile/profile.ts +228 -0
- package/src/profile/role-prompts.ts +129 -0
- package/src/routing/catalog.ts +227 -0
- package/src/routing/commands.ts +22 -0
- package/src/routing/jev.ts +181 -0
- package/src/routing/route.ts +155 -0
- package/src/routing/select.ts +90 -0
- package/src/tui/backends.ts +57 -0
- package/src/tui/commands.ts +51 -0
- package/src/tui/editor.tsx +221 -0
- package/src/tui/init.tsx +221 -0
- package/src/tui/matrix-model.ts +211 -0
- package/src/tui/matrix.tsx +350 -0
- package/src/tui/profiles.ts +38 -0
- package/src/tui/theme.ts +140 -0
- package/src/tui/ui.tsx +104 -0
- package/src/tui/watch-model.ts +83 -0
- package/src/tui/watch.tsx +258 -0
- package/src/types.ts +184 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { availableParallelism } from "node:os";
|
|
2
|
+
import { capableFor, entryFor, modelOf } from "../routing/catalog.ts";
|
|
3
|
+
import {
|
|
4
|
+
type Backend,
|
|
5
|
+
type Catalog,
|
|
6
|
+
type CatalogModel,
|
|
7
|
+
type NotifyMoment,
|
|
8
|
+
type Profile,
|
|
9
|
+
ROLES,
|
|
10
|
+
type Role,
|
|
11
|
+
type RungId,
|
|
12
|
+
rungOf,
|
|
13
|
+
splitRung,
|
|
14
|
+
} from "../types.ts";
|
|
15
|
+
import type { BackendStatus } from "./backends.ts";
|
|
16
|
+
|
|
17
|
+
export const NOTIFY: NotifyMoment[] = ["milestone", "finish", "blocked"];
|
|
18
|
+
const BACKENDS: Backend[] = ["claude", "codex", "opencode"];
|
|
19
|
+
|
|
20
|
+
export type BudgetField = "minutes" | "tokens" | "usd";
|
|
21
|
+
export const BUDGET_FIELDS: BudgetField[] = ["minutes", "tokens", "usd"];
|
|
22
|
+
|
|
23
|
+
export type Row =
|
|
24
|
+
| { kind: "role"; role: Role }
|
|
25
|
+
| { kind: "backend"; role: Role; backend: Backend }
|
|
26
|
+
| { kind: "harness"; role: Role; backend: "codex" | "opencode" }
|
|
27
|
+
| { kind: "model"; role: Role; model: CatalogModel }
|
|
28
|
+
| { kind: "effort"; role: Role; model: CatalogModel; effort: string }
|
|
29
|
+
| { kind: "objective" }
|
|
30
|
+
| { kind: "lock" }
|
|
31
|
+
| { kind: "budget"; field: BudgetField }
|
|
32
|
+
| { kind: "failover"; rung: RungId }
|
|
33
|
+
| { kind: "notify"; moment: NotifyMoment };
|
|
34
|
+
|
|
35
|
+
export type Toggled = { profile: Profile } | { needsTreatLike: RungId } | { refused: string };
|
|
36
|
+
|
|
37
|
+
export function rowId(r: Row): string {
|
|
38
|
+
switch (r.kind) {
|
|
39
|
+
case "role":
|
|
40
|
+
return `role:${r.role}`;
|
|
41
|
+
case "backend":
|
|
42
|
+
case "harness":
|
|
43
|
+
return `${r.kind}:${r.role}:${r.backend}`;
|
|
44
|
+
case "model":
|
|
45
|
+
return `model:${r.role}:${r.model.id}`;
|
|
46
|
+
case "effort":
|
|
47
|
+
return `effort:${r.role}:${rungOf(r.model.id, r.effort)}`;
|
|
48
|
+
case "budget":
|
|
49
|
+
return `budget:${r.field}`;
|
|
50
|
+
case "failover":
|
|
51
|
+
return `failover:${r.rung}`;
|
|
52
|
+
case "notify":
|
|
53
|
+
return `notify:${r.moment}`;
|
|
54
|
+
default:
|
|
55
|
+
return r.kind;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function openKey(r: Row): string | null {
|
|
60
|
+
if (r.kind === "role") return `role:${r.role}`;
|
|
61
|
+
if (r.kind === "model" || r.kind === "effort") return `model:${r.role}:${r.model.id}`;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readySet(backends: BackendStatus[]): Set<Backend> {
|
|
66
|
+
return new Set<Backend>([
|
|
67
|
+
"claude",
|
|
68
|
+
...backends.filter((b) => b.installed && b.loggedIn).map((b) => b.backend),
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function rows(p: Profile, c: Catalog, open: ReadonlySet<string>, filter = ""): Row[] {
|
|
73
|
+
const f = filter.toLowerCase();
|
|
74
|
+
const out: Row[] = [];
|
|
75
|
+
for (const role of ROLES) {
|
|
76
|
+
out.push({ kind: "role", role });
|
|
77
|
+
if (!f && !open.has(`role:${role}`)) continue;
|
|
78
|
+
const models = c.models.filter((m) => capableFor(role, m) && m.id.toLowerCase().includes(f));
|
|
79
|
+
for (const backend of BACKENDS) {
|
|
80
|
+
const group = models.filter((m) => m.backend === backend);
|
|
81
|
+
if (group.length === 0) continue;
|
|
82
|
+
out.push({ kind: "backend", role, backend });
|
|
83
|
+
if (backend !== "claude") out.push({ kind: "harness", role, backend });
|
|
84
|
+
for (const model of group) {
|
|
85
|
+
out.push({ kind: "model", role, model });
|
|
86
|
+
if (!open.has(`model:${role}:${model.id}`)) continue;
|
|
87
|
+
for (const effort of model.efforts) out.push({ kind: "effort", role, model, effort });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
out.push(
|
|
92
|
+
{ kind: "objective" },
|
|
93
|
+
{ kind: "lock" },
|
|
94
|
+
...BUDGET_FIELDS.map((field): Row => ({ kind: "budget", field })),
|
|
95
|
+
...enabledFailoverRungs(p, c).map((rung): Row => ({ kind: "failover", rung })),
|
|
96
|
+
...NOTIFY.map((moment): Row => ({ kind: "notify", moment })),
|
|
97
|
+
);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** spec §11b: every codex/opencode rung ticked by a currently-enabled role gets a failover row. */
|
|
102
|
+
export function enabledFailoverRungs(p: Profile, c: Catalog): RungId[] {
|
|
103
|
+
const set = new Set<RungId>();
|
|
104
|
+
for (const role of ROLES) {
|
|
105
|
+
if (!p.roles[role].enabled) continue;
|
|
106
|
+
for (const rung of ticked(p, role)) {
|
|
107
|
+
const backend = modelOf(c, splitRung(rung).model)?.backend;
|
|
108
|
+
if (backend === "codex" || backend === "opencode") set.add(rung);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return [...set].sort();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A failover stand-in must be scored (or treat-like) and on a different backend — the same rule
|
|
115
|
+
* `validateProfile` enforces, kept in sync here so the picker only ever offers a valid choice. */
|
|
116
|
+
export function failoverOptions(c: Catalog, rung: RungId): RungId[] {
|
|
117
|
+
const from = modelOf(c, splitRung(rung).model)?.backend;
|
|
118
|
+
const candidates = new Set<RungId>([...c.entries.map((e) => e.rung), ...Object.keys(c.treatLike)]);
|
|
119
|
+
return [...candidates]
|
|
120
|
+
.filter((r) => {
|
|
121
|
+
const backend = modelOf(c, splitRung(r).model)?.backend;
|
|
122
|
+
return backend !== undefined && backend !== from;
|
|
123
|
+
})
|
|
124
|
+
.sort();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isTicked(p: Profile, r: Row): boolean {
|
|
128
|
+
switch (r.kind) {
|
|
129
|
+
case "role":
|
|
130
|
+
return p.roles[r.role].enabled;
|
|
131
|
+
case "harness":
|
|
132
|
+
return p.harness[r.backend].isolated;
|
|
133
|
+
case "model":
|
|
134
|
+
return (p.roles[r.role].models[r.model.id]?.length ?? 0) > 0;
|
|
135
|
+
case "effort":
|
|
136
|
+
return p.roles[r.role].models[r.model.id]?.includes(r.effort) ?? false;
|
|
137
|
+
case "budget":
|
|
138
|
+
case "failover":
|
|
139
|
+
return false;
|
|
140
|
+
case "notify":
|
|
141
|
+
return p.notify.includes(r.moment);
|
|
142
|
+
default:
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function ticked(p: Profile, role: Role): RungId[] {
|
|
148
|
+
return Object.entries(p.roles[role].models).flatMap(([model, efforts]) =>
|
|
149
|
+
efforts.map((e) => rungOf(model, e)),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function nextLock(v: number | "cpus/2", max = availableParallelism()): number | "cpus/2" {
|
|
154
|
+
if (v === "cpus/2") return 1;
|
|
155
|
+
return v >= max ? "cpus/2" : v + 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function treatLikeOptions(c: Catalog, role: Role): RungId[] {
|
|
159
|
+
return c.entries
|
|
160
|
+
.map((e) => e.rung)
|
|
161
|
+
.filter((rung) => {
|
|
162
|
+
const m = modelOf(c, splitRung(rung).model);
|
|
163
|
+
return m !== undefined && capableFor(role, m);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function toggle(p: Profile, c: Catalog, r: Row, ready: ReadonlySet<Backend>): Toggled {
|
|
168
|
+
const next = structuredClone(p);
|
|
169
|
+
switch (r.kind) {
|
|
170
|
+
case "role":
|
|
171
|
+
if (r.role === "worker") return { refused: "The worker is always on." };
|
|
172
|
+
next.roles[r.role].enabled = !p.roles[r.role].enabled;
|
|
173
|
+
return { profile: next };
|
|
174
|
+
case "harness":
|
|
175
|
+
next.harness[r.backend].isolated = !p.harness[r.backend].isolated;
|
|
176
|
+
return { profile: next };
|
|
177
|
+
case "model":
|
|
178
|
+
delete next.roles[r.role].models[r.model.id];
|
|
179
|
+
return { profile: next };
|
|
180
|
+
case "effort": {
|
|
181
|
+
const models = next.roles[r.role].models;
|
|
182
|
+
const have = models[r.model.id] ?? [];
|
|
183
|
+
if (have.includes(r.effort)) {
|
|
184
|
+
const rest = have.filter((e) => e !== r.effort);
|
|
185
|
+
if (rest.length > 0) models[r.model.id] = rest;
|
|
186
|
+
else delete models[r.model.id];
|
|
187
|
+
return { profile: next };
|
|
188
|
+
}
|
|
189
|
+
if (!ready.has(r.model.backend)) return { refused: `${r.model.backend} is not ready yet.` };
|
|
190
|
+
const rung = rungOf(r.model.id, r.effort);
|
|
191
|
+
if (!entryFor(c, rung)) return { needsTreatLike: rung };
|
|
192
|
+
models[r.model.id] = r.model.efforts.filter((e) => e === r.effort || have.includes(e));
|
|
193
|
+
return { profile: next };
|
|
194
|
+
}
|
|
195
|
+
case "objective":
|
|
196
|
+
next.objective = p.objective === "cost" ? "speed" : "cost";
|
|
197
|
+
return { profile: next };
|
|
198
|
+
case "lock":
|
|
199
|
+
next.lock.heavy = nextLock(p.lock.heavy);
|
|
200
|
+
return { profile: next };
|
|
201
|
+
case "notify":
|
|
202
|
+
next.notify = NOTIFY.filter((m) => (m === r.moment ? !p.notify.includes(m) : p.notify.includes(m)));
|
|
203
|
+
return { profile: next };
|
|
204
|
+
case "backend":
|
|
205
|
+
return { profile: p };
|
|
206
|
+
case "budget":
|
|
207
|
+
case "failover":
|
|
208
|
+
// These open their own editor/picker in the Matrix component instead of toggling.
|
|
209
|
+
return { profile: p };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { useKeyboard } from "@opentui/react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { HarnessCost } from "../core/harness.ts";
|
|
5
|
+
import { entryFor, saveTreatLike } from "../routing/catalog.ts";
|
|
6
|
+
import { type Backend, type Catalog, type Profile, type RungId, rungOf } from "../types.ts";
|
|
7
|
+
import type { BackendStatus } from "./backends.ts";
|
|
8
|
+
import {
|
|
9
|
+
type BudgetField,
|
|
10
|
+
failoverOptions,
|
|
11
|
+
isTicked,
|
|
12
|
+
openKey,
|
|
13
|
+
readySet,
|
|
14
|
+
type Row,
|
|
15
|
+
rowId,
|
|
16
|
+
rows as buildRows,
|
|
17
|
+
ticked,
|
|
18
|
+
toggle,
|
|
19
|
+
treatLikeOptions,
|
|
20
|
+
} from "./matrix-model.ts";
|
|
21
|
+
import { costNote, glyph, HARNESS_HINT, shortRung, tint, type Ui } from "./theme.ts";
|
|
22
|
+
|
|
23
|
+
export interface MatrixProps {
|
|
24
|
+
ui: Ui;
|
|
25
|
+
profile: Profile;
|
|
26
|
+
catalog: Catalog;
|
|
27
|
+
backends: BackendStatus[];
|
|
28
|
+
costs?: HarnessCost[];
|
|
29
|
+
reloadCatalog: () => Catalog;
|
|
30
|
+
onChange: (p: Profile) => void;
|
|
31
|
+
onCatalog: (c: Catalog) => void;
|
|
32
|
+
onSubmit: () => void;
|
|
33
|
+
onQuit: () => void;
|
|
34
|
+
onKey?: (input: string) => void;
|
|
35
|
+
active?: boolean;
|
|
36
|
+
height?: number;
|
|
37
|
+
submitLabel?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type EffortRow = Extract<Row, { kind: "effort" }>;
|
|
41
|
+
type Mode =
|
|
42
|
+
| "nav"
|
|
43
|
+
| "filter"
|
|
44
|
+
| { row: EffortRow; rung: RungId }
|
|
45
|
+
| { failover: RungId }
|
|
46
|
+
| { budgetField: BudgetField; buffer: string };
|
|
47
|
+
|
|
48
|
+
export function Matrix(props: MatrixProps) {
|
|
49
|
+
const {
|
|
50
|
+
ui,
|
|
51
|
+
profile,
|
|
52
|
+
catalog,
|
|
53
|
+
backends,
|
|
54
|
+
costs = [],
|
|
55
|
+
active = true,
|
|
56
|
+
height = 14,
|
|
57
|
+
submitLabel = "save",
|
|
58
|
+
} = props;
|
|
59
|
+
const [cursor, setCursor] = useState(0);
|
|
60
|
+
const [open, setOpen] = useState<ReadonlySet<string>>(new Set());
|
|
61
|
+
const [filter, setFilter] = useState("");
|
|
62
|
+
const [mode, setMode] = useState<Mode>("nav");
|
|
63
|
+
const [note, setNote] = useState("");
|
|
64
|
+
const ready = readySet(backends);
|
|
65
|
+
const list = buildRows(profile, catalog, open, filter);
|
|
66
|
+
const at = Math.min(cursor, list.length - 1);
|
|
67
|
+
const here = list[at];
|
|
68
|
+
const pink = tint("pink", ui.depth);
|
|
69
|
+
const dot = ` ${glyph("dot", ui.plain)} `;
|
|
70
|
+
|
|
71
|
+
const apply = (row: Row, c: Catalog) => {
|
|
72
|
+
const t = toggle(profile, c, row, ready);
|
|
73
|
+
if ("profile" in t) {
|
|
74
|
+
props.onChange(t.profile);
|
|
75
|
+
setNote("");
|
|
76
|
+
} else if ("refused" in t) {
|
|
77
|
+
setNote(t.refused);
|
|
78
|
+
} else if (row.kind === "effort") {
|
|
79
|
+
if (treatLikeOptions(c, row.role).length === 0)
|
|
80
|
+
setNote(`No scored rung can stand in for ${t.needsTreatLike}.`);
|
|
81
|
+
else setMode({ row, rung: t.needsTreatLike });
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const collapse = (row: Row) => {
|
|
86
|
+
const own = openKey(row);
|
|
87
|
+
const key =
|
|
88
|
+
row.kind === "backend" || row.kind === "harness" || (row.kind === "model" && !open.has(own ?? ""))
|
|
89
|
+
? `role:${row.role}`
|
|
90
|
+
: own;
|
|
91
|
+
if (!key) return;
|
|
92
|
+
const next = new Set(open);
|
|
93
|
+
next.delete(key);
|
|
94
|
+
setOpen(next);
|
|
95
|
+
const idx = buildRows(profile, catalog, next, filter).findIndex(
|
|
96
|
+
(r) => (r.kind === "role" || r.kind === "model") && openKey(r) === key,
|
|
97
|
+
);
|
|
98
|
+
setCursor(Math.max(0, idx));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
useKeyboard((key) => {
|
|
102
|
+
if (!active || !here) return;
|
|
103
|
+
if (typeof mode !== "string") {
|
|
104
|
+
if (key.name === "escape") {
|
|
105
|
+
setMode("nav");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if ("budgetField" in mode) {
|
|
109
|
+
if (key.name === "return") {
|
|
110
|
+
const n = mode.buffer === "" ? undefined : Number(mode.buffer);
|
|
111
|
+
const next = structuredClone(profile);
|
|
112
|
+
const budget = { ...next.budget };
|
|
113
|
+
// ponytail: "1.2.3" parses to NaN and clears the cap rather than rejecting the keystroke;
|
|
114
|
+
// the digit/"." filter below already keeps the common case sane.
|
|
115
|
+
if (n === undefined || Number.isNaN(n)) delete budget[mode.budgetField];
|
|
116
|
+
else budget[mode.budgetField] = n;
|
|
117
|
+
next.budget = Object.keys(budget).length > 0 ? budget : undefined;
|
|
118
|
+
props.onChange(next);
|
|
119
|
+
setMode("nav");
|
|
120
|
+
} else if (key.name === "backspace") {
|
|
121
|
+
setMode({ ...mode, buffer: mode.buffer.slice(0, -1) });
|
|
122
|
+
} else if (key.sequence && /^[0-9.]$/.test(key.sequence)) {
|
|
123
|
+
setMode({ ...mode, buffer: mode.buffer + key.sequence });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (mode === "filter") {
|
|
129
|
+
if (key.name === "escape") {
|
|
130
|
+
setFilter("");
|
|
131
|
+
setMode("nav");
|
|
132
|
+
} else if (key.name === "return") setMode("nav");
|
|
133
|
+
else if (key.name === "backspace") setFilter((f) => f.slice(0, -1));
|
|
134
|
+
else if (!key.ctrl && !key.meta) {
|
|
135
|
+
const ch = key.name === "space" ? " " : key.sequence;
|
|
136
|
+
// Typed text can arrive as several keypress events inside one JS tick, before this
|
|
137
|
+
// component re-renders, so the update must build on the queued value, not the closure's.
|
|
138
|
+
if (ch && ch.length === 1) {
|
|
139
|
+
setFilter((f) => f + ch);
|
|
140
|
+
setCursor(0);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (key.name === "up") setCursor(Math.max(0, at - 1));
|
|
146
|
+
else if (key.name === "down") setCursor(Math.min(list.length - 1, at + 1));
|
|
147
|
+
else if (key.name === "right") {
|
|
148
|
+
const k = here.kind === "role" || here.kind === "model" ? openKey(here) : null;
|
|
149
|
+
if (k) setOpen(new Set(open).add(k));
|
|
150
|
+
} else if (key.name === "left") collapse(here);
|
|
151
|
+
else if (key.name === "return") props.onSubmit();
|
|
152
|
+
else if (key.name === "space") {
|
|
153
|
+
if (here.kind === "model" && !isTicked(profile, here))
|
|
154
|
+
setOpen(new Set(open).add(`model:${here.role}:${here.model.id}`));
|
|
155
|
+
else if (here.kind === "budget")
|
|
156
|
+
setMode({ budgetField: here.field, buffer: String(profile.budget?.[here.field] ?? "") });
|
|
157
|
+
else if (here.kind === "failover") setMode({ failover: here.rung });
|
|
158
|
+
else apply(here, catalog);
|
|
159
|
+
} else if (key.sequence === "/") setMode("filter");
|
|
160
|
+
else if (key.sequence === "q") props.onQuit();
|
|
161
|
+
else if (key.sequence) props.onKey?.(key.sequence);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (typeof mode !== "string" && "budgetField" in mode) {
|
|
165
|
+
return (
|
|
166
|
+
<box style={{ flexDirection: "column" }}>
|
|
167
|
+
<text wrapMode="none" truncate>{`${mode.budgetField}: ${mode.buffer}_`}</text>
|
|
168
|
+
<text attributes={TextAttributes.DIM}>
|
|
169
|
+
{`digits set the cap${dot}backspace edits${dot}enter saves${dot}esc cancels${dot}empty clears it`}
|
|
170
|
+
</text>
|
|
171
|
+
</box>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (typeof mode !== "string" && "failover" in mode) {
|
|
176
|
+
return (
|
|
177
|
+
<box style={{ flexDirection: "column" }}>
|
|
178
|
+
<text wrapMode="none" truncate>
|
|
179
|
+
{`Stand-in for ${mode.failover} on a quota limit:`}
|
|
180
|
+
</text>
|
|
181
|
+
<select
|
|
182
|
+
focused
|
|
183
|
+
showDescription={false}
|
|
184
|
+
style={{ height: 8 }}
|
|
185
|
+
options={[
|
|
186
|
+
{ name: "(none)", description: "", value: "" },
|
|
187
|
+
...failoverOptions(catalog, mode.failover).map((r) => ({ name: r, description: "", value: r })),
|
|
188
|
+
]}
|
|
189
|
+
onSelect={(_, option) => {
|
|
190
|
+
if (!option) return;
|
|
191
|
+
const next = structuredClone(profile);
|
|
192
|
+
const failover = { ...next.failover };
|
|
193
|
+
if (option.value) failover[mode.failover] = option.value as RungId;
|
|
194
|
+
else delete failover[mode.failover];
|
|
195
|
+
next.failover = Object.keys(failover).length > 0 ? failover : undefined;
|
|
196
|
+
props.onChange(next);
|
|
197
|
+
setMode("nav");
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
<text attributes={TextAttributes.DIM}>{`enter picks${dot}esc cancels`}</text>
|
|
201
|
+
</box>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof mode !== "string") {
|
|
206
|
+
return (
|
|
207
|
+
<box style={{ flexDirection: "column" }}>
|
|
208
|
+
<text wrapMode="none" truncate>
|
|
209
|
+
{`${mode.rung} has no scores yet. Treat it like which scored rung?`}
|
|
210
|
+
</text>
|
|
211
|
+
<select
|
|
212
|
+
focused
|
|
213
|
+
showDescription={false}
|
|
214
|
+
style={{ height: 8 }}
|
|
215
|
+
options={treatLikeOptions(catalog, mode.row.role).map((r) => ({
|
|
216
|
+
name: r,
|
|
217
|
+
description: "",
|
|
218
|
+
value: r,
|
|
219
|
+
}))}
|
|
220
|
+
onSelect={(_, option) => {
|
|
221
|
+
if (!option) return;
|
|
222
|
+
const like = option.value as RungId;
|
|
223
|
+
saveTreatLike(mode.rung, like);
|
|
224
|
+
const c = props.reloadCatalog();
|
|
225
|
+
props.onCatalog(c);
|
|
226
|
+
setMode("nav");
|
|
227
|
+
apply(mode.row, c);
|
|
228
|
+
}}
|
|
229
|
+
/>
|
|
230
|
+
<text attributes={TextAttributes.DIM}>{`enter picks${dot}esc cancels`}</text>
|
|
231
|
+
</box>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const notReady = new Map<Backend, string>(
|
|
236
|
+
backends.filter((b) => !(b.installed && b.loggedIn)).map((b) => [b.backend, b.fix ?? ""]),
|
|
237
|
+
);
|
|
238
|
+
const box = (on: boolean) => glyph(on ? "on" : "off", ui.plain);
|
|
239
|
+
const fold = (k: string) =>
|
|
240
|
+
glyph(open.has(k) || (filter !== "" && k.startsWith("role:")) ? "open" : "shut", ui.plain);
|
|
241
|
+
|
|
242
|
+
const describe = (r: Row): { text: string; dim: boolean; tag: string | null } => {
|
|
243
|
+
switch (r.kind) {
|
|
244
|
+
case "role": {
|
|
245
|
+
const on = profile.roles[r.role].enabled;
|
|
246
|
+
const rungs = ticked(profile, r.role).map(shortRung).join(", ") || "nothing ticked";
|
|
247
|
+
return {
|
|
248
|
+
text: `${fold(`role:${r.role}`)} ${box(on)} ${r.role.padEnd(12)}${on ? rungs : "off"}`,
|
|
249
|
+
dim: !on,
|
|
250
|
+
tag: null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
case "backend": {
|
|
254
|
+
const fix = notReady.get(r.backend);
|
|
255
|
+
return {
|
|
256
|
+
text: ` ${r.backend}`,
|
|
257
|
+
dim: fix !== undefined,
|
|
258
|
+
tag: fix === undefined ? null : `not ready${dot}${fix}`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
case "harness": {
|
|
262
|
+
const iso = profile.harness[r.backend].isolated;
|
|
263
|
+
const cost = costs.find((h) => h.backend === r.backend);
|
|
264
|
+
const sw = `${iso ? "native" : "[native]"} ${glyph("swap", ui.plain)} ${iso ? "[isolated]" : "isolated"}`;
|
|
265
|
+
return { text: ` ${r.backend}: ${sw}`, dim: false, tag: cost ? costNote(cost) || null : null };
|
|
266
|
+
}
|
|
267
|
+
case "model": {
|
|
268
|
+
const efforts = profile.roles[r.role].models[r.model.id] ?? [];
|
|
269
|
+
return {
|
|
270
|
+
text: ` ${fold(`model:${r.role}:${r.model.id}`)} ${box(efforts.length > 0)} ${r.model.id} ${efforts.join(" ")}`,
|
|
271
|
+
dim: !ready.has(r.model.backend),
|
|
272
|
+
tag: null,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
case "effort": {
|
|
276
|
+
const rung = rungOf(r.model.id, r.effort);
|
|
277
|
+
const like = catalog.treatLike[rung];
|
|
278
|
+
return {
|
|
279
|
+
text: ` ${box(isTicked(profile, r))} ${r.effort}`,
|
|
280
|
+
dim: !ready.has(r.model.backend),
|
|
281
|
+
tag: like ? `like ${shortRung(like)}` : entryFor(catalog, rung) ? null : "unscored",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
case "objective":
|
|
285
|
+
return {
|
|
286
|
+
text: `objective ${profile.objective} (${profile.objective === "cost" ? "cheapest rung first" : "fastest rung first"})`,
|
|
287
|
+
dim: false,
|
|
288
|
+
tag: null,
|
|
289
|
+
};
|
|
290
|
+
case "lock":
|
|
291
|
+
return { text: `heavy slots ${profile.lock.heavy} (for catherd lock)`, dim: false, tag: null };
|
|
292
|
+
case "budget": {
|
|
293
|
+
const v = profile.budget?.[r.field];
|
|
294
|
+
return {
|
|
295
|
+
text: `${`budget ${r.field}`.padEnd(16)}${v === undefined ? "no cap" : v}`,
|
|
296
|
+
dim: false,
|
|
297
|
+
tag: null,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
case "failover": {
|
|
301
|
+
const to = profile.failover?.[r.rung];
|
|
302
|
+
return {
|
|
303
|
+
text: `${`failover ${shortRung(r.rung)}`.padEnd(28)}${to ? `${glyph("arrow", ui.plain)} ${shortRung(to)}` : "no stand-in"}`,
|
|
304
|
+
dim: false,
|
|
305
|
+
tag: null,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
case "notify":
|
|
309
|
+
return {
|
|
310
|
+
text: `${box(profile.notify.includes(r.moment))} notify on ${r.moment}`,
|
|
311
|
+
dim: false,
|
|
312
|
+
tag: null,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const start = Math.max(0, Math.min(at - Math.floor(height / 2), list.length - height));
|
|
318
|
+
return (
|
|
319
|
+
<box style={{ flexDirection: "column" }}>
|
|
320
|
+
{list.slice(start, start + height).map((r, i) => {
|
|
321
|
+
const cur = start + i === at;
|
|
322
|
+
const { text, dim, tag } = describe(r);
|
|
323
|
+
const attrs = (cur ? TextAttributes.BOLD : 0) | (dim && !cur ? TextAttributes.DIM : 0);
|
|
324
|
+
return (
|
|
325
|
+
<text key={rowId(r)} wrapMode="none" truncate attributes={attrs}>
|
|
326
|
+
<span fg={pink}>{cur ? glyph("cursor", ui.plain) : " "}</span>
|
|
327
|
+
{` ${text}`}
|
|
328
|
+
{tag ? <span fg={pink}>{` ${tag}`}</span> : null}
|
|
329
|
+
</text>
|
|
330
|
+
);
|
|
331
|
+
})}
|
|
332
|
+
{here?.kind === "harness" ? (
|
|
333
|
+
<text attributes={TextAttributes.DIM} wrapMode="none" truncate>
|
|
334
|
+
{HARNESS_HINT[here.backend]}
|
|
335
|
+
</text>
|
|
336
|
+
) : null}
|
|
337
|
+
<text attributes={TextAttributes.DIM} wrapMode="none" truncate>
|
|
338
|
+
{mode === "filter"
|
|
339
|
+
? `filter: ${filter}_${dot}enter keeps it${dot}esc clears it`
|
|
340
|
+
: `${glyph("keys", ui.plain)} move${dot}${glyph("arrow", ui.plain)} open${dot}space ticks${dot}/ filter${dot}enter ${submitLabel}${dot}q quit`}
|
|
341
|
+
</text>
|
|
342
|
+
{filter && mode === "nav" ? <text attributes={TextAttributes.DIM}>{`filter: ${filter}`}</text> : null}
|
|
343
|
+
{note ? (
|
|
344
|
+
<text fg={pink} wrapMode="none" truncate>
|
|
345
|
+
{note}
|
|
346
|
+
</text>
|
|
347
|
+
) : null}
|
|
348
|
+
</box>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
}
|