diff --git a/README.md b/README.md index 93a3305..26ae255 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,35 @@ For agents, pair the CLI with the [Upstash Skill](https://docs.upstash.com/agent npx skills add upstash/skills ``` +## Connect your AI agent + +One command wires Upstash into your coding agents: + +```bash +npx @upstash/cli setup +``` + +It picks the best install path per agent: + +| Agent | What gets installed | +|---|---| +| Claude Code, Codex, Gemini CLI | The Upstash plugin via the agent's own CLI (MCP server + skills) | +| Cursor | The Upstash plugin as a local plugin in `~/.cursor/plugins/local/upstash` | +| VS Code, GitHub Copilot CLI, OpenCode | The remote MCP server in the agent's config, plus the `upstash` skill | + +In a terminal it walks you through a few prompts: where to install (all projects or just this one) and which agents (detected ones are pre-selected). Flags answer a question up front; `-y` or `--json` skip the prompts entirely. + +Agents are detected from their config directories, or chosen with flags (`--claude --cursor ...`). If a plugin can't be installed (for example the agent's CLI isn't on your `PATH`), setup falls back to MCP + skill for that agent. + +```bash +upstash setup --claude --cursor -y # no prompts +upstash setup --project # this repo instead of your user config +upstash setup --mode mcp # MCP + skill everywhere, no plugins +upstash setup --dry-run # show what would change +``` + +The MCP server signs in with OAuth: on first use your agent opens a consent page where you pick the account (turn read-only off if the agent should change resources). Setup never writes an API key into an agent's config. Rerunning `setup` updates what it installed. + ## No account? Start with a free Redis If you are an AI agent (or just want to try things out) and need a Redis DB to use or experiment with, you don't need an Upstash account: diff --git a/package-lock.json b/package-lock.json index cc883e3..e76577f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "@upstash/cli", - "version": "1.0.0", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@upstash/cli", - "version": "1.0.0", + "version": "0.0.0", "license": "MIT", "dependencies": { + "@clack/prompts": "~1.0.1", "commander": "^13.0.0", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "picocolors": "^1.1.1" }, "bin": { "upstash": "dist/cli.js" @@ -24,6 +26,27 @@ "node": ">=18.0.0" } }, + "node_modules/@clack/core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.1.tgz", + "integrity": "sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.1.tgz", + "integrity": "sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.0.1", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1143,7 +1166,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/postcss": { @@ -1227,6 +1249,12 @@ "dev": true, "license": "ISC" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 4491786..79535e4 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,10 @@ "author": "Upstash", "license": "MIT", "dependencies": { + "@clack/prompts": "~1.0.1", "commander": "^13.0.0", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "picocolors": "^1.1.1" }, "devDependencies": { "@types/node": "^20.10.0", diff --git a/src/cli.ts b/src/cli.ts index 885a83a..881703a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { registerLogin } from "./commands/login.js"; import { registerLogout } from "./commands/logout.js"; import { registerStartRedis } from "./commands/start-redis.js"; import { registerTelemetry } from "./commands/telemetry.js"; +import { registerSetup } from "./commands/setup.js"; import { handleError } from "./output.js"; import dotenv from "dotenv"; @@ -43,6 +44,7 @@ program registerLogin(program); registerLogout(program); registerStartRedis(program); +registerSetup(program); registerTelemetry(program); registerRedis(program); registerTeam(program); diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 0000000..f28301a --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,351 @@ +import { Command } from "commander"; +import { access } from "node:fs/promises"; +import { join } from "node:path"; +import { plainError } from "../output.js"; +import { + AGENT_NAMES, + SKILL_NAME, + getAgent, + type AgentName, + type Scope, +} from "../setup/agents.js"; +import { hasMcpEntry, resolveMcpPath, writeMcpEntry } from "../setup/mcp-config.js"; +import { installPlugin, isPluginInstalled, runCommand, type Runner, type Step } from "../setup/plugins.js"; +import { fetchSkillsRepo, subtree, writeTree, type RepoFiles } from "../setup/repo.js"; +import * as ui from "../setup/ui.js"; + +type Mode = "auto" | "plugin" | "mcp"; +type Method = "plugin" | "mcp"; + +interface SetupFlags extends Partial> { + mode: string; + project?: boolean; + yes?: boolean; + dryRun?: boolean; + json?: boolean; +} + +export interface AgentResult { + agent: AgentName; + name: string; + method: Method; + ok: boolean; + steps: Step[]; + notes: string[]; +} + +const METHOD_LABEL: Record = { plugin: "plugin", mcp: "MCP + skill" }; + +/** Swappable in tests so plugin installs do not shell out to real agent CLIs. */ +let runner: Runner = runCommand; +export function setRunner(next: Runner): void { + runner = next; +} + +export function registerSetup(program: Command): void { + const command = program + .command("setup") + .description( + "Connect AI coding agents to Upstash. Installs the Upstash plugin (MCP server + skills) where the agent supports plugins — Claude Code, Codex, Cursor, Gemini CLI — and otherwise writes the remote MCP server into the agent's config and installs the Upstash skill.", + ); + + for (const name of AGENT_NAMES) command.option(`--${name}`, `Set up ${getAgent(name).displayName}`); + + command + .option("--mode ", "auto (plugin where supported, else MCP + skill), plugin, or mcp", "auto") + .option("-p, --project", "Configure the current project instead of your user config") + .option("-y, --yes", "Do not prompt; without agent flags, set up every detected agent") + .option("--dry-run", "Show what would change without writing anything") + .option("--json", "Print the result as JSON") + .action(async (_flags: unknown, cmd: Command) => { + await runSetup(cmd); + }); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export async function detectAgents(scope: Scope): Promise { + const found: AgentName[] = []; + for (const name of AGENT_NAMES) { + for (const p of getAgent(name).detect(scope)) { + if (await pathExists(p)) { + found.push(name); + break; + } + } + } + return found; +} + +/** Prompts only when a person is at the terminal and has not opted out with --yes or --json. */ +function isInteractive(flags: SetupFlags): boolean { + const tty = ui.promptIO().input !== undefined || Boolean(process.stdin.isTTY && process.stdout.isTTY); + return tty && !flags.yes && !flags.json; +} + +function noAgentsError(detail: string): Error { + const list = AGENT_NAMES.map((n) => `--${n}`).join(" "); + return plainError(`No agents ${detail}. Pass one or more of: ${list}`); +} + +async function resolveAgents(flags: SetupFlags, scope: Scope): Promise { + const explicit = AGENT_NAMES.filter((n) => flags[n]); + if (explicit.length > 0) return explicit; + const detected = await detectAgents(scope); + if (detected.length === 0) throw noAgentsError("selected or detected"); + return detected; +} + +async function isConnected(name: AgentName, scope: Scope): Promise { + const agent = getAgent(name); + if (await hasMcpEntry(agent, scope)) return true; + // Plugin installs are tracked per user, so they only say something about the global scope. + return scope === "global" && agent.plugin ? isPluginInstalled(agent.plugin.kind) : false; +} + +async function promptScope(cmd: Command): Promise { + if (cmd.getOptionValueSource("project") !== undefined) return cmd.opts().project ? "project" : "global"; + return ui.pickOne( + "Where should Upstash be set up?", + [ + { value: "global", label: "All projects", hint: "your user config" }, + { value: "project", label: "This project only", hint: ui.tildify(process.cwd()) }, + ], + "global", + ); +} + +async function promptAgents(flags: SetupFlags, scope: Scope): Promise { + const explicit = AGENT_NAMES.filter((n) => flags[n]); + if (explicit.length > 0) return explicit; + const detected = await detectAgents(scope); + const options = await Promise.all( + AGENT_NAMES.map(async (name) => { + const hint = (await isConnected(name, scope)) ? "already connected" : detected.includes(name) ? "detected" : undefined; + return { value: name, label: getAgent(name).displayName, hint }; + }), + ); + return ui.pickMany( + `Which agents should use Upstash? ${ui.dim("(space to toggle, enter to confirm)")}`, + options, + detected, + ); +} + +/** Picks plugin vs MCP + skill for one agent, with the reason when the plugin is ruled out up front. */ +function chooseMethod( + name: AgentName, + mode: Mode, + scope: Scope, +): { method: Method; note?: string } { + const plugin = getAgent(name).plugin; + if (mode === "mcp") return { method: "mcp" }; + if (!plugin) { + return mode === "plugin" + ? { method: "mcp", note: `${getAgent(name).displayName} has no plugin support; installed MCP + skill instead.` } + : { method: "mcp" }; + } + if (!plugin.scopes.includes(scope)) { + return { method: "mcp", note: "Plugins install per user, not per project; wrote project-level MCP + skill instead." }; + } + return { method: "plugin" }; +} + +async function setupMcp( + name: AgentName, + scope: Scope, + dryRun: boolean, + repo: () => Promise, +): Promise<{ ok: boolean; steps: Step[] }> { + const agent = getAgent(name); + const steps: Step[] = []; + const mcpLabel = "MCP server upstash"; + const skillPath = join(agent.skillDir(scope), SKILL_NAME); + const skillLabel = `Skill ${SKILL_NAME}`; + + if (dryRun) { + steps.push({ label: mcpLabel, status: "planned", path: await resolveMcpPath(agent, scope) }); + steps.push({ label: skillLabel, status: "planned", path: skillPath }); + return { ok: true, steps }; + } + + try { + const { path, replaced } = await writeMcpEntry(agent, scope); + steps.push({ label: `${mcpLabel}${replaced ? ", replaced existing entry" : ""}`, status: "done", path }); + } catch (err) { + steps.push({ label: mcpLabel, status: "failed", detail: err instanceof Error ? err.message : String(err) }); + } + + try { + const files = subtree(await repo(), `skills/${SKILL_NAME}`); + await writeTree(files, skillPath); + steps.push({ label: skillLabel, status: "done", path: skillPath }); + } catch (err) { + steps.push({ label: skillLabel, status: "failed", path: skillPath, detail: err instanceof Error ? err.message : String(err) }); + } + + return { ok: steps.every((s) => s.status !== "failed"), steps }; +} + +interface AgentContext { + mode: Mode; + scope: Scope; + dryRun: boolean; + repo: () => Promise; +} + +async function setupAgent(name: AgentName, ctx: AgentContext): Promise { + const { mode, scope, dryRun, repo } = ctx; + const agent = getAgent(name); + const choice = chooseMethod(name, mode, scope); + const notes = choice.note ? [choice.note] : []; + + if (choice.method === "plugin" && agent.plugin) { + const res = await installPlugin(agent.plugin.kind, { scope, dryRun, run: runner, repo }); + if (res.ok || mode === "plugin") { + if (res.missing) notes.push(`\`${res.missing}\` not found on PATH.`); + const manual = res.ok ? await hasMcpEntry(agent, scope) : undefined; + if (manual) { + notes.push(`${manual} also declares an "upstash" MCP server; remove it so the agent does not load the server twice.`); + } + return { agent: name, name: agent.displayName, method: "plugin", ok: res.ok, steps: res.steps, notes: [...notes, ...res.notes] }; + } + const why = res.missing + ? `\`${res.missing}\` not found on PATH` + : `plugin install failed (${res.steps.find((s) => s.status === "failed")?.detail ?? "unknown error"})`; + notes.push(`${why}; installed MCP + skill instead.`); + } + + if (agent.plugin && (await isPluginInstalled(agent.plugin.kind))) { + notes.push("The Upstash plugin is also installed and brings its own MCP server; uninstall one of the two to avoid duplicate tools."); + } + const res = await setupMcp(name, scope, dryRun, repo); + return { agent: name, name: agent.displayName, method: "mcp", ok: res.ok, steps: res.steps, notes }; +} + +/** One download serves every agent that needs files. */ +function lazyRepo(): () => Promise { + let repoPromise: Promise | undefined; + return () => (repoPromise ??= fetchSkillsRepo()); +} + +export async function runSetup(cmd: Command): Promise { + const flags = cmd.optsWithGlobals() as SetupFlags; + const mode = flags.mode as Mode; + if (!["auto", "plugin", "mcp"].includes(mode)) throw plainError(`--mode must be auto, plugin, or mcp (got ${mode})`); + + if (!isInteractive(flags)) return runPlain(flags, mode); + try { + return await runInteractive(cmd, flags, mode); + } catch (err) { + if (!(err instanceof ui.SetupCancelled)) throw err; + ui.cancelled(err.message); + process.exitCode = 130; + return []; + } +} + +async function runPlain(flags: SetupFlags, mode: Mode): Promise { + const scope: Scope = flags.project ? "project" : "global"; + const dryRun = Boolean(flags.dryRun); + const agents = await resolveAgents(flags, scope); + const ctx: AgentContext = { mode, scope, dryRun, repo: lazyRepo() }; + + const results: AgentResult[] = []; + for (const name of agents) results.push(await setupAgent(name, ctx)); + if (results.some((r) => !r.ok)) process.exitCode = 1; + + if (flags.json) { + console.log(JSON.stringify({ scope, dry_run: dryRun, results }, null, 2)); + } else { + printSummary(results, scope, dryRun); + } + return results; +} + +async function runInteractive(cmd: Command, flags: SetupFlags, mode: Mode): Promise { + const dryRun = Boolean(flags.dryRun); + ui.intro(dryRun ? "Upstash setup (dry run)" : "Upstash setup"); + + const scope = await promptScope(cmd); + const agents = await promptAgents(flags, scope); + if (agents.length === 0) throw noAgentsError("selected"); + + if (!dryRun) { + const width = Math.max(...agents.map((n) => getAgent(n).displayName.length)); + const plan = agents.map((n) => { + const { method } = chooseMethod(n, mode, scope); + const what = method === "plugin" ? "Upstash plugin" : `MCP server + ${SKILL_NAME} skill`; + return `${getAgent(n).displayName.padEnd(width)} ${ui.dim(what)}`; + }); + ui.note(plan.join("\n"), scope === "project" ? "Will set up in this project" : "Will set up for your user"); + if (!(await ui.confirm("Continue?"))) throw new ui.SetupCancelled(); + } + + const ctx: AgentContext = { mode, scope, dryRun, repo: lazyRepo() }; + const results: AgentResult[] = []; + for (const name of agents) { + const label = getAgent(name).displayName; + const spin = ui.spinner(); + spin.start(`${dryRun ? "Planning" : "Setting up"} ${label}`); + const res = await setupAgent(name, ctx); + const title = `${label} ${ui.dim(`· ${METHOD_LABEL[res.method]}`)}`; + if (res.ok) spin.stop(title); + else spin.error(title); + ui.printSteps(res.steps, res.notes); + results.push(res); + } + + const failed = results.filter((r) => !r.ok); + if (failed.length > 0) process.exitCode = 1; + + if (dryRun) { + ui.outro("Dry run: nothing was changed."); + } else if (failed.length === results.length) { + ui.outro("Setup failed. See the errors above."); + } else { + const next = [ + "Restart your agents to pick up the changes.", + "On first use the Upstash MCP opens a browser consent page: pick the account,", + "and turn read-only off if the agent should create or change resources.", + ]; + ui.note(next.join("\n"), "Next steps"); + ui.outro( + failed.length > 0 + ? `Connected ${results.length - failed.length} of ${results.length} agents to Upstash.` + : `Connected ${results.length === 1 ? "1 agent" : `${results.length} agents`} to Upstash.`, + ); + } + return results; +} + +const ICON: Record = { done: "+", planned: "~", failed: "x" }; + +function printSummary(results: AgentResult[], scope: Scope, dryRun: boolean): void { + const lines: string[] = []; + const where = scope === "project" ? "this project" : "your user config"; + lines.push(`${dryRun ? "Dry run: " : ""}Upstash setup for ${where}`, ""); + for (const r of results) { + lines.push(`${r.name} · ${METHOD_LABEL[r.method]}`); + for (const s of r.steps) { + lines.push(` ${ICON[s.status]} ${s.label}${s.path ? ` → ${s.path}` : ""}`); + if (s.detail) lines.push(` ${s.detail}`); + } + for (const n of r.notes) lines.push(` ! ${n}`); + lines.push(""); + } + if (!dryRun && results.some((r) => r.ok)) { + lines.push( + "Restart your agents to pick up the changes.", + "On first use the Upstash MCP opens a browser consent page: pick the account, and turn read-only off if the agent should create or change resources.", + ); + } + console.log(lines.join("\n").trimEnd()); +} diff --git a/src/setup/agents.ts b/src/setup/agents.ts new file mode 100644 index 0000000..326a842 --- /dev/null +++ b/src/setup/agents.ts @@ -0,0 +1,200 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const MCP_URL = "https://mcp.upstash.com/mcp"; +export const SERVER_NAME = "upstash"; +export const SKILL_NAME = "upstash"; +export const SKILLS_REPO = "upstash/skills"; +export const PLUGIN_ID = "upstash@upstash"; + +export type Scope = "global" | "project"; + +export type PluginKind = "claude" | "codex" | "cursor" | "gemini"; + +export interface AgentConfig { + displayName: string; + /** Set when the agent can install the upstash/skills plugin (skills + MCP in one step). */ + plugin?: { kind: PluginKind; scopes: Scope[] }; + mcp: { + format: "json" | "toml"; + /** Candidate config files; the first that exists wins, otherwise the first is created. */ + paths: (scope: Scope) => string[]; + configKey: string; + /** The `upstash` server entry. It carries no credential: the server signs in with OAuth on first use. */ + entry: Record; + }; + /** Directory the `upstash` skill folder is written into. */ + skillDir: (scope: Scope) => string; + /** Paths whose existence means the agent is in use. */ + detect: (scope: Scope) => string[]; +} + +const home = (...parts: string[]): string => join(homedir(), ...parts); +const cwd = (...parts: string[]): string => join(process.cwd(), ...parts); +const pick = (scope: Scope, project: string, global: string): string => + scope === "project" ? cwd(project) : global; + +/** https://code.claude.com/docs/en/env-vars (`CLAUDE_CONFIG_DIR`) */ +function claudeConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR || home(".claude"); +} + +/** + * The docs do not say where `.claude.json` goes under `CLAUDE_CONFIG_DIR`; + * `claude mcp add --scope user` (v2.1.287) writes `$CLAUDE_CONFIG_DIR/.claude.json`. + */ +function claudeGlobalMcpPath(): string { + const dir = process.env.CLAUDE_CONFIG_DIR; + return dir ? join(dir, ".claude.json") : home(".claude.json"); +} + +/** https://code.visualstudio.com/docs/configure/settings#_settings-file-locations */ +export function vscodeUserDir(platform: NodeJS.Platform = process.platform): string { + if (platform === "win32") { + return join(process.env.APPDATA || home("AppData", "Roaming"), "Code", "User"); + } + if (platform === "darwin") return home("Library", "Application Support", "Code", "User"); + return join(process.env.XDG_CONFIG_HOME || home(".config"), "Code", "User"); +} + +/** Only `opencode.json` / `opencode.jsonc` are documented; the dotted names are accepted for older setups. */ +const OPENCODE_FILES = ["opencode.json", "opencode.jsonc", ".opencode.json", ".opencode.jsonc"]; + +/** + * Where each agent keeps its MCP config and skills. Every entry links the + * vendor docs its paths and entry shape come from; check them when an agent + * changes its layout. + */ +export const AGENTS = { + /** + * MCP: https://code.claude.com/docs/en/mcp (user scope: ~/.claude.json, project scope: .mcp.json, `type: "http"`) + * Skills: https://code.claude.com/docs/en/skills + * Plugins: https://code.claude.com/docs/en/plugins/cli-reference + */ + claude: { + displayName: "Claude Code", + plugin: { kind: "claude", scopes: ["global", "project"] }, + mcp: { + format: "json", + paths: (s) => [s === "project" ? cwd(".mcp.json") : claudeGlobalMcpPath()], + configKey: "mcpServers", + entry: { type: "http", url: MCP_URL }, + }, + skillDir: (s) => pick(s, join(".claude", "skills"), join(claudeConfigDir(), "skills")), + detect: (s) => (s === "project" ? [cwd(".mcp.json"), cwd(".claude")] : [claudeConfigDir()]), + }, + /** + * MCP: https://developers.openai.com/codex/mcp#configure-with-configtoml (`[mcp_servers.]`, `url`; + * project .codex/config.toml loads only in trusted projects) + * Skills: https://developers.openai.com/codex/skills (~/.agents/skills, .agents/skills) + * Plugins: https://developers.openai.com/codex/cli/reference#codex-plugin + */ + codex: { + displayName: "Codex", + plugin: { kind: "codex", scopes: ["global"] }, + mcp: { + format: "toml", + paths: (s) => [pick(s, join(".codex", "config.toml"), home(".codex", "config.toml"))], + configKey: "mcp_servers", + entry: { url: MCP_URL }, + }, + skillDir: (s) => pick(s, join(".agents", "skills"), home(".agents", "skills")), + detect: (s) => [pick(s, ".codex", home(".codex"))], + }, + /** + * MCP: https://cursor.com/docs/context/mcp#configuration-locations (~/.cursor/mcp.json, .cursor/mcp.json) + * Skills: https://cursor.com/docs/context/skills#skill-directories + * Plugins: https://cursor.com/docs/plugins#test-plugins-locally + */ + cursor: { + displayName: "Cursor", + plugin: { kind: "cursor", scopes: ["global"] }, + mcp: { + format: "json", + paths: (s) => [pick(s, join(".cursor", "mcp.json"), home(".cursor", "mcp.json"))], + configKey: "mcpServers", + entry: { url: MCP_URL }, + }, + skillDir: (s) => pick(s, join(".cursor", "skills"), home(".cursor", "skills")), + detect: (s) => [pick(s, ".cursor", home(".cursor"))], + }, + /** + * MCP: https://geminicli.com/docs/tools/mcp-server/#configuration-properties (`mcpServers`, `httpUrl`) + * Settings files: https://geminicli.com/docs/reference/configuration/#settings-files + * Skills: https://geminicli.com/docs/cli/skills/#discovery-tiers + * Extensions: https://geminicli.com/docs/extensions/reference/#install-an-extension + */ + gemini: { + displayName: "Gemini CLI", + plugin: { kind: "gemini", scopes: ["global"] }, + mcp: { + format: "json", + paths: (s) => [pick(s, join(".gemini", "settings.json"), home(".gemini", "settings.json"))], + configKey: "mcpServers", + entry: { httpUrl: MCP_URL }, + }, + skillDir: (s) => pick(s, join(".gemini", "skills"), home(".gemini", "skills")), + detect: (s) => [pick(s, ".gemini", home(".gemini"))], + }, + /** + * MCP: https://code.visualstudio.com/docs/agents/reference/mcp-configuration#_configuration-file (top-level `servers`) + * These docs now list .vscode/mcp.json and the user-profile mcp.json as deprecated in favour of + * .mcp.json and ~/.copilot/mcp-config.json; VS Code still reads them: + * https://code.visualstudio.com/docs/agent-customization/mcp-servers#_configure-the-mcpjson-file + * Skills: https://code.visualstudio.com/docs/agent-customization/agent-skills + */ + vscode: { + displayName: "VS Code", + mcp: { + format: "json", + paths: (s) => [pick(s, join(".vscode", "mcp.json"), join(vscodeUserDir(), "mcp.json"))], + configKey: "servers", + entry: { type: "http", url: MCP_URL }, + }, + skillDir: (s) => pick(s, join(".agents", "skills"), home(".agents", "skills")), + detect: (s) => [pick(s, ".vscode", vscodeUserDir())], + }, + /** + * MCP: https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers + * (~/.copilot/mcp-config.json, project .mcp.json, `tools: ["*"]`) + * Skills: https://docs.github.com/en/copilot/concepts/agents/about-agent-skills + */ + copilot: { + displayName: "GitHub Copilot CLI", + mcp: { + format: "json", + paths: (s) => [pick(s, ".mcp.json", home(".copilot", "mcp-config.json"))], + configKey: "mcpServers", + entry: { type: "http", url: MCP_URL, tools: ["*"] }, + }, + skillDir: (s) => pick(s, join(".agents", "skills"), home(".agents", "skills")), + // Copilot shares .mcp.json with Claude Code, so a project cannot be + // attributed to it; --copilot still works explicitly. + detect: (s) => (s === "project" ? [] : [home(".copilot")]), + }, + /** + * Config files: https://opencode.ai/docs/config#locations + * MCP: https://opencode.ai/docs/mcp-servers#remote (`mcp`, `type: "remote"`) + * Skills: https://opencode.ai/docs/skills#place-files + */ + opencode: { + displayName: "OpenCode", + mcp: { + format: "json", + paths: (s) => + OPENCODE_FILES.map((f) => (s === "project" ? cwd(f) : home(".config", "opencode", f))), + configKey: "mcp", + entry: { type: "remote", url: MCP_URL, enabled: true }, + }, + skillDir: (s) => pick(s, join(".agents", "skills"), home(".config", "opencode", "skills")), + detect: (s) => + s === "project" ? OPENCODE_FILES.map((f) => cwd(f)) : [home(".config", "opencode")], + }, +} satisfies Record; + +export type AgentName = keyof typeof AGENTS; +export const AGENT_NAMES = Object.keys(AGENTS) as AgentName[]; + +export function getAgent(name: AgentName): AgentConfig { + return AGENTS[name]; +} diff --git a/src/setup/mcp-config.ts b/src/setup/mcp-config.ts new file mode 100644 index 0000000..28d5917 --- /dev/null +++ b/src/setup/mcp-config.ts @@ -0,0 +1,286 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { SERVER_NAME, type AgentConfig, type Scope } from "./agents.js"; + +/** Reads a string literal starting at `i`; returns the index just past its closing quote. */ +function skipString(text: string, i: number): number { + i++; + while (i < text.length && text[i] !== '"') { + if (text[i] === "\\") i++; + i++; + } + return i + 1; +} + +/** + * Turns JSONC (OpenCode, VS Code) into JSON: drops // and /* *\/ comments and + * trailing commas outside strings. + */ +export function stripJsonc(text: string): string { + return dropTrailingCommas(stripJsonComments(text)); +} + +function dropTrailingCommas(text: string): string { + let out = ""; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '"') { + const end = skipString(text, i); + out += text.slice(i, end); + i = end; + } else if (ch === ",") { + let j = i + 1; + while (j < text.length && /\s/.test(text[j]!)) j++; + if (text[j] !== "}" && text[j] !== "]") out += ch; + i++; + } else { + out += ch; + i++; + } + } + return out; +} + +function stripJsonComments(text: string): string { + let out = ""; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '"') { + const end = skipString(text, i); + out += text.slice(i, end); + i = end; + } else if (ch === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") i++; + } else if (ch === "/" && text[i + 1] === "*") { + i += 2; + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; + i += 2; + } else { + out += ch; + i++; + } + } + return out; +} + +/** A missing file reads as empty; any other read error is thrown so the file is never overwritten blind. */ +async function readText(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw err; + } +} + +export async function readJsonConfig(path: string): Promise> { + const raw = (await readText(path)).trim(); + if (!raw) return {}; + try { + const parsed = JSON.parse(stripJsonc(raw)) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // fall through + } + // Refuse to overwrite a file we cannot read back faithfully. + throw new Error(`${path} is not a JSON object; fix or remove it and rerun`); +} + +export function mergeServerEntry( + config: Record, + configKey: string, + name: string, + entry: Record, +): { config: Record; replaced: boolean } { + const current = config[configKey]; + const section = + current && typeof current === "object" && !Array.isArray(current) + ? (current as Record) + : {}; + return { + config: { ...config, [configKey]: { ...section, [name]: entry } }, + replaced: name in section, + }; +} + +const tomlKey = (key: string): string => (/^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key)); + +/** Serializes a flat entry as a TOML table. JSON string/array literals are valid TOML for the values we write. */ +export function buildTomlTable(table: string, entry: Record): string { + const lines = [`[${table}]`]; + for (const [key, value] of Object.entries(entry)) lines.push(`${tomlKey(key)} = ${JSON.stringify(value)}`); + return lines.join("\n") + "\n"; +} + +/** Splits a dotted TOML key (`mcp_servers."upstash"`) into its parts, or undefined if it is not one. */ +function parseKeys(body: string): string[] | undefined { + const keys: string[] = []; + const re = /\s*("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)\s*(\.|$)/y; + let pos = 0; + while (pos < body.length) { + re.lastIndex = pos; + const k = re.exec(body); + if (!k) return undefined; + const raw = k[1]!; + keys.push(raw.startsWith('"') ? (JSON.parse(raw) as string) : raw.startsWith("'") ? raw.slice(1, -1) : raw); + pos = re.lastIndex; + if (!k[2]) break; + } + return pos >= body.length && keys.length > 0 ? keys : undefined; +} + +/** + * The dotted keys of a TOML table header, with quotes resolved, so + * `[mcp_servers."upstash"] # note` reads as ["mcp_servers", "upstash"]. + * Undefined for any line that is not a `[table]` header. + */ +function tableKeys(line: string): string[] | undefined { + const m = /^\s*\[(?!\[)(.+)\]\s*(?:#.*)?$/.exec(line); + return m ? parseKeys(m[1]!) : undefined; +} + +const startsWith = (keys: string[], prefix: string[]): boolean => + keys.length >= prefix.length && prefix.every((k, i) => keys[i] === k); + +/** + * Whether `table` is defined anywhere other than its own `[table]` section: + * as a dotted key (`mcp_servers.upstash.url = ...`) or an inline table + * (`upstash = { ... }` under `[mcp_servers]`). Appending a `[table]` header + * then would declare it twice and break the whole file. + */ +function definedOutsideTable(lines: string[], table: string): boolean { + const want = table.split("."); + let current: string[] = []; + let inMultiline = false; + for (const line of lines) { + const quotes = (line.match(/"""|'''/g) ?? []).length; + if (inMultiline) { + if (quotes % 2 === 1) inMultiline = false; + continue; + } + const array = /^\s*\[\[(.+)\]\]\s*(?:#.*)?$/.exec(line); + const header = array ? parseKeys(array[1]!) : tableKeys(line); + if (header) { + current = header; + continue; + } + const kv = /^\s*([^=#\s][^=]*?)\s*=/.exec(line); + const keys = kv ? parseKeys(kv[1]!) : undefined; + if (keys && !startsWith(current, want) && startsWith([...current, ...keys], want)) return true; + if (quotes % 2 === 1) inMultiline = true; + } + return false; +} + +const isTableHeader = (line: string): boolean => /^\s*\[/.test(line) && (tableKeys(line) !== undefined || /^\s*\[\[/.test(line)); + +function isHeader(line: string, table: string): boolean { + const keys = tableKeys(line); + const want = table.split("."); + return keys !== undefined && keys.length === want.length && keys.every((k, i) => k === want[i]); +} + +function isSubTable(line: string, table: string): boolean { + const keys = tableKeys(line); + const want = table.split("."); + return keys !== undefined && keys.length > want.length && startsWith(keys, want); +} + +/** + * Replaces `[table]` and its `[table.*]` sub-tables, or appends the block. + * Throws when the table is also defined inline or with dotted keys, which + * this line-based merge cannot rewrite safely. + */ +export function upsertTomlTable( + existing: string, + table: string, + block: string, +): { content: string; replaced: boolean } { + const lines = existing.split("\n"); + if (definedOutsideTable(lines, table)) { + throw new Error(`${table} is defined inline or with dotted keys; replace it with a [${table}] table or remove it, then rerun`); + } + const start = lines.findIndex((l) => isHeader(l, table)); + if (start === -1) { + const base = existing.trimEnd(); + return { content: (base ? `${base}\n\n` : "") + block, replaced: false }; + } + let end = start + 1; + while (end < lines.length) { + const line = lines[end]!; + if (isTableHeader(line) && !isSubTable(line, table)) break; + end++; + } + const before = lines.slice(0, start).join("\n").trimEnd(); + const after = lines.slice(end).join("\n").trim(); + const content = [before, block.trimEnd(), after].filter((s) => s.length > 0).join("\n\n"); + return { content: content + "\n", replaced: true }; +} + +async function firstExisting(candidates: string[]): Promise { + for (const c of candidates) { + try { + await access(c); + return c; + } catch { + // try the next one + } + } + return candidates[0]!; +} + +export function resolveMcpPath(agent: AgentConfig, scope: Scope): Promise { + return firstExisting(agent.mcp.paths(scope)); +} + +/** Writes the `upstash` server into the agent's MCP config, keeping every other server. */ +export async function writeMcpEntry( + agent: AgentConfig, + scope: Scope, +): Promise<{ path: string; replaced: boolean }> { + const path = await resolveMcpPath(agent, scope); + const entry = agent.mcp.entry; + let content: string; + let replaced: boolean; + + if (agent.mcp.format === "toml") { + const block = buildTomlTable(`${agent.mcp.configKey}.${SERVER_NAME}`, entry); + try { + ({ content, replaced } = upsertTomlTable(await readText(path), `${agent.mcp.configKey}.${SERVER_NAME}`, block)); + } catch (err) { + throw new Error(`${path}: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + const merged = mergeServerEntry(await readJsonConfig(path), agent.mcp.configKey, SERVER_NAME, entry); + content = JSON.stringify(merged.config, null, 2) + "\n"; + replaced = merged.replaced; + } + + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, "utf8"); + return { path, replaced }; +} + +/** Whether the agent's MCP config already declares the `upstash` server. */ +export async function hasMcpEntry(agent: AgentConfig, scope: Scope): Promise { + const path = await resolveMcpPath(agent, scope); + if (agent.mcp.format === "toml") { + const table = `${agent.mcp.configKey}.${SERVER_NAME}`; + try { + const lines = (await readText(path)).split("\n"); + return lines.some((l) => isHeader(l, table)) || definedOutsideTable(lines, table) ? path : undefined; + } catch { + return undefined; + } + } + try { + const section = (await readJsonConfig(path))[agent.mcp.configKey]; + return section && typeof section === "object" && SERVER_NAME in section ? path : undefined; + } catch { + return undefined; + } +} diff --git a/src/setup/plugins.ts b/src/setup/plugins.ts new file mode 100644 index 0000000..c0315e9 --- /dev/null +++ b/src/setup/plugins.ts @@ -0,0 +1,221 @@ +import { execFile } from "node:child_process"; +import { access, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { PLUGIN_ID, SKILLS_REPO, type PluginKind, type Scope } from "./agents.js"; +import { subtree, writeTree, type RepoFiles } from "./repo.js"; + +export type StepStatus = "done" | "planned" | "failed"; + +export interface Step { + label: string; + status: StepStatus; + path?: string; + detail?: string; +} + +export interface PluginResult { + ok: boolean; + /** The agent's CLI binary, when it is not on PATH and there was nothing to call. */ + missing?: string; + steps: Step[]; + notes: string[]; +} + +interface RunResult { + ok: boolean; + missing: boolean; + output: string; +} + +export type Runner = (bin: string, args: string[]) => Promise; + +export const runCommand: Runner = (bin, args) => + new Promise((resolve) => { + execFile( + bin, + args, + // Windows ships these CLIs as .cmd shims, which only resolve through a shell. + { timeout: 180_000, maxBuffer: 10 * 1024 * 1024, shell: process.platform === "win32" }, + (err, stdout, stderr) => { + const output = `${stdout ?? ""}${stderr ?? ""}`.trim(); + if (err && (err as NodeJS.ErrnoException).code === "ENOENT") { + resolve({ ok: false, missing: true, output: `${bin} not found on PATH` }); + return; + } + resolve({ ok: !err, missing: false, output: output || (err ? err.message : "") }); + }, + ); + }); + +/** Last meaningful line of CLI output, for a one-line failure reason. */ +function reason(output: string): string { + const lines = output.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("WARNING")); + return lines.at(-1) ?? "unknown error"; +} + +function planned(label: string): Step { + return { label, status: "planned" }; +} + +export interface PluginContext { + scope: Scope; + dryRun: boolean; + run: Runner; + /** Lazily downloads upstash/skills; only the Cursor installer needs the files. */ + repo: () => Promise; +} + +/** Runs `add`, then `install`, through an agent's own plugin CLI. */ +async function viaCli( + ctx: PluginContext, + bin: string, + marketplace: { add: string[]; refresh: string[] }, + install: string[], + installLabel: string, +): Promise { + const addLabel = `Marketplace ${SKILLS_REPO}`; + if (ctx.dryRun) return { ok: true, steps: [planned(addLabel), planned(installLabel)], notes: [] }; + + const add = await ctx.run(bin, marketplace.add); + if (add.missing) return { ok: false, missing: bin, steps: [], notes: [] }; + if (!add.ok) { + return { ok: false, steps: [{ label: addLabel, status: "failed", detail: reason(add.output) }], notes: [] }; + } + // `add` is a no-op when the marketplace already exists; refresh so a rerun + // picks up the latest plugin. Best-effort: an old CLI may lack the command. + await ctx.run(bin, marketplace.refresh); + + const res = await ctx.run(bin, install); + const steps: Step[] = [ + { label: addLabel, status: "done" }, + res.ok + ? { label: installLabel, status: "done" } + : { label: installLabel, status: "failed", detail: reason(res.output) }, + ]; + return { ok: res.ok, steps, notes: [] }; +} + +/** https://code.claude.com/docs/en/plugins/cli-reference (`marketplace add`, `marketplace update`, `install`, `update`) */ +async function claude(ctx: PluginContext): Promise { + const scope = ctx.scope === "project" ? "project" : "user"; + const result = await viaCli( + ctx, + "claude", + { + add: ["plugin", "marketplace", "add", SKILLS_REPO, "--scope", scope], + refresh: ["plugin", "marketplace", "update", "upstash"], + }, + ["plugin", "install", PLUGIN_ID, "--scope", scope], + `Plugin ${PLUGIN_ID} (${scope} scope)`, + ); + // `install` reports success without upgrading an existing install. + if (result.ok && !ctx.dryRun) await ctx.run("claude", ["plugin", "update", PLUGIN_ID, "--scope", scope]); + return result; +} + +/** + * https://developers.openai.com/codex/cli/reference#codex-plugin-marketplace (`marketplace add`, `marketplace upgrade`) + * https://developers.openai.com/codex/cli/reference#codex-plugin (`plugin add`) + */ +function codex(ctx: PluginContext): Promise { + return viaCli( + ctx, + "codex", + { + add: ["plugin", "marketplace", "add", SKILLS_REPO], + refresh: ["plugin", "marketplace", "upgrade", "upstash"], + }, + ["plugin", "add", PLUGIN_ID], + `Plugin ${PLUGIN_ID}`, + ); +} + +/** https://geminicli.com/docs/extensions/reference/#install-an-extension (`install --consent`, `update `) */ +async function gemini(ctx: PluginContext): Promise { + const label = "Extension upstash"; + if (ctx.dryRun) return { ok: true, steps: [planned(label)], notes: [] }; + let res = await ctx.run("gemini", ["extensions", "install", `https://github.com/${SKILLS_REPO}`, "--consent"]); + if (res.missing) return { ok: false, missing: "gemini", steps: [], notes: [] }; + if (!res.ok && /already installed/i.test(res.output)) { + res = await ctx.run("gemini", ["extensions", "update", "upstash"]); + } + return { + ok: res.ok, + steps: [res.ok ? { label, status: "done" } : { label, status: "failed", detail: reason(res.output) }], + notes: [], + }; +} + +export function cursorPluginDir(): string { + return join(homedir(), ".cursor", "plugins", "local", "upstash"); +} + +/** + * Cursor has no plugin CLI, and the Upstash plugin is not in its marketplace + * yet, so this installs it as a local plugin: the same manifest, skills and + * assets the marketplace would fetch, under ~/.cursor/plugins/local/. + * The manifest declares the MCP server inline (`mcpServers` in plugin.json), so + * the plugin brings the server along with the skills. + * https://cursor.com/docs/plugins#test-plugins-locally + * https://cursor.com/docs/reference/plugins#mcp-servers + */ +async function cursor(ctx: PluginContext): Promise { + const dest = cursorPluginDir(); + const label = "Local plugin upstash"; + const notes = ["Reload Cursor (Developer: Reload Window) to load the plugin."]; + if (ctx.dryRun) return { ok: true, steps: [{ ...planned(label), path: dest }], notes }; + try { + const repo = await ctx.repo(); + const files: RepoFiles = new Map(); + for (const dir of [".cursor-plugin", "skills", "assets"]) { + for (const [rel, content] of subtree(repo, dir)) files.set(`${dir}/${rel}`, content); + } + if (!files.has(".cursor-plugin/plugin.json")) { + throw new Error(`${SKILLS_REPO} has no .cursor-plugin/plugin.json`); + } + await writeTree(files, dest); + return { ok: true, steps: [{ label, status: "done", path: dest }], notes }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return { ok: false, steps: [{ label, status: "failed", path: dest, detail }], notes: [] }; + } +} + +const INSTALLERS: Record Promise> = { + claude, + codex, + cursor, + gemini, +}; + +export function installPlugin(kind: PluginKind, ctx: PluginContext): Promise { + return INSTALLERS[kind](ctx); +} + +async function fileIncludes(path: string, needle: string): Promise { + try { + return (await readFile(path, "utf8")).includes(needle); + } catch { + return false; + } +} + +/** + * Best-effort check for a user-level Upstash plugin, to warn about a second MCP server. + * Claude records installs in installed_plugins.json: https://code.claude.com/docs/en/plugins/loading#find-plugins-on-disk + * Gemini loads extensions from ~/.gemini/extensions: https://geminicli.com/docs/extensions/reference/#extension-format + */ +export async function isPluginInstalled(kind: PluginKind): Promise { + const claudeDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); + switch (kind) { + case "claude": + return fileIncludes(join(claudeDir, "plugins", "installed_plugins.json"), `"${PLUGIN_ID}"`); + case "codex": + return fileIncludes(join(homedir(), ".codex", "config.toml"), `[plugins."${PLUGIN_ID}"]`); + case "cursor": + return access(cursorPluginDir()).then(() => true, () => false); + case "gemini": + return access(join(homedir(), ".gemini", "extensions", "upstash")).then(() => true, () => false); + } +} diff --git a/src/setup/repo.ts b/src/setup/repo.ts new file mode 100644 index 0000000..b3173b3 --- /dev/null +++ b/src/setup/repo.ts @@ -0,0 +1,146 @@ +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, normalize, sep } from "node:path"; +import { gunzipSync } from "node:zlib"; +import { SKILLS_REPO } from "./agents.js"; + +/** Repo-relative path → file contents. */ +export type RepoFiles = Map; + +function cString(buf: Buffer, start: number, length: number): string { + const slice = buf.subarray(start, start + length); + const nul = slice.indexOf(0); + return slice.subarray(0, nul === -1 ? slice.length : nul).toString("utf8"); +} + +function paxPath(body: Buffer): string | undefined { + // Records are " key=value\n", len counting the whole record. + let offset = 0; + while (offset < body.length) { + const space = body.indexOf(0x20, offset); + if (space === -1) break; + const len = Number.parseInt(body.subarray(offset, space).toString("utf8"), 10); + if (!Number.isFinite(len) || len <= 0) break; + const record = body.subarray(space + 1, offset + len - 1).toString("utf8"); + const eq = record.indexOf("="); + if (eq !== -1 && record.slice(0, eq) === "path") return record.slice(eq + 1); + offset += len; + } + return undefined; +} + +/** Minimal reader for the ustar/pax archives GitHub serves; regular files only. */ +export function parseTar(tar: Buffer): RepoFiles { + const files: RepoFiles = new Map(); + let offset = 0; + let longName: string | undefined; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every((b) => b === 0)) break; + const size = Number.parseInt(cString(header, 124, 12).trim() || "0", 8); + const type = String.fromCharCode(header[156] ?? 0); + const bodyStart = offset + 512; + const body = tar.subarray(bodyStart, bodyStart + size); + offset = bodyStart + Math.ceil(size / 512) * 512; + + if (type === "x") { + longName = paxPath(body) ?? longName; + continue; + } + if (type === "L") { + longName = cString(body, 0, body.length); + continue; + } + if (type === "g") continue; + + const name = cString(header, 0, 100); + const prefix = cString(header, 345, 155); + const path = longName ?? (prefix ? `${prefix}/${name}` : name); + longName = undefined; + if (type === "0" || type === "\0") files.set(path, Buffer.from(body)); + } + return files; +} + +/** GitHub tarballs nest everything under `-/`. */ +export function stripTopDir(files: RepoFiles): RepoFiles { + const out: RepoFiles = new Map(); + for (const [path, content] of files) { + const slash = path.indexOf("/"); + if (slash !== -1 && slash < path.length - 1) out.set(path.slice(slash + 1), content); + } + return out; +} + +/** Downloads upstash/skills at its default branch. */ +export async function fetchSkillsRepo(): Promise { + const url = `https://codeload.github.com/${SKILLS_REPO}/tar.gz/main`; + let res: Response; + try { + res = await fetch(url, { headers: { "User-Agent": "upstash/cli" } }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`Could not download ${SKILLS_REPO}: ${reason}`); + } + if (!res.ok) throw new Error(`Could not download ${SKILLS_REPO}: HTTP ${res.status}`); + return stripTopDir(parseTar(gunzipSync(Buffer.from(await res.arrayBuffer())))); +} + +/** Files under `prefix/`, keyed by their path relative to it. */ +export function subtree(files: RepoFiles, prefix: string): RepoFiles { + const base = prefix.endsWith("/") ? prefix : `${prefix}/`; + const out: RepoFiles = new Map(); + for (const [path, content] of files) { + if (path.startsWith(base)) out.set(path.slice(base.length), content); + } + return out; +} + +/** + * Replaces `dest` with exactly `files`, so files removed upstream do not linger. + * `dest` is always a directory this CLI owns (named `upstash`). Every path is + * checked and the tree is written to a staging directory first, then swapped + * in through a backup, so a bad archive or a failed write or rename leaves the + * previous install in place. + */ +export async function writeTree(files: RepoFiles, dest: string): Promise { + if (files.size === 0) throw new Error(`Nothing to install into ${dest}`); + const entries = [...files].map(([rel, content]) => { + const clean = normalize(rel); + if (isAbsolute(clean) || clean === ".." || clean.startsWith(`..${sep}`)) { + throw new Error(`Refusing to write outside ${dest}: ${rel}`); + } + return [clean, content] as const; + }); + const staging = `${dest}.tmp-${process.pid}`; + await rm(staging, { recursive: true, force: true }); + try { + for (const [clean, content] of entries) { + const target = join(staging, clean); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); + } + } catch (err) { + await rm(staging, { recursive: true, force: true }); + throw err; + } + // Swap through a backup so a failed rename can put the previous install back. + const backup = `${dest}.old-${process.pid}`; + await rm(backup, { recursive: true, force: true }); + let hadPrevious = false; + try { + hadPrevious = await rename(dest, backup).then( + () => true, + (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") return false; + throw err; + }, + ); + await rename(staging, dest); + } catch (err) { + if (hadPrevious) await rename(backup, dest); + await rm(staging, { recursive: true, force: true }); + throw err; + } + if (hadPrevious) await rm(backup, { recursive: true, force: true }); + return files.size; +} diff --git a/src/setup/ui.ts b/src/setup/ui.ts new file mode 100644 index 0000000..52d31e0 --- /dev/null +++ b/src/setup/ui.ts @@ -0,0 +1,110 @@ +import * as p from "@clack/prompts"; +import { homedir } from "node:os"; +import type { Readable, Writable } from "node:stream"; +import pc from "picocolors"; +import type { Step } from "./plugins.js"; + +/** Streams the interactive UI reads and writes; swappable in tests. */ +export interface PromptIO { + input?: Readable; + output?: Writable; +} + +let io: PromptIO = {}; +export function setPromptIO(next: PromptIO): void { + io = next; +} +export function promptIO(): PromptIO { + return io; +} + +export class SetupCancelled extends Error { + constructor() { + super("Setup cancelled."); + } +} + +/** Unwraps a prompt answer, turning Ctrl+C / Esc into a SetupCancelled throw. */ +function answer(value: T | symbol): T { + if (p.isCancel(value)) throw new SetupCancelled(); + return value as T; +} + +export interface Choice { + value: T; + label: string; + hint?: string; +} + +export async function pickMany(message: string, options: Choice[], initial: T[]): Promise { + return answer( + await p.multiselect({ + ...io, + message, + options: options as p.Option[], + initialValues: initial, + required: true, + }), + ); +} + +export async function pickOne(message: string, options: Choice[], initial?: T): Promise { + return answer(await p.select({ ...io, message, options: options as p.Option[], initialValue: initial })); +} + +export async function confirm(message: string): Promise { + return answer(await p.confirm({ ...io, message, initialValue: true })); +} + +export const intro = (title: string): void => p.intro(pc.bgCyan(pc.black(` ${title} `)), io); +export const outro = (message: string): void => p.outro(message, io); +export const cancelled = (message: string): void => p.cancel(message, io); +export const note = (message: string, title: string): void => p.note(message, title, io); +export const spinner = (): p.SpinnerResult => p.spinner(io); + +/** `/home/me/.cursor/mcp.json` → `~/.cursor/mcp.json`. */ +export function tildify(path: string): string { + const home = homedir(); + return path === home || path.startsWith(home + "/") || path.startsWith(home + "\\") + ? "~" + path.slice(home.length) + : path; +} + +const STEP_ICON: Record = { + done: pc.green("✓"), + planned: pc.cyan("○"), + failed: pc.red("✗"), +}; + +/** Word-wraps to the terminal so long notes stay inside the guide rail. */ +function wrap(text: string, indent: string): string[] { + const width = Math.max(40, ((io.output as { columns?: number } | undefined)?.columns ?? process.stdout.columns ?? 80) - 6); + const lines: string[] = []; + let line = ""; + for (const word of text.split(" ")) { + if (line && line.length + 1 + word.length > width - indent.length) { + lines.push(line); + line = word; + } else { + line = line ? `${line} ${word}` : word; + } + } + if (line) lines.push(line); + return lines.map((l, i) => (i === 0 ? l : indent + l)); +} + +/** One agent's steps and notes, rendered under its spinner line. */ +export function printSteps(steps: Step[], notes: string[]): void { + const lines: string[] = []; + for (const s of steps) { + lines.push(`${STEP_ICON[s.status]} ${s.label}${s.path ? pc.dim(` → ${tildify(s.path)}`) : ""}`); + if (s.detail) lines.push(` ${pc.red(s.detail)}`); + } + for (const n of notes) { + const [first = "", ...rest] = wrap(n, " "); + lines.push(`${pc.yellow("!")} ${pc.yellow(first)}`, ...rest.map((l) => pc.yellow(l))); + } + if (lines.length > 0) p.log.message(lines, { ...io, symbol: pc.gray("│"), spacing: 0 }); +} + +export const dim = pc.dim; diff --git a/tests/unit/setup.test.ts b/tests/unit/setup.test.ts new file mode 100644 index 0000000..3d784ab --- /dev/null +++ b/tests/unit/setup.test.ts @@ -0,0 +1,396 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { gzipSync } from "node:zlib"; +import { registerSetup, setRunner } from "../../src/commands/setup.js"; +import { runCommand } from "../../src/setup/plugins.js"; +import { parseTar, stripTopDir, writeTree } from "../../src/setup/repo.js"; +import { setPromptIO } from "../../src/setup/ui.js"; +import { mergeServerEntry, upsertTomlTable, buildTomlTable, stripJsonc } from "../../src/setup/mcp-config.js"; + +// --- a tiny tar writer, so the fixtures need no binaries --------------------- + +function header(name: string, size: number, type: string): Buffer { + const h = Buffer.alloc(512); + h.write(name.slice(0, 100), 0); + h.write("0000644\0", 100); + h.write(size.toString(8).padStart(11, "0") + "\0", 124); + h.write(type, 156); + h.write("ustar\0", 257); + return h; +} + +function pad(buf: Buffer): Buffer { + const rest = buf.length % 512; + return rest === 0 ? buf : Buffer.concat([buf, Buffer.alloc(512 - rest)]); +} + +function tar(files: Record, opts: { paxFor?: string } = {}): Buffer { + const parts: Buffer[] = [header("pax_global_header", 0, "g")]; + for (const [name, text] of Object.entries(files)) { + const body = Buffer.from(text); + if (name === opts.paxFor) { + const rec = ` path=${name}\n`; + const len = String(rec.length + String(rec.length).length); + const pax = Buffer.from(`${len}${rec}`); + parts.push(header("PaxHeader", pax.length, "x"), pad(pax)); + parts.push(header("truncated", body.length, "0"), pad(body)); + } else { + parts.push(header(name, body.length, "0"), pad(body)); + } + } + parts.push(Buffer.alloc(1024)); + return Buffer.concat(parts); +} + +const REPO = { + "skills-main/skills/upstash/SKILL.md": "---\nname: upstash\n---\n", + "skills-main/skills/upstash/upstash-redis-js/overview.md": "redis", + "skills-main/skills/upstash-redis-js/SKILL.md": "redis source", + "skills-main/.cursor-plugin/plugin.json": '{"name":"upstash"}', + "skills-main/assets/icon.png": "png", + "skills-main/README.md": "readme", +}; + +// --- harness --------------------------------------------------------------- + +let home: string; +let calls: string[][]; +const origHome = process.env.HOME; +const origCwd = process.cwd(); + +function program(): Command { + const p = new Command() + .exitOverride() + .option("--email ") + .option("--api-key "); + registerSetup(p); + return p; +} + +async function run(argv: string[]): Promise { + const out: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => out.push(args.join(" ")); + try { + await program().parseAsync(["node", "upstash", "setup", ...argv]); + } finally { + console.log = orig; + } + return out.join("\n"); +} + +async function runJson(argv: string[]): Promise<{ results: Array<{ agent: string; method: string; ok: boolean; notes: string[]; steps: Array<{ detail?: string }> }> }> { + return JSON.parse(await run([...argv, "--json"])); +} + +const read = (...p: string[]): string => readFileSync(join(home, ...p), "utf8"); +const readJson = (...p: string[]): Record => JSON.parse(read(...p)); + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "upstash-setup-")); + process.env.HOME = home; + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.UPSTASH_EMAIL; + delete process.env.UPSTASH_API_KEY; + calls = []; + setRunner(async (bin, args) => { + calls.push([bin, ...args]); + return { ok: true, missing: false, output: "" }; + }); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(gzipSync(tar(REPO)))); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setRunner(runCommand); + setPromptIO({}); + process.chdir(origCwd); + process.env.HOME = origHome; + process.exitCode = 0; + rmSync(home, { recursive: true, force: true }); +}); + +// --- pure helpers ---------------------------------------------------------- + +describe("tar reader", () => { + it("reads regular files, honors pax paths, and strips the top directory", () => { + const long = `skills-main/${"a/".repeat(60)}deep.md`; + const files = stripTopDir(parseTar(tar({ ...REPO, [long]: "deep" }, { paxFor: long }))); + expect(files.get("skills/upstash/SKILL.md")?.toString()).toBe("---\nname: upstash\n---\n"); + expect(files.get(long.slice("skills-main/".length))?.toString()).toBe("deep"); + expect(files.has("pax_global_header")).toBe(false); + }); +}); + +describe("config merging", () => { + it("keeps other JSON servers and reports a replaced entry", () => { + const first = mergeServerEntry({ mcpServers: { other: { url: "x" } }, theme: "dark" }, "mcpServers", "upstash", { url: "u" }); + expect(first.replaced).toBe(false); + expect(first.config).toEqual({ mcpServers: { other: { url: "x" }, upstash: { url: "u" } }, theme: "dark" }); + expect(mergeServerEntry(first.config, "mcpServers", "upstash", { url: "v" }).replaced).toBe(true); + }); + + it("replaces a TOML table with its sub-tables and leaves neighbours alone", () => { + const existing = [ + "model = \"o3\"", + "", + "[mcp_servers.upstash]", + "url = \"old\"", + "", + "[mcp_servers.upstash.http_headers]", + "Authorization = \"Bearer old\"", + "", + "[mcp_servers.other]", + "url = \"y\"", + "", + ].join("\n"); + const block = buildTomlTable("mcp_servers.upstash", { url: "new" }); + const { content, replaced } = upsertTomlTable(existing, "mcp_servers.upstash", block); + expect(replaced).toBe(true); + expect(content).toBe('model = "o3"\n\n[mcp_servers.upstash]\nurl = "new"\n\n[mcp_servers.other]\nurl = "y"\n'); + }); + + it("appends a TOML table to a file that lacks it", () => { + const block = buildTomlTable("mcp_servers.upstash", { url: "u" }); + const { content, replaced } = upsertTomlTable('model = "o3"\n', "mcp_servers.upstash", block); + expect(replaced).toBe(false); + expect(content).toBe('model = "o3"\n\n[mcp_servers.upstash]\nurl = "u"\n'); + }); + + it("matches quoted TOML keys instead of declaring the table twice", () => { + const existing = '[mcp_servers."upstash"] # added by hand\nurl = "old"\n\n[mcp_servers.\'upstash\'.http_headers]\nX = "1"\n\n[[profiles]]\nname = "a"\n'; + const { content, replaced } = upsertTomlTable(existing, "mcp_servers.upstash", buildTomlTable("mcp_servers.upstash", { url: "new" })); + expect(replaced).toBe(true); + expect(content).toBe('[mcp_servers.upstash]\nurl = "new"\n\n[[profiles]]\nname = "a"\n'); + }); + + it("detects inline and dotted definitions outside the table's own section", () => { + const block = buildTomlTable("mcp_servers.upstash", { url: "new" }); + for (const existing of [ + 'mcp_servers.upstash = { url = "x" }\n', + 'mcp_servers.upstash.url = "x"\n', + '[mcp_servers]\n"upstash" = { url = "x" }\n', + ]) { + expect(() => upsertTomlTable(existing, "mcp_servers.upstash", block)).toThrow(/defined inline/); + } + // Dotted keys inside the table's own section, other servers, and text inside a multi-line string are fine. + const ok = '[mcp_servers.upstash]\nurl = "old"\nhttp_headers.X = "1"\n\n[mcp_servers]\nother = { url = "y" }\n\n[notes]\ntext = """\nmcp_servers.upstash = 1\n"""\n'; + expect(upsertTomlTable(ok, "mcp_servers.upstash", block).replaced).toBe(true); + }); + + it("reads JSONC with comments and trailing commas, leaving strings alone", () => { + const text = '{\n // note\n "a": "x, }",\n "b": [1, 2,],\n /* c */ "c": { "d": "//not a comment", },\n}'; + expect(JSON.parse(stripJsonc(text))).toEqual({ a: "x, }", b: [1, 2], c: { d: "//not a comment" } }); + }); +}); + +describe("writeTree", () => { + it("keeps the previous install when an entry would escape the destination", async () => { + const dest = join(home, "skills", "upstash"); + await writeTree(new Map([["SKILL.md", Buffer.from("v1")]]), dest); + await expect(writeTree(new Map([["SKILL.md", Buffer.from("v2")], ["../evil", Buffer.from("x")]]), dest)).rejects.toThrow( + /Refusing to write outside/, + ); + expect(readFileSync(join(dest, "SKILL.md"), "utf8")).toBe("v1"); + expect(existsSync(join(home, "skills", "evil"))).toBe(false); + }); + + it("swaps in a new tree and leaves no staging or backup directories", async () => { + const dest = join(home, "skills", "upstash"); + await writeTree(new Map([["old.md", Buffer.from("v1")]]), dest); + await writeTree(new Map([["new.md", Buffer.from("v2")]]), dest); + expect(readdirSync(dest)).toEqual(["new.md"]); + expect(readdirSync(join(home, "skills"))).toEqual(["upstash"]); + }); +}); + +// --- the command ----------------------------------------------------------- + +describe("setup", () => { + it("installs plugins through the agent CLIs and falls back to MCP + skill elsewhere", async () => { + const { results } = await runJson(["--claude", "--codex", "--cursor", "--opencode"]); + expect(results.map((r) => [r.agent, r.method, r.ok])).toEqual([ + ["claude", "plugin", true], + ["codex", "plugin", true], + ["cursor", "plugin", true], + ["opencode", "mcp", true], + ]); + expect(calls).toContainEqual(["claude", "plugin", "marketplace", "add", "upstash/skills", "--scope", "user"]); + expect(calls).toContainEqual(["claude", "plugin", "install", "upstash@upstash", "--scope", "user"]); + expect(calls).toContainEqual(["codex", "plugin", "marketplace", "add", "upstash/skills"]); + expect(calls).toContainEqual(["codex", "plugin", "add", "upstash@upstash"]); + + const cursor = join(home, ".cursor", "plugins", "local", "upstash"); + expect(readFileSync(join(cursor, ".cursor-plugin", "plugin.json"), "utf8")).toBe('{"name":"upstash"}'); + expect(existsSync(join(cursor, "skills", "upstash-redis-js", "SKILL.md"))).toBe(true); + expect(existsSync(join(cursor, "README.md"))).toBe(false); + + expect(readJson(".config", "opencode", "opencode.json").mcp.upstash).toEqual({ + type: "remote", + url: "https://mcp.upstash.com/mcp", + enabled: true, + }); + expect(read(".config", "opencode", "skills", "upstash", "upstash-redis-js", "overview.md")).toBe("redis"); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("falls back to MCP + skill when the agent CLI is missing", async () => { + setRunner(async (bin) => ({ ok: false, missing: true, output: `${bin} not found on PATH` })); + const { results } = await runJson(["--claude"]); + expect(results[0]).toMatchObject({ agent: "claude", method: "mcp", ok: true }); + expect(results[0]!.notes[0]).toContain("`claude` not found on PATH"); + expect(readJson(".claude.json").mcpServers.upstash).toEqual({ type: "http", url: "https://mcp.upstash.com/mcp" }); + expect(read(".claude", "skills", "upstash", "SKILL.md")).toContain("name: upstash"); + }); + + it("fails instead of falling back with --mode plugin", async () => { + setRunner(async () => ({ ok: false, missing: false, output: "boom" })); + const { results } = await runJson(["--codex", "--mode", "plugin"]); + expect(results[0]).toMatchObject({ method: "plugin", ok: false }); + expect(process.exitCode).toBe(1); + expect(existsSync(join(home, ".codex", "config.toml"))).toBe(false); + }); + + it("writes no credentials, and drops a key header left by an older setup", async () => { + mkdirSync(join(home, ".codex"), { recursive: true }); + writeFileSync( + join(home, ".codex", "config.toml"), + '[mcp_servers.other]\nurl = "y"\n\n[mcp_servers.upstash]\nurl = "x"\n\n[mcp_servers.upstash.http_headers]\nAuthorization = "Bearer me@x.com:sk"\n', + ); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync(join(home, ".cursor", "mcp.json"), '{"mcpServers":{"upstash":{"url":"x","headers":{"Authorization":"Bearer k"}}}}'); + const { results } = await runJson(["--codex", "--cursor", "--mode", "mcp", "--email", "me@x.com", "--api-key", "sk"]); + expect(results.every((r) => r.ok)).toBe(true); + expect(read(".codex", "config.toml")).toBe('[mcp_servers.other]\nurl = "y"\n\n[mcp_servers.upstash]\nurl = "https://mcp.upstash.com/mcp"\n'); + expect(readJson(".cursor", "mcp.json").mcpServers.upstash).toEqual({ url: "https://mcp.upstash.com/mcp" }); + }); + + it("has no --auth option", async () => { + await expect(run(["--claude", "--auth", "api-key"])).rejects.toThrow(/unknown option '--auth'/); + }); + + it("has no --ref option", async () => { + await expect(run(["--gemini", "--ref", "v2"])).rejects.toThrow(/unknown option '--ref'/); + expect(calls).toEqual([]); + }); + + it("refuses to append a second upstash table when Codex defines it inline", async () => { + mkdirSync(join(home, ".codex"), { recursive: true }); + const config = '[mcp_servers]\nupstash = { url = "x" }\n'; + writeFileSync(join(home, ".codex", "config.toml"), config); + const { results } = await runJson(["--codex", "--mode", "mcp"]); + expect(results[0]!.ok).toBe(false); + expect(results[0]!.steps[0]!.detail).toMatch(/defined inline or with dotted keys/); + expect(read(".codex", "config.toml")).toBe(config); + }); + + it("sets up detected agents with --yes", async () => { + mkdirSync(join(home, ".gemini")); + mkdirSync(join(home, ".copilot")); + const { results } = await runJson(["--yes"]); + expect(results.map((r) => r.agent)).toEqual(["gemini", "copilot"]); + expect(calls).toContainEqual(["gemini", "extensions", "install", "https://github.com/upstash/skills", "--consent"]); + }); + + it("errors when nothing is selected or detected", async () => { + await expect(run(["--yes"])).rejects.toThrow(/No agents selected/); + }); + + it("configures the project with --project, keeping Claude's project-scoped plugin", async () => { + const project = join(home, "proj"); + mkdirSync(project); + process.chdir(project); + const { results } = await runJson(["--claude", "--cursor", "--project"]); + expect(results.map((r) => [r.agent, r.method])).toEqual([ + ["claude", "plugin"], + ["cursor", "mcp"], + ]); + expect(calls).toContainEqual(["claude", "plugin", "install", "upstash@upstash", "--scope", "project"]); + expect(readJson("proj", ".cursor", "mcp.json").mcpServers.upstash).toEqual({ url: "https://mcp.upstash.com/mcp" }); + expect(existsSync(join(project, ".cursor", "skills", "upstash", "SKILL.md"))).toBe(true); + }); + + it("replaces the skill folder so files removed upstream do not linger", async () => { + const stale = join(home, ".claude", "skills", "upstash", "stale.md"); + mkdirSync(join(home, ".claude", "skills", "upstash"), { recursive: true }); + writeFileSync(stale, "old"); + await runJson(["--claude", "--mode", "mcp"]); + expect(existsSync(stale)).toBe(false); + }); + + it("refuses to overwrite an unreadable JSON config", async () => { + mkdirSync(join(home, ".cursor")); + writeFileSync(join(home, ".cursor", "mcp.json"), "{nope"); + const { results } = await runJson(["--cursor", "--mode", "mcp"]); + expect(results[0]!.ok).toBe(false); + expect(read(".cursor", "mcp.json")).toBe("{nope"); + }); + + it("changes nothing and downloads nothing on --dry-run", async () => { + const out = await run(["--claude", "--opencode", "--dry-run"]); + expect(out).toContain("Dry run"); + expect(calls).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + expect(existsSync(join(home, ".config"))).toBe(false); + }); + + it("warns when switching modes would leave two upstash servers", async () => { + mkdirSync(join(home, ".cursor", "plugins", "local", "upstash"), { recursive: true }); + const { results } = await runJson(["--cursor", "--mode", "mcp"]); + expect(results[0]!.notes.join(" ")).toMatch(/plugin is also installed/); + + const again = await runJson(["--cursor"]); + expect(again.results[0]!.notes.join(" ")).toMatch(/also declares an "upstash" MCP server/); + }); + + describe("interactive", () => { + const ENTER = "\r"; + const SPACE = " "; + const DOWN = "\x1b[B"; + + /** Runs setup against fake terminal streams, typing one key batch per prompt. */ + async function interactive(argv: string[], answers: string[]): Promise { + const input = new PassThrough(); + const output = new PassThrough(); + let screen = ""; + output.on("data", (d) => (screen += d.toString())); + setPromptIO({ input, output }); + const done = program().parseAsync(["node", "upstash", "setup", ...argv]); + for (const keys of answers) { + await new Promise((r) => setTimeout(r, 30)); + for (const key of keys.match(/\x1b\[.|./gs) ?? []) input.write(key); + } + await done; + return screen; + } + + it("asks for scope and agents, then sets up the picked agents", async () => { + mkdirSync(join(home, ".cursor")); + // Scope: All projects. Agents: Cursor is pre-checked as detected; also tick OpenCode (7th row). Confirm. + const screen = await interactive([], [ENTER, DOWN.repeat(6) + SPACE + ENTER, ENTER]); + expect(screen).toContain("Which agents should use Upstash?"); + expect(screen).toContain("Connected 2 agents to Upstash."); + expect(existsSync(join(home, ".cursor", "plugins", "local", "upstash", ".cursor-plugin", "plugin.json"))).toBe(true); + expect(readJson(".config", "opencode", "opencode.json").mcp.upstash.url).toBe("https://mcp.upstash.com/mcp"); + }); + + it("skips questions answered by flags", async () => { + process.chdir(home); + const screen = await interactive(["--codex", "--project"], [ENTER]); + expect(screen).not.toContain("Where should Upstash be set up?"); + expect(screen).not.toContain("Which agents"); + expect(calls).toEqual([]); + expect(read(".codex", "config.toml")).toBe('[mcp_servers.upstash]\nurl = "https://mcp.upstash.com/mcp"\n'); + }); + + it("changes nothing when the plan is declined", async () => { + await interactive(["--opencode"], [ENTER, DOWN + ENTER]); + expect(process.exitCode).toBe(130); + expect(existsSync(join(home, ".config"))).toBe(false); + }); + }); +});