ff39a5e8 Andy McCall 1 day ago ↶
1
// Setting Spectra up without the command line (spec §5.0). Each step runs
2
// a spectra command; every confirmation spectra would ask at a terminal is
3
// a dialog here, after which the command runs with --yes or --expect.
4
import * as cp from "node:child_process";
5
import * as fs from "node:fs";
6
import * as os from "node:os";
7
import * as path from "node:path";
8
import * as vscode from "vscode";
9
import * as cli from "./cli";
10
import { EXPIRIES, KINDS, expandHome, mergeMcpConfig, nameFromFolder, shimArgs, suggestAgentName, validName } from "./pure";
11
import type { Agent, Project } from "./spectra-api";
12
13
export interface Hooks {
14
/** After a step that changes what Spectra is or who signs. */
15
changed(): Promise<void>;
16
projects(): Project[];
17
agents(): Agent[];
18
folderProject(): string | undefined;
19
}
20
21
function folder(): vscode.WorkspaceFolder | undefined {
22
return vscode.workspace.workspaceFolders?.[0];
23
}
24
25
async function fail(e: unknown): Promise<undefined> {
26
void vscode.window.showErrorMessage(`Spectra: ${(e as Error).message}`);
27
return undefined;
28
}
29
30
export async function checkBinary(): Promise<boolean> {
31
const bin = cli.binary();
32
if (bin) {
33
const r = await cli.run(["--version"]);
34
if (r.code === 0) {
35
void vscode.window.showInformationMessage(`Found ${r.stdout.trim()} at ${bin}.`);
36
return true;
37
}
38
}
39
const pick = await vscode.window.showWarningMessage(
40
"The spectra program was not found. Install it (make install in the spectra repository puts it in ~/.local/bin), or point Spectra at it.",
41
"Locate spectra…",
42
);
43
if (pick) {
44
const f = await vscode.window.showOpenDialog({ canSelectMany: false, openLabel: "Use this spectra" });
45
if (f?.[0]) {
46
await vscode.workspace.getConfiguration("spectra").update("path", f[0].fsPath, vscode.ConfigurationTarget.Global);
47
return checkBinary();
48
}
49
}
50
return false;
51
}
52
53
export async function createIdentity(h: Hooks): Promise<void> {
54
const st = await cli.status().catch(() => undefined);
55
if (st?.identity) {
56
void vscode.window.showInformationMessage(`You already have an identity: ${st.identity.name}, key ${st.identity.key.slice(0, 12)}…`);
57
return;
58
}
59
const name = await vscode.window.showInputBox({
60
title: "Your identity (1/2)",
61
prompt: "Your name, as your agents and Radar will show it",
62
value: os.userInfo().username,
63
validateInput: (v) => (v.trim() ? undefined : "A name, please"),
64
});
65
if (!name) return;
66
const contact = await vscode.window.showInputBox({ title: "Your identity (2/2)", prompt: "An email or URL to show beside it (optional)" });
67
if (contact === undefined) return;
68
try {
69
const made = await cli.json<{ name: string; key: string; home: string }>(["identity", "create", "--name", name.trim(), "--contact", contact.trim(), "--json"]);
70
const copy = await vscode.window.showInformationMessage(
71
`Created your identity, ${made.name}. Its key is ${made.key.slice(0, 12)}…; the keys folder in ${made.home} is secret: anyone with it can act as you.`,
72
"Copy Key",
73
);
74
if (copy) await vscode.env.clipboard.writeText(made.key);
75
await h.changed();
76
} catch (e) {
77
await fail(e);
78
}
79
}
80
81
export async function setupHere(h: Hooks): Promise<void> {
82
const ok = await vscode.window.showInformationMessage(
83
"Run Spectra on this machine?",
84
{ modal: true, detail: "This makes its ledger in ~/.local/share/spectra, owned by your identity, and starts it. Agents on this machine connect to it." },
85
"Run Here",
86
);
87
if (!ok) return;
88
try {
89
const st = await cli.status();
90
if (!st.local.initialised) await cli.ok(["init"]);
91
if (!(await cli.status()).local.serving) startDaemon();
92
await new Promise((r) => setTimeout(r, 1500));
93
await h.changed();
94
} catch (e) {
95
await fail(e);
96
}
97
}
98
99
/** The user service if it is installed, else a terminal running spectra serve. */
100
export function startDaemon(): void {
101
const unit = path.join(os.homedir(), ".config", "systemd", "user", "spectra.service");
102
if (fs.existsSync(unit)) {
103
cp.execFile("systemctl", ["--user", "start", "spectra"], () => undefined);
104
return;
105
}
106
const bin = cli.binary();
107
if (!bin) return;
108
const t = vscode.window.createTerminal({ name: "Spectra", shellPath: bin, shellArgs: ["serve"], isTransient: false });
109
t.show(true);
110
}
111
112
export async function connectServer(h: Hooks): Promise<void> {
113
const url = await vscode.window.showInputBox({
114
title: "Connect to a server",
115
prompt: "The server's address",
116
placeHolder: "https://earth.example:7350",
117
validateInput: (v) => (/^https:\/\/[^/\s]+/.test(v.trim()) ? undefined : "An https:// address"),
118
});
119
if (!url) return;
120
const st = await cli.status().catch(() => undefined);
121
let probe: { name: string; instance: string; owner: string | null; waiting_for: string | null; yours: boolean };
122
try {
123
probe = await cli.json(["probe", url.trim(), "--json"]);
124
} catch (e) {
125
const msg = (e as Error).message;
126
const copy = await vscode.window.showErrorMessage(
127
`Spectra: ${msg}. If the server is not set up yet, run there: spectra init --owner ${st?.identity?.key ?? "YOUR-KEY"}`,
128
...(st?.identity ? ["Copy That Command"] : []),
129
);
130
if (copy && st?.identity) await vscode.env.clipboard.writeText(`spectra init --owner ${st.identity.key}`);
131
return;
132
}
133
if (!probe.yours) {
134
const whose = probe.owner ? "belongs to someone else" : `waits for another identity (${(probe.waiting_for ?? "").slice(0, 12)}…)`;
135
const copy = await vscode.window.showErrorMessage(
136
`Spectra: ${probe.name} ${whose}. To set it up for you, run there: spectra init --owner ${st?.identity?.key ?? "YOUR-KEY"}`,
137
...(st?.identity ? ["Copy That Command"] : []),
138
);
139
if (copy && st?.identity) await vscode.env.clipboard.writeText(`spectra init --owner ${st.identity.key}`);
140
return;
141
}
142
const grouped = probe.instance.match(/.{1,4}/g)!.join(" ");
143
const trust = await vscode.window.showWarningMessage(
144
`Trust ${probe.name} as your Spectra?`,
145
{
146
modal: true,
147
detail: `Its instance key is:\n\n${grouped}\n\nWhen the server was set up, spectra init printed its instance key. Trust it only if this is the same. Your identity will then delegate to it, and this machine will pin its certificate.`,
148
},
149
"Trust",
150
);
151
if (!trust) return;
152
try {
153
await cli.ok(["connect", url.trim(), "--expect", probe.instance]);
154
void vscode.window.showInformationMessage(`Connected to ${probe.name}.`);
155
await h.changed();
156
} catch (e) {
157
await fail(e);
158
}
159
}
160
161
export async function registerFolder(h: Hooks): Promise<void> {
162
const f = folder();
163
if (!f) {
164
void vscode.window.showWarningMessage("Open a folder first: a project is the work in it.");
165
return;
166
}
167
const already = h.folderProject();
168
if (already) {
169
void vscode.window.showInformationMessage(`This folder is already the project ${already}.`);
170
return;
171
}
172
const taken = h.projects().map((p) => p.name);
173
const name = await vscode.window.showInputBox({
174
title: "Register this folder as a project (1/2)",
175
prompt: "The project's name; its channel is #name",
176
value: nameFromFolder(f.uri.fsPath),
177
validateInput: (v) => (!validName(v) ? "Lower-case letters, digits and dashes" : taken.includes(v) ? "Taken" : undefined),
178
});
179
if (!name) return;
180
const description = await vscode.window.showInputBox({ title: "Register this folder as a project (2/2)", prompt: "What the work is, for your agents (optional)" });
181
if (description === undefined) return;
182
try {
183
await cli.ok(["project", "add", f.uri.fsPath, "--name", name, "-d", description]);
184
await h.changed();
185
} catch (e) {
186
await fail(e);
187
}
188
}
189
190
export async function linkProject(h: Hooks): Promise<void> {
191
const names = h.projects().map((p) => p.name);
192
const p = await vscode.window.showQuickPick(names, { title: "Link projects (1/2)", placeHolder: "Which project depends on another?" });
193
if (!p) return;
194
const q = await vscode.window.showQuickPick(names.filter((n) => n !== p), { title: "Link projects (2/2)", placeHolder: `${p} depends on…` });
195
if (!q) return;
196
try {
197
await cli.ok(["project", "link", p, "--depends-on", q]);
198
await h.changed();
199
} catch (e) {
200
await fail(e);
201
}
202
}
203
204
export async function addAgent(h: Hooks): Promise<void> {
205
const projects = h.projects().map((p) => p.name);
206
if (!projects.length) {
207
void vscode.window.showWarningMessage("An agent needs a project: register this folder first.", "Register This Folder").then((x) => {
208
if (x) void vscode.commands.executeCommand("spectra.registerFolder");
209
});
210
return;
211
}
212
const kind = await vscode.window.showQuickPick(
213
KINDS.map((k) => ({ label: k.label, client: k.kind })),
214
{ title: "Add an agent (1/4)", placeHolder: "Which client will the agent run in?" },
215
);
216
if (!kind) return;
217
const mine = h.folderProject();
218
const project =
219
projects.length === 1
220
? projects[0]
221
: await vscode.window.showQuickPick(
222
[...projects].sort((a, b) => Number(b === mine) - Number(a === mine)),
223
{ title: "Add an agent (2/4)", placeHolder: "Which project will it work on?" },
224
);
225
if (!project) return;
226
const taken = h.agents().map((a) => a.name);
227
const name = await vscode.window.showInputBox({
228
title: "Add an agent (3/4)",
229
prompt: "Its name",
230
value: suggestAgentName(kind.client, project, taken),
231
validateInput: (v) => (!validName(v) ? "Lower-case letters, digits and dashes" : taken.includes(v) ? "Taken" : undefined),
232
});
233
if (!name) return;
234
const expiry = await vscode.window.showQuickPick(EXPIRIES.map((e) => ({ label: e.label, arg: e.arg })), {
235
title: "Add an agent (4/4)",
236
placeHolder: "How long may it act for you? (Renew Agent gives it more time later.)",
237
});
238
if (!expiry) return;
239
const home = expandHome(path.join(vscode.workspace.getConfiguration("spectra").get<string>("agentsHome") || "~/.spectra-agents", name), os.homedir());
240
const ok = await vscode.window.showWarningMessage(
241
`Let ${name} act for you?`,
242
{ modal: true, detail: `${kind.label} on #${project}, ${expiry.arg ? `for ${expiry.label.toLowerCase()}` : "until you disable it"}. Its key is made in ${home}; your identity will delegate to it.` },
243
"Let It Act for Me",
244
);
245
if (!ok) return;
246
try {
247
const args = ["agent", "add", name, "--kind", kind.client, "--project", project, "--home", home, "--yes", ...(expiry.arg ? ["--expires", expiry.arg] : [])];
248
await cli.ok(args);
249
await h.changed();
250
} catch (e) {
251
await fail(e);
252
return;
253
}
254
await configureClient(kind.client, name, home);
255
}
256
257
/** Write the agent's MCP entry for its client, or say how. */
258
async function configureClient(kind: string, agent: string, home: string): Promise<void> {
259
const bin = cli.binary() ?? "spectra";
260
const args = shimArgs(agent, home);
261
const line = `${bin} ${args.join(" ")}`;
262
const which = KINDS.find((k) => k.kind === kind)?.label ?? kind;
263
const go = await vscode.window.showInformationMessage(`Added ${agent}. Connect ${which} to Spectra as ${agent}?`, "Connect It", "Show Me How");
264
if (go === "Show Me How" || !go) {
265
if (go) {
266
const doc = await vscode.workspace.openTextDocument({
267
language: "markdown",
268
content: `# Connecting ${agent}\n\nIn ${which}, add an MCP server named \`spectra\` that runs:\n\n\`\`\`\n${line}\n\`\`\`\n\nClaude Code: \`claude mcp add spectra -- ${line}\` in the project's folder.\nKimi Code: \`kimi mcp add spectra -- ${line}\`.\nAntigravity: in \`~/.gemini/config/mcp_config.json\`, \`mcpServers.spectra = { "command": "${bin}", "args": ${JSON.stringify(args)} }\`.\n`,
269
});
270
await vscode.window.showTextDocument(doc);
271
}
272
return;
273
}
274
const cwd = folder()?.uri.fsPath;
275
try {
276
if (kind === "claude-code" || kind === "kimi-code") {
277
const tool = kind === "claude-code" ? "claude" : "kimi";
278
await new Promise<void>((resolve) => cp.execFile(tool, ["mcp", "remove", "spectra"], { cwd }, () => resolve()));
279
await new Promise<void>((resolve, reject) =>
280
cp.execFile(tool, ["mcp", "add", "spectra", "--", bin, ...args], { cwd }, (err, _o, stderr) => (err ? reject(new Error(stderr.trim() || err.message)) : resolve())),
281
);
282
void vscode.window.showInformationMessage(`${which} in this folder now connects to Spectra as ${agent}: start a new session there to pick it up.`);
283
} else if (kind === "antigravity") {
284
const file = path.join(os.homedir(), ".gemini", "config", "mcp_config.json");
285
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
286
if (before) fs.copyFileSync(file, file + ".bak");
287
fs.mkdirSync(path.dirname(file), { recursive: true });
288
fs.writeFileSync(file, mergeMcpConfig(before, bin, args));
289
void vscode.window.showInformationMessage(`Antigravity now connects to Spectra as ${agent} (${file}${before ? ", previous copy kept as .bak" : ""}): restart it to pick it up.`);
290
} else {
291
await vscode.env.clipboard.writeText(line);
292
void vscode.window.showInformationMessage(`Copied the command for ${which}'s MCP settings: ${line}`);
293
}
294
} catch (e) {
295
void vscode.window.showWarningMessage(`Could not configure ${which} (${(e as Error).message}). Add an MCP server named spectra running: ${line}`);
296
}
297
}
298
299
export async function renewAgent(h: Hooks, agent?: string): Promise<void> {
300
const name = agent ?? (await vscode.window.showQuickPick(h.agents().filter((a) => !a.disabled).map((a) => a.name), { placeHolder: "Which agent?" }));
301
if (!name) return;
302
const expiry = await vscode.window.showQuickPick(EXPIRIES.map((e) => ({ label: e.label, arg: e.arg ?? "never" })), { placeHolder: `How long may ${name} act for you from now?` });
303
if (!expiry) return;
304
try {
305
await cli.ok(["agent", "renew", name, "--expires", expiry.arg]);
306
await h.changed();
307
} catch (e) {
308
await fail(e);
309
}
310
}