| ff39a5e8 Andy McCall ↶ |
1 |
// Helpers with no VS Code in them, so they can be tested with node:test. |
| 2 |
|
| 3 |
/** Server-sent events: feed chunks, get back each event's data. */ |
| 4 |
export class SseParser { |
| 5 |
private buf = ""; |
| 6 |
private data: string[] = []; |
| 7 |
push(chunk: string): string[] { |
| 8 |
this.buf += chunk; |
| 9 |
const out: string[] = []; |
| 10 |
let i: number; |
| 11 |
while ((i = this.buf.indexOf("\n")) >= 0) { |
| 12 |
const line = this.buf.slice(0, i).replace(/\r$/, ""); |
| 13 |
this.buf = this.buf.slice(i + 1); |
| 14 |
if (line === "") { |
| 15 |
if (this.data.length) out.push(this.data.join("\n")); |
| 16 |
this.data = []; |
| 17 |
} else if (line.startsWith("data:")) { |
| 18 |
this.data.push(line.slice(5).replace(/^ /, "")); |
| 19 |
} |
| 20 |
} |
| 21 |
return out; |
| 22 |
} |
| 23 |
} |
| 24 |
|
| 25 |
export const KINDS: { kind: string; label: string; prefix: string }[] = [ |
| 26 |
{ kind: "claude-code", label: "Claude Code", prefix: "claude" }, |
| 27 |
{ kind: "antigravity", label: "Antigravity", prefix: "agy" }, |
| 28 |
{ kind: "kimi-code", label: "Kimi Code", prefix: "kimi" }, |
| 29 |
{ kind: "codex", label: "Codex", prefix: "codex" }, |
| 30 |
{ kind: "other", label: "Another MCP client", prefix: "agent" }, |
| 31 |
]; |
| 32 |
|
| 33 |
/** A name Spectra accepts: lower-case letters, digits and dashes. */ |
| 34 |
export function validName(n: string): boolean { |
| 35 |
return /^[a-z0-9][a-z0-9-]{0,63}$/.test(n); |
| 36 |
} |
| 37 |
|
| 38 |
/** A project name from a folder name. */ |
| 39 |
export function nameFromFolder(folder: string): string { |
| 40 |
const base = folder.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? ""; |
| 41 |
const n = base.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+/, "").slice(0, 64); |
| 42 |
return n || "project"; |
| 43 |
} |
| 44 |
|
| 45 |
/** The suggested agent name for a kind on a project, not already taken. */ |
| 46 |
export function suggestAgentName(kind: string, project: string, taken: string[]): string { |
| 47 |
const prefix = KINDS.find((k) => k.kind === kind)?.prefix ?? "agent"; |
| 48 |
const base = `${prefix}-${project}`.slice(0, 60); |
| 49 |
if (!taken.includes(base)) return base; |
| 50 |
for (let i = 2; ; i++) if (!taken.includes(`${base}-${i}`)) return `${base}-${i}`; |
| 51 |
} |
| 52 |
|
| 53 |
export const EXPIRIES: { label: string; arg: string | undefined }[] = [ |
| 54 |
{ label: "8 hours", arg: "8h" }, |
| 55 |
{ label: "A day", arg: "1d" }, |
| 56 |
{ label: "A week", arg: "7d" }, |
| 57 |
{ label: "Until I disable it", arg: undefined }, |
| 58 |
]; |
| 59 |
|
| 60 |
/** `~/x` with the home directory. */ |
| 61 |
export function expandHome(p: string, home: string): string { |
| 62 |
return p === "~" ? home : p.startsWith("~/") ? home.replace(/\/$/, "") + p.slice(1) : p; |
| 63 |
} |
| 64 |
|
| 65 |
/** The arguments of the stdio shim for an agent. */ |
| 66 |
export function shimArgs(agent: string, home: string): string[] { |
| 67 |
return ["mcp", "--agent", agent, "--home", home]; |
| 68 |
} |
| 69 |
|
| 70 |
/** Antigravity's mcp_config.json with Spectra's server set, keeping the rest. */ |
| 71 |
export function mergeMcpConfig(existing: string, command: string, args: string[]): string { |
| 72 |
let cfg: Record<string, unknown> = {}; |
| 73 |
if (existing.trim()) { |
| 74 |
const parsed = JSON.parse(existing); |
| 75 |
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) cfg = parsed as Record<string, unknown>; |
| 76 |
} |
| 77 |
const servers = (cfg.mcpServers && typeof cfg.mcpServers === "object" ? cfg.mcpServers : {}) as Record<string, unknown>; |
| 78 |
servers.spectra = { command, args }; |
| 79 |
cfg.mcpServers = servers; |
| 80 |
return JSON.stringify(cfg, null, 2) + "\n"; |
| 81 |
} |
| 82 |
|
| 83 |
/** The project whose folder holds `folder`, the deepest if several do. */ |
| 84 |
export function projectForFolder(projects: { name: string; path?: string | null }[], folder: string): string | undefined { |
| 85 |
const norm = (p: string) => p.replace(/[\\/]+$/, ""); |
| 86 |
const f = norm(folder); |
| 87 |
let best: { name: string; len: number } | undefined; |
| 88 |
for (const p of projects) { |
| 89 |
if (!p.path) continue; |
| 90 |
const q = norm(p.path); |
| 91 |
if ((f === q || f.startsWith(q + "/")) && (!best || q.length > best.len)) best = { name: p.name, len: q.length }; |
| 92 |
} |
| 93 |
return best?.name; |
| 94 |
} |
| 95 |
|
| 96 |
/** `12:04` today, else `3 Nov 12:04`. */ |
| 97 |
export function when(ms: number, now = Date.now()): string { |
| 98 |
const d = new Date(ms); |
| 99 |
const t = d.toTimeString().slice(0, 5); |
| 100 |
return new Date(now).toDateString() === d.toDateString() ? t : `${d.getDate()} ${d.toLocaleString("en-GB", { month: "short" })} ${t}`; |
| 101 |
} |
| 102 |
|
| 103 |
/** Text for HTML. */ |
| 104 |
export function esc(s: string): string { |
| 105 |
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); |
| 106 |
} |