OXYGENHUB
# 06699bcc6e2e spectra-vscode / src / setup.ts
15.4 KiB · last changed 0.1.2: one Spectra entry per client for every folder (spectra mcp --auto) 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 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
    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
      const tool = cli.program("kimi");
296
      if (!tool) throw new Error("kimi was not found on PATH or in ~/.local/bin");
297
      await new Promise<void>((resolve) => cp.execFile(tool, ["mcp", "remove", "spectra"], { cwd }, () => resolve()));
298
      await new Promise<void>((resolve, reject) =>
299
        cp.execFile(tool, ["mcp", "add", "spectra", "--", bin, ...auto], { cwd }, (err, _o, stderr) => (err ? reject(new Error(stderr.trim() || err.message)) : resolve())),
300
      );
301
      void vscode.window.showInformationMessage(`Kimi Code connects to Spectra as ${agent} in this folder: start a new session here to pick it up.`);
302
    } else if (kind === "antigravity") {
303
      const file = path.join(os.homedir(), ".gemini", "config", "mcp_config.json");
304
      const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
305
      if (before) fs.copyFileSync(file, file + ".bak");
306
      fs.mkdirSync(path.dirname(file), { recursive: true });
307
      fs.writeFileSync(file, mergeMcpConfig(before, bin, auto));
308
      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.`);
309
    } else {
310
      await vscode.env.clipboard.writeText(line);
311
      void vscode.window.showInformationMessage(`Copied the command for ${which}'s MCP settings: ${line}`);
312
    }
313
  } catch (e) {
314
    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");
315
    if (copy) await vscode.env.clipboard.writeText(kind === "claude-code" ? `claude mcp add spectra -- ${line}` : line);
316
  }
317
}
318
319
export async function renewAgent(h: Hooks, agent?: string): Promise<void> {
320
  const name = agent ?? (await vscode.window.showQuickPick(h.agents().filter((a) => !a.disabled).map((a) => a.name), { placeHolder: "Which agent?" }));
321
  if (!name) return;
322
  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?` });
323
  if (!expiry) return;
324
  try {
325
    await cli.ok(["agent", "renew", name, "--expires", expiry.arg]);
326
    await h.changed();
327
  } catch (e) {
328
    await fail(e);
329
  }
330
}