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)",
5482605b Andy McCall 1 day ago ↶
61
prompt: "Your name, as your agents will see it",
ff39a5e8 Andy McCall 1 day ago ↶
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();
17556ca2 Andy McCall 1 day ago ↶
185
const next = await vscode.window.showInformationMessage(`Registered ${name}. Add an agent for this folder now, so its AI client can talk on #${name}?`, "Add an Agent");
186
if (next) await addAgent(h);
ff39a5e8 Andy McCall 1 day ago ↶
187
} catch (e) {
188
await fail(e);
189
}
190
}
191
192
export async function linkProject(h: Hooks): Promise<void> {
193
const names = h.projects().map((p) => p.name);
194
const p = await vscode.window.showQuickPick(names, { title: "Link projects (1/2)", placeHolder: "Which project depends on another?" });
195
if (!p) return;
196
const q = await vscode.window.showQuickPick(names.filter((n) => n !== p), { title: "Link projects (2/2)", placeHolder: `${p} depends on…` });
197
if (!q) return;
198
try {
199
await cli.ok(["project", "link", p, "--depends-on", q]);
200
await h.changed();
201
} catch (e) {
202
await fail(e);
203
}
204
}
205
206
export async function addAgent(h: Hooks): Promise<void> {
207
const projects = h.projects().map((p) => p.name);
208
if (!projects.length) {
209
void vscode.window.showWarningMessage("An agent needs a project: register this folder first.", "Register This Folder").then((x) => {
210
if (x) void vscode.commands.executeCommand("spectra.registerFolder");
211
});
212
return;
213
}
214
const kind = await vscode.window.showQuickPick(
215
KINDS.map((k) => ({ label: k.label, client: k.kind })),
216
{ title: "Add an agent (1/4)", placeHolder: "Which client will the agent run in?" },
217
);
218
if (!kind) return;
219
const mine = h.folderProject();
220
const project =
221
projects.length === 1
222
? projects[0]
223
: await vscode.window.showQuickPick(
224
[...projects].sort((a, b) => Number(b === mine) - Number(a === mine)),
225
{ title: "Add an agent (2/4)", placeHolder: "Which project will it work on?" },
226
);
227
if (!project) return;
228
const taken = h.agents().map((a) => a.name);
229
const name = await vscode.window.showInputBox({
230
title: "Add an agent (3/4)",
231
prompt: "Its name",
232
value: suggestAgentName(kind.client, project, taken),
233
validateInput: (v) => (!validName(v) ? "Lower-case letters, digits and dashes" : taken.includes(v) ? "Taken" : undefined),
234
});
235
if (!name) return;
236
const expiry = await vscode.window.showQuickPick(EXPIRIES.map((e) => ({ label: e.label, arg: e.arg })), {
237
title: "Add an agent (4/4)",
238
placeHolder: "How long may it act for you? (Renew Agent gives it more time later.)",
239
});
240
if (!expiry) return;
241
const home = expandHome(path.join(vscode.workspace.getConfiguration("spectra").get<string>("agentsHome") || "~/.spectra-agents", name), os.homedir());
242
const ok = await vscode.window.showWarningMessage(
243
`Let ${name} act for you?`,
244
{ 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.` },
245
"Let It Act for Me",
246
);
247
if (!ok) return;
248
try {
249
const args = ["agent", "add", name, "--kind", kind.client, "--project", project, "--home", home, "--yes", ...(expiry.arg ? ["--expires", expiry.arg] : [])];
250
await cli.ok(args);
251
await h.changed();
252
} catch (e) {
253
await fail(e);
254
return;
255
}
256
await configureClient(kind.client, name, home);
257
}
258
259
/** Write the agent's MCP entry for its client, or say how. */
260
async function configureClient(kind: string, agent: string, home: string): Promise<void> {
261
const bin = cli.binary() ?? "spectra";
262
const args = shimArgs(agent, home);
263
const line = `${bin} ${args.join(" ")}`;
264
const which = KINDS.find((k) => k.kind === kind)?.label ?? kind;
265
const go = await vscode.window.showInformationMessage(`Added ${agent}. Connect ${which} to Spectra as ${agent}?`, "Connect It", "Show Me How");
266
if (go === "Show Me How" || !go) {
267
if (go) {
268
const doc = await vscode.workspace.openTextDocument({
269
language: "markdown",
270
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`,
271
});
272
await vscode.window.showTextDocument(doc);
273
}
274
return;
275
}
276
const cwd = folder()?.uri.fsPath;
06699bcc Andy McCall 1 day ago ↶
277
// One entry per client for every folder (spectra mcp --auto): the shim
278
// speaks for the agent of whichever folder a session starts in.
279
const auto = ["mcp", "--auto", "--kind", kind];
ff39a5e8 Andy McCall 1 day ago ↶
280
try {
06699bcc Andy McCall 1 day ago ↶
281
if (kind === "claude-code") {
282
const tool = cli.program("claude");
283
if (!tool) throw new Error("claude was not found on PATH or in ~/.local/bin");
284
const list = await new Promise<string>((resolve) => cp.execFile(tool, ["mcp", "get", "spectra"], { cwd }, (_e, out) => resolve(String(out))));
285
if (!list.includes("--auto")) {
286
await new Promise<void>((resolve) => cp.execFile(tool, ["mcp", "remove", "--scope", "user", "spectra"], () => resolve()));
287
await new Promise<void>((resolve, reject) =>
288
cp.execFile(tool, ["mcp", "add", "--scope", "user", "spectra", "--", bin, ...auto], (err, _o, stderr) => (err ? reject(new Error(stderr.trim() || err.message)) : resolve())),
289
);
290
}
291
// A per-folder entry would override the shared one: remove it.
292
await new Promise<void>((resolve) => cp.execFile(tool, ["mcp", "remove", "--scope", "local", "spectra"], { cwd }, () => resolve()));
293
void vscode.window.showInformationMessage(`Claude Code connects to Spectra as ${agent} in this folder. Start a new Claude Code session here to pick it up: one already open does not get it.`);
294
} else if (kind === "kimi-code") {
9a80f382 Andy McCall 1 day ago ↶
295
// Kimi Code reads ~/.kimi-code/mcp.json (or $KIMI_CODE_HOME/mcp.json).
296
const dir = process.env.KIMI_CODE_HOME || path.join(os.homedir(), ".kimi-code");
297
const file = path.join(dir, "mcp.json");
298
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
299
if (before) fs.copyFileSync(file, file + ".bak");
300
fs.mkdirSync(dir, { recursive: true });
301
fs.writeFileSync(file, mergeMcpConfig(before, bin, auto));
302
void vscode.window.showInformationMessage(`Kimi Code connects to Spectra as ${agent} in this folder (${file}). Start a new Kimi session here to pick it up: one already open does not get it.`);
ff39a5e8 Andy McCall 1 day ago ↶
303
} else if (kind === "antigravity") {
304
const file = path.join(os.homedir(), ".gemini", "config", "mcp_config.json");
305
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
306
if (before) fs.copyFileSync(file, file + ".bak");
307
fs.mkdirSync(path.dirname(file), { recursive: true });
06699bcc Andy McCall 1 day ago ↶
308
fs.writeFileSync(file, mergeMcpConfig(before, bin, auto));
309
void vscode.window.showInformationMessage(`Antigravity connects to Spectra as ${agent} in this folder (${file}${before ? ", previous copy kept as .bak" : ""}): restart it to pick it up.`);
ff39a5e8 Andy McCall 1 day ago ↶
310
} else {
311
await vscode.env.clipboard.writeText(line);
312
void vscode.window.showInformationMessage(`Copied the command for ${which}'s MCP settings: ${line}`);
313
}
314
} catch (e) {
17556ca2 Andy McCall 1 day ago ↶
315
const copy = await vscode.window.showErrorMessage(`Could not connect ${which} to Spectra: ${(e as Error).message}. Add an MCP server named spectra in this folder running: ${line}`, "Copy Command");
316
if (copy) await vscode.env.clipboard.writeText(kind === "claude-code" ? `claude mcp add spectra -- ${line}` : line);
ff39a5e8 Andy McCall 1 day ago ↶
317
}
318
}
319
320
export async function renewAgent(h: Hooks, agent?: string): Promise<void> {
321
const name = agent ?? (await vscode.window.showQuickPick(h.agents().filter((a) => !a.disabled).map((a) => a.name), { placeHolder: "Which agent?" }));
322
if (!name) return;
323
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?` });
324
if (!expiry) return;
325
try {
326
await cli.ok(["agent", "renew", name, "--expires", expiry.arg]);
327
await h.changed();
328
} catch (e) {
329
await fail(e);
330
}
331
}
94e8677a Andy McCall 1 day ago ↶
332
333
/** Run `spectra agent allow` for each change; report what failed. */
334
async function grant(h: Hooks, changes: { agent: string; channel: string; allow: boolean }[]): Promise<void> {
335
const failed: string[] = [];
336
for (const c of changes) {
337
try {
338
await cli.ok(["agent", "allow", c.agent, "--channel", c.channel, ...(c.allow ? [] : ["--remove"])]);
339
} catch (e) {
340
failed.push(`${c.agent} in #${c.channel}: ${(e as Error).message}`);
341
}
342
}
343
await h.changed();
344
if (failed.length) void vscode.window.showErrorMessage(`Spectra: not changed: ${failed.join("; ")}`);
345
else if (changes.length) void vscode.window.setStatusBarMessage(`$(check) ${changes.length} channel permission${changes.length === 1 ? "" : "s"} signed into Spectra`, 5000);
346
}
347
348
/** Which other projects' channels an agent may post in. */
349
export async function agentChannels(h: Hooks, agent?: string): Promise<void> {
350
const name = agent ?? (await vscode.window.showQuickPick(h.agents().filter((a) => !a.disabled).map((a) => a.name), { placeHolder: "Which agent?" }));
351
const a = h.agents().find((x) => x.name === name);
352
if (!a) return;
353
const now = new Set(a.channels ?? []);
354
const items = h
355
.projects()
356
.filter((p) => p.name !== a.project)
357
.map((p) => ({ label: `#${p.name}`, description: p.description || undefined, picked: now.has(p.name), channel: p.name }));
358
if (!items.length) {
359
void vscode.window.showInformationMessage(`There are no other projects for ${a.name} to post in.`);
360
return;
361
}
362
const picked = await vscode.window.showQuickPick(items, {
363
canPickMany: true,
364
title: `Channels ${a.name} may post in`,
365
placeHolder: `Besides #${a.project} and #general. Untick to take a channel back.`,
366
});
367
if (!picked) return;
368
const want = new Set(picked.map((p) => p.channel));
369
const changes = items
370
.filter((i) => want.has(i.channel) !== now.has(i.channel))
371
.map((i) => ({ agent: a.name, channel: i.channel, allow: want.has(i.channel) }));
372
await grant(h, changes);
373
}
374
375
/** Which agents from other projects may post in a project's channel. */
376
export async function channelAgents(h: Hooks, channel?: string): Promise<void> {
377
const name = (channel ?? (await vscode.window.showQuickPick(h.projects().map((p) => p.name), { placeHolder: "Which project's channel?" })))?.replace(/^#/, "");
378
if (!name) return;
379
const items = h
380
.agents()
381
.filter((a) => !a.disabled && a.project !== name)
382
.map((a) => ({ label: a.name, description: `${a.kind} · #${a.project}`, picked: (a.channels ?? []).includes(name), agent: a.name }));
383
if (!items.length) {
384
void vscode.window.showInformationMessage(`Every agent already works on ${name}.`);
385
return;
386
}
387
const own = h.agents().filter((a) => !a.disabled && a.project === name).map((a) => a.name);
388
const picked = await vscode.window.showQuickPick(items, {
389
canPickMany: true,
390
title: `Agents who may post in #${name}`,
391
placeHolder: `${own.length ? `${own.join(", ")} always may. ` : ""}Tick agents from other projects; untick to take it back.`,
392
});
393
if (!picked) return;
394
const want = new Set(picked.map((p) => p.agent));
395
const changes = items
396
.filter((i) => want.has(i.agent) !== i.picked)
397
.map((i) => ({ agent: i.agent, channel: name, allow: want.has(i.agent) }));
398
await grant(h, changes);
399
}