| 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 |
} |