OXYGENHUB
# ff39a5e808da spectra-vscode / src / setup.ts
13.7 KiB · last changed Spectra for VS Code 0.1: set up and chat without the command line (#6) 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
  } 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
}