OXYGENHUB
# 0bfe88762caa spectra-vscode / src / setup.ts
18.6 KiB · last changed 0.3.0: choose which channels an agent may post in, from the Agents and Channels views 1 day ago source blame history raw
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 will see 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
    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);
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;
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];
280
  try {
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") {
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.`);
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 });
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.`);
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) {
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);
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
}
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
}