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,57 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { Editor } from "./editor.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
|
+
export async function editorRun({ rawArgs }: { rawArgs: string[] }): Promise<void> {
|
|
32
|
+
if (!isBare(rawArgs)) return;
|
|
33
|
+
process.exitCode = await mount(createElement(Editor, { ui: detectUi(rawArgs) }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const initCommand = defineCommand({
|
|
37
|
+
meta: { name: "init", description: "First run: the Jev key, your backends, and a profile" },
|
|
38
|
+
args: uiArgs,
|
|
39
|
+
async run({ rawArgs }) {
|
|
40
|
+
const code = await mount(createElement(Init, { ui: detectUi(rawArgs) }));
|
|
41
|
+
process.exitCode = process.exitCode || code;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const watchCommand = defineCommand({
|
|
46
|
+
meta: { name: "watch", description: "Live view of running and recent runs" },
|
|
47
|
+
args: uiArgs,
|
|
48
|
+
async run({ rawArgs }) {
|
|
49
|
+
process.exitCode = await mount(createElement(Watch, { ui: detectUi(rawArgs) }));
|
|
50
|
+
},
|
|
51
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { type HarnessCost, harnessCosts } from "../core/harness.ts";
|
|
5
|
+
import { listRuns } from "../core/runstore.ts";
|
|
6
|
+
import {
|
|
7
|
+
activeProfileName,
|
|
8
|
+
defaultProfile,
|
|
9
|
+
listProfiles,
|
|
10
|
+
loadProfile,
|
|
11
|
+
validateProfile,
|
|
12
|
+
} from "../profile/profile.ts";
|
|
13
|
+
import { loadCatalog } from "../routing/catalog.ts";
|
|
14
|
+
import type { Catalog, Profile } from "../types.ts";
|
|
15
|
+
import { type BackendStatus, detectBackends } from "./backends.ts";
|
|
16
|
+
import { Matrix } from "./matrix.tsx";
|
|
17
|
+
import { deleteProfile, nameError, saveAndActivate } from "./profiles.ts";
|
|
18
|
+
import { face, glyph, type Mood, tint, type Ui } from "./theme.ts";
|
|
19
|
+
import { CatSpinner, Header, Screen } from "./ui.tsx";
|
|
20
|
+
|
|
21
|
+
export interface EditorDeps {
|
|
22
|
+
loadCatalog: () => Catalog;
|
|
23
|
+
detectBackends: () => Promise<BackendStatus[]>;
|
|
24
|
+
harnessCosts: () => HarnessCost[];
|
|
25
|
+
save: (p: Profile, c: Catalog) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const editorDeps: EditorDeps = {
|
|
29
|
+
loadCatalog,
|
|
30
|
+
detectBackends,
|
|
31
|
+
harnessCosts: () => harnessCosts(listRuns().map((r) => r.dir)),
|
|
32
|
+
save: saveAndActivate,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Ask = "new" | "copy" | "delete" | "quit" | null;
|
|
36
|
+
|
|
37
|
+
function openBook(): { profiles: Profile[]; idx: number } {
|
|
38
|
+
const names = listProfiles();
|
|
39
|
+
const profiles = names.length > 0 ? names.map((n) => loadProfile(n)) : [defaultProfile()];
|
|
40
|
+
const active = activeProfileName();
|
|
41
|
+
return {
|
|
42
|
+
profiles,
|
|
43
|
+
idx: Math.max(
|
|
44
|
+
0,
|
|
45
|
+
profiles.findIndex((p) => p.name === active),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function Editor({ ui, deps = {} }: { ui: Ui; deps?: Partial<EditorDeps> }) {
|
|
51
|
+
const d = { ...editorDeps, ...deps };
|
|
52
|
+
const renderer = useRenderer();
|
|
53
|
+
const exit = () => renderer.destroy();
|
|
54
|
+
const [catalog, setCatalog] = useState(d.loadCatalog);
|
|
55
|
+
const [costs] = useState(d.harnessCosts);
|
|
56
|
+
const [backends, setBackends] = useState<BackendStatus[] | null>(null);
|
|
57
|
+
const [book, setBook] = useState(openBook);
|
|
58
|
+
const [active, setActive] = useState(activeProfileName);
|
|
59
|
+
const [dirty, setDirty] = useState<ReadonlySet<string>>(new Set());
|
|
60
|
+
const [errors, setErrors] = useState<string[]>([]);
|
|
61
|
+
const [note, setNote] = useState("");
|
|
62
|
+
const [ask, setAsk] = useState<Ask>(null);
|
|
63
|
+
const profile = book.profiles[book.idx] as Profile;
|
|
64
|
+
const pink = tint("pink", ui.depth);
|
|
65
|
+
const dot = ` ${glyph("dot", ui.plain)} `;
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
d.detectBackends().then(setBackends, () => setBackends([]));
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const mark = (name: string, on: boolean) => {
|
|
72
|
+
const next = new Set(dirty);
|
|
73
|
+
if (on) next.add(name);
|
|
74
|
+
else next.delete(name);
|
|
75
|
+
setDirty(next);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const put = (p: Profile) => {
|
|
79
|
+
setBook({ profiles: book.profiles.map((x, i) => (i === book.idx ? p : x)), idx: book.idx });
|
|
80
|
+
mark(p.name, true);
|
|
81
|
+
setErrors([]);
|
|
82
|
+
setNote("");
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const save = () => {
|
|
86
|
+
const errs = validateProfile(profile, catalog);
|
|
87
|
+
setErrors(errs);
|
|
88
|
+
if (errs.length > 0) return;
|
|
89
|
+
d.save(profile, catalog);
|
|
90
|
+
mark(profile.name, false);
|
|
91
|
+
setActive(profile.name);
|
|
92
|
+
setNote(`Saved ${profile.name}; it is active now. New Claude Code sessions get its agents.`);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const add = (name: string) => {
|
|
96
|
+
const err = nameError(
|
|
97
|
+
name,
|
|
98
|
+
book.profiles.map((p) => p.name),
|
|
99
|
+
);
|
|
100
|
+
setAsk(null);
|
|
101
|
+
if (err) {
|
|
102
|
+
setNote(err);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const base = ask === "new" ? defaultProfile() : structuredClone(profile);
|
|
106
|
+
setBook({ profiles: [...book.profiles, { ...base, name }], idx: book.profiles.length });
|
|
107
|
+
mark(name, true);
|
|
108
|
+
setNote("");
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const remove = () => {
|
|
112
|
+
if (book.profiles.length === 1) {
|
|
113
|
+
setNote("Keep at least one profile.");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
if (listProfiles().includes(profile.name)) deleteProfile(profile.name);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
setNote((e as Error).message);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
mark(profile.name, false);
|
|
123
|
+
setBook({ profiles: book.profiles.filter((_, i) => i !== book.idx), idx: 0 });
|
|
124
|
+
setNote(`Deleted ${profile.name}.`);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const onKey = (input: string) => {
|
|
128
|
+
setNote("");
|
|
129
|
+
if (input === "p") {
|
|
130
|
+
setBook({ ...book, idx: (book.idx + 1) % book.profiles.length });
|
|
131
|
+
setErrors([]);
|
|
132
|
+
} else if (input === "n") setAsk("new");
|
|
133
|
+
else if (input === "c") setAsk("copy");
|
|
134
|
+
else if (input === "x") setAsk("delete");
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
useKeyboard((key) => {
|
|
138
|
+
if (ask === "new" || ask === "copy") {
|
|
139
|
+
if (key.name === "escape") setAsk(null);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (ask === "quit") {
|
|
143
|
+
if (key.sequence === "q") exit();
|
|
144
|
+
setAsk(null);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (ask === "delete") {
|
|
148
|
+
if (key.sequence === "y") remove();
|
|
149
|
+
setAsk(null);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const mood: Mood =
|
|
154
|
+
backends === null
|
|
155
|
+
? "working"
|
|
156
|
+
: errors.length > 0
|
|
157
|
+
? "failed"
|
|
158
|
+
: dirty.has(profile.name)
|
|
159
|
+
? "waiting"
|
|
160
|
+
: "good";
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Screen>
|
|
164
|
+
<Header ui={ui} mood={mood} title={`profiles${dot}${profile.name}`} />
|
|
165
|
+
<text wrapMode="none" truncate>
|
|
166
|
+
{`profile ${profile.name} ${book.idx + 1}/${book.profiles.length}${
|
|
167
|
+
profile.name === active ? " (active)" : ""
|
|
168
|
+
}${dirty.has(profile.name) ? " (unsaved)" : ""}`}
|
|
169
|
+
<span attributes={TextAttributes.DIM}>{`${dot}p next${dot}n new${dot}c copy${dot}x delete`}</span>
|
|
170
|
+
</text>
|
|
171
|
+
{backends === null ? (
|
|
172
|
+
<CatSpinner ui={ui} label="sniffing out codex and opencode" />
|
|
173
|
+
) : (
|
|
174
|
+
<Matrix
|
|
175
|
+
key={profile.name}
|
|
176
|
+
ui={ui}
|
|
177
|
+
profile={profile}
|
|
178
|
+
catalog={catalog}
|
|
179
|
+
backends={backends}
|
|
180
|
+
costs={costs}
|
|
181
|
+
reloadCatalog={d.loadCatalog}
|
|
182
|
+
onChange={put}
|
|
183
|
+
onCatalog={setCatalog}
|
|
184
|
+
onSubmit={save}
|
|
185
|
+
onQuit={() => (dirty.size > 0 ? setAsk("quit") : exit())}
|
|
186
|
+
onKey={onKey}
|
|
187
|
+
active={ask === null}
|
|
188
|
+
/>
|
|
189
|
+
)}
|
|
190
|
+
{ask === "new" || ask === "copy" ? (
|
|
191
|
+
<box style={{ flexDirection: "row" }}>
|
|
192
|
+
<text>
|
|
193
|
+
{ask === "new" ? "Name for the new profile: " : `Name for the copy of ${profile.name}: `}
|
|
194
|
+
</text>
|
|
195
|
+
{/* React 19's DOMAttributes adds a DOM `onSubmit(event: SubmitEvent)` overload to every
|
|
196
|
+
intrinsic element, including OpenTUI's non-DOM <input>; `as any` sidesteps that
|
|
197
|
+
phantom overload rather than fighting it with a full-signature cast. */}
|
|
198
|
+
<input focused onSubmit={add as any} />
|
|
199
|
+
</box>
|
|
200
|
+
) : null}
|
|
201
|
+
{ask === "delete" ? (
|
|
202
|
+
<text fg={pink}>{`Delete profile ${profile.name}? y deletes it, any other key keeps it.`}</text>
|
|
203
|
+
) : null}
|
|
204
|
+
{ask === "quit" ? (
|
|
205
|
+
<text fg={pink} wrapMode="none" truncate>
|
|
206
|
+
{`Unsaved changes in ${[...dirty].join(", ")}. q quits without saving, any other key stays.`}
|
|
207
|
+
</text>
|
|
208
|
+
) : null}
|
|
209
|
+
{errors.map((e) => (
|
|
210
|
+
<text key={e} fg={pink} wrapMode="none" truncate>
|
|
211
|
+
{`${face("failed", ui.plain)} ${e}`}
|
|
212
|
+
</text>
|
|
213
|
+
))}
|
|
214
|
+
{note ? (
|
|
215
|
+
<text wrapMode="none" truncate>
|
|
216
|
+
{note}
|
|
217
|
+
</text>
|
|
218
|
+
) : null}
|
|
219
|
+
</Screen>
|
|
220
|
+
);
|
|
221
|
+
}
|
package/src/tui/init.tsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import { type HarnessCost, harnessCosts } from "../core/harness.ts";
|
|
5
|
+
import { listRuns } from "../core/runstore.ts";
|
|
6
|
+
import { defaultProfile, validateProfile } from "../profile/profile.ts";
|
|
7
|
+
import { loadCatalog } from "../routing/catalog.ts";
|
|
8
|
+
import { jevKey, saveJevKey, testJevKey } from "../routing/jev.ts";
|
|
9
|
+
import type { Catalog, Profile } from "../types.ts";
|
|
10
|
+
import { type BackendStatus, detectBackends } from "./backends.ts";
|
|
11
|
+
import { Matrix } from "./matrix.tsx";
|
|
12
|
+
import { saveAndActivate } from "./profiles.ts";
|
|
13
|
+
import { face, glyph, type Mood, tint, type Ui } from "./theme.ts";
|
|
14
|
+
import { CatSpinner, Header, Intro, Screen } from "./ui.tsx";
|
|
15
|
+
|
|
16
|
+
export interface InitDeps {
|
|
17
|
+
jevKey: () => string | null;
|
|
18
|
+
testJevKey: (key: string) => Promise<boolean>;
|
|
19
|
+
saveJevKey: (key: string) => void;
|
|
20
|
+
detectBackends: () => Promise<BackendStatus[]>;
|
|
21
|
+
loadCatalog: () => Catalog;
|
|
22
|
+
harnessCosts: () => HarnessCost[];
|
|
23
|
+
save: (p: Profile, c: Catalog) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const initDeps: InitDeps = {
|
|
27
|
+
jevKey,
|
|
28
|
+
testJevKey,
|
|
29
|
+
saveJevKey,
|
|
30
|
+
detectBackends,
|
|
31
|
+
loadCatalog,
|
|
32
|
+
harnessCosts: () => harnessCosts(listRuns().map((r) => r.dir)),
|
|
33
|
+
save: saveAndActivate,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const PLUGIN_STEPS = ["/plugin marketplace add 47vigen/catherd", "/plugin install catherd@catherd"];
|
|
37
|
+
|
|
38
|
+
type Phase = "intro" | "key" | "testing" | "backends" | "matrix" | "done";
|
|
39
|
+
|
|
40
|
+
const TITLES: Record<Phase, string> = {
|
|
41
|
+
intro: "",
|
|
42
|
+
key: "init 1/3: the Jev key",
|
|
43
|
+
testing: "init 1/3: the Jev key",
|
|
44
|
+
backends: "init 2/3: your backends",
|
|
45
|
+
matrix: "init 3/3: your profile",
|
|
46
|
+
done: "ready",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function BackendLine({ ui, b }: { ui: Ui; b: BackendStatus }) {
|
|
50
|
+
const ok = b.installed && b.loggedIn;
|
|
51
|
+
const state = !b.installed ? "not installed" : b.loggedIn ? "logged in" : "not logged in";
|
|
52
|
+
const fix = b.fix ? ` ${glyph("dot", ui.plain)} fix: ${b.fix}` : "";
|
|
53
|
+
return (
|
|
54
|
+
<text wrapMode="none" truncate attributes={ok ? TextAttributes.NONE : TextAttributes.DIM}>
|
|
55
|
+
{`${glyph(ok ? "on" : "off", ui.plain)} ${b.backend.padEnd(9)}${(b.version ?? "").padEnd(9)}${state}${fix}`}
|
|
56
|
+
</text>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** No masked input ships with OpenTUI, so the key is kept in state and shown as asterisks. */
|
|
61
|
+
function MaskedKeyInput({ onSubmit }: { onSubmit: (key: string) => void }) {
|
|
62
|
+
const [value, setValue] = useState("");
|
|
63
|
+
useKeyboard((key) => {
|
|
64
|
+
if (key.name === "return") {
|
|
65
|
+
onSubmit(value);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (key.name === "backspace") {
|
|
69
|
+
setValue((v) => v.slice(0, -1));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!key.ctrl && !key.meta) {
|
|
73
|
+
const ch = key.name === "space" ? " " : key.sequence;
|
|
74
|
+
if (ch && ch.length === 1) setValue((v) => v + ch);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return <text>{"*".repeat(value.length)}</text>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function Init({ ui, deps = {} }: { ui: Ui; deps?: Partial<InitDeps> }) {
|
|
81
|
+
const d = { ...initDeps, ...deps };
|
|
82
|
+
const renderer = useRenderer();
|
|
83
|
+
const started = useRef(false);
|
|
84
|
+
const [phase, setPhase] = useState<Phase>("intro");
|
|
85
|
+
const [note, setNote] = useState("");
|
|
86
|
+
const [catalog, setCatalog] = useState(d.loadCatalog);
|
|
87
|
+
const [costs] = useState(d.harnessCosts);
|
|
88
|
+
const [profile, setProfile] = useState<Profile>(defaultProfile);
|
|
89
|
+
const [backends, setBackends] = useState<BackendStatus[]>([]);
|
|
90
|
+
const [errors, setErrors] = useState<string[]>([]);
|
|
91
|
+
const pink = tint("pink", ui.depth);
|
|
92
|
+
|
|
93
|
+
// Any key finishes, once the "done" message has actually had a chance to sit on screen:
|
|
94
|
+
// destroying the renderer immediately tears down the render tree, so a frame captured
|
|
95
|
+
// right after would show nothing, and a real terminal would lose the message the same way.
|
|
96
|
+
useKeyboard(() => {
|
|
97
|
+
if (phase === "done") renderer.destroy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const check = async (key: string, typed: boolean) => {
|
|
101
|
+
setPhase("testing");
|
|
102
|
+
const ok = await d.testJevKey(key).catch(() => false);
|
|
103
|
+
if (!ok) {
|
|
104
|
+
setNote(
|
|
105
|
+
typed
|
|
106
|
+
? "Jev did not accept that key. Check it at typesafe.ai and paste it again."
|
|
107
|
+
: "The saved Jev key did not answer. Paste a new one.",
|
|
108
|
+
);
|
|
109
|
+
setPhase("key");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (typed) d.saveJevKey(key);
|
|
113
|
+
setNote("");
|
|
114
|
+
setPhase("backends");
|
|
115
|
+
setBackends(await d.detectBackends());
|
|
116
|
+
setPhase("matrix");
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const afterIntro = () => {
|
|
120
|
+
if (started.current) return;
|
|
121
|
+
started.current = true;
|
|
122
|
+
const saved = d.jevKey();
|
|
123
|
+
if (saved) void check(saved, false);
|
|
124
|
+
else setPhase("key");
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const accept = () => {
|
|
128
|
+
const errs = validateProfile(profile, catalog);
|
|
129
|
+
setErrors(errs);
|
|
130
|
+
if (errs.length > 0) return;
|
|
131
|
+
d.save(profile, catalog);
|
|
132
|
+
setPhase("done");
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (phase === "intro") {
|
|
136
|
+
return (
|
|
137
|
+
<Screen>
|
|
138
|
+
<Intro ui={ui} onDone={afterIntro} />
|
|
139
|
+
</Screen>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const mood: Mood =
|
|
144
|
+
phase === "done"
|
|
145
|
+
? "landed"
|
|
146
|
+
: errors.length > 0 || note
|
|
147
|
+
? "failed"
|
|
148
|
+
: phase === "key"
|
|
149
|
+
? "waiting"
|
|
150
|
+
: phase === "matrix"
|
|
151
|
+
? "good"
|
|
152
|
+
: "working";
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Screen>
|
|
156
|
+
<Header ui={ui} mood={mood} title={TITLES[phase]} />
|
|
157
|
+
{phase === "key" ? (
|
|
158
|
+
<box style={{ flexDirection: "column" }}>
|
|
159
|
+
<text>Paste your TypeSafe API key. Jev uses it to pick each lane's model and effort.</text>
|
|
160
|
+
<box style={{ flexDirection: "row" }}>
|
|
161
|
+
<text>key: </text>
|
|
162
|
+
<MaskedKeyInput
|
|
163
|
+
onSubmit={(k) => {
|
|
164
|
+
if (k.trim()) void check(k.trim(), true);
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
</box>
|
|
168
|
+
</box>
|
|
169
|
+
) : null}
|
|
170
|
+
{phase === "testing" ? <CatSpinner ui={ui} label="asking Jev a test question" /> : null}
|
|
171
|
+
{phase === "backends" ? <CatSpinner ui={ui} label="sniffing out codex and opencode" /> : null}
|
|
172
|
+
{phase === "matrix" ? (
|
|
173
|
+
<box style={{ flexDirection: "column" }}>
|
|
174
|
+
{backends.map((b) => (
|
|
175
|
+
<BackendLine key={b.backend} ui={ui} b={b} />
|
|
176
|
+
))}
|
|
177
|
+
<Matrix
|
|
178
|
+
ui={ui}
|
|
179
|
+
profile={profile}
|
|
180
|
+
catalog={catalog}
|
|
181
|
+
backends={backends}
|
|
182
|
+
costs={costs}
|
|
183
|
+
reloadCatalog={d.loadCatalog}
|
|
184
|
+
onChange={(p) => {
|
|
185
|
+
setProfile(p);
|
|
186
|
+
setErrors([]);
|
|
187
|
+
}}
|
|
188
|
+
onCatalog={setCatalog}
|
|
189
|
+
onSubmit={accept}
|
|
190
|
+
onQuit={() => {
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
renderer.destroy();
|
|
193
|
+
}}
|
|
194
|
+
submitLabel="accept"
|
|
195
|
+
/>
|
|
196
|
+
{errors.map((e) => (
|
|
197
|
+
<text key={e} fg={pink} wrapMode="none" truncate>
|
|
198
|
+
{`${face("failed", ui.plain)} ${e}`}
|
|
199
|
+
</text>
|
|
200
|
+
))}
|
|
201
|
+
</box>
|
|
202
|
+
) : null}
|
|
203
|
+
{phase === "done" ? (
|
|
204
|
+
<box style={{ flexDirection: "column" }}>
|
|
205
|
+
<text>{`Saved profile ${profile.name} and its Claude agents.`}</text>
|
|
206
|
+
<text>Now add the plugin in Claude Code:</text>
|
|
207
|
+
{PLUGIN_STEPS.map((s) => (
|
|
208
|
+
<text key={s} fg={tint("ginger", ui.depth)}>{` ${s}`}</text>
|
|
209
|
+
))}
|
|
210
|
+
<text>Then start a new Claude Code session: it picks up the agents.</text>
|
|
211
|
+
<text attributes={TextAttributes.DIM}>Press any key to finish.</text>
|
|
212
|
+
</box>
|
|
213
|
+
) : null}
|
|
214
|
+
{note ? (
|
|
215
|
+
<text fg={pink} wrapMode="none" truncate>
|
|
216
|
+
{note}
|
|
217
|
+
</text>
|
|
218
|
+
) : null}
|
|
219
|
+
</Screen>
|
|
220
|
+
);
|
|
221
|
+
}
|