From 817e86897f1fa997eb732b0a0f33f1e8df07e2a0 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Wed, 23 Sep 2026 15:39:57 +0000 Subject: [PATCH 1/6] add `upstash setup` to connect AI agents to Upstash Installs the upstash/skills plugin (MCP server + skills) through each agent's own CLI where plugins are supported (Claude Code, Codex, Gemini CLI), as a local plugin for Cursor, and otherwise writes the remote MCP server into the agent's config and installs the combined `upstash` skill (VS Code, Copilot CLI, OpenCode). Falls back to MCP + skill when a plugin can't be installed. Supports --project, --mode auto|plugin|mcp, --auth oauth|api-key, --dry-run, --json, and agent detection. No new dependencies: skills come from the upstash/skills tarball, parsed with a small tar reader. --- README.md | 28 ++++ src/cli.ts | 2 + src/commands/setup.ts | 291 +++++++++++++++++++++++++++++++++++++++ src/setup/agents.ts | 165 ++++++++++++++++++++++ src/setup/mcp-config.ts | 179 ++++++++++++++++++++++++ src/setup/plugins.ts | 210 ++++++++++++++++++++++++++++ src/setup/repo.ts | 117 ++++++++++++++++ tests/unit/setup.test.ts | 285 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 1277 insertions(+) create mode 100644 src/commands/setup.ts create mode 100644 src/setup/agents.ts create mode 100644 src/setup/mcp-config.ts create mode 100644 src/setup/plugins.ts create mode 100644 src/setup/repo.ts create mode 100644 tests/unit/setup.test.ts diff --git a/README.md b/README.md index 93a3305..1f95a6a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,34 @@ 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 | + +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 --auth api-key # API key header instead of OAuth (uses `upstash login` credentials) +upstash setup --dry-run # show what would change +``` + +The MCP server authenticates with OAuth by default: on first use your agent opens a consent page where you pick the account (turn read-only off if the agent should change resources). Plugins are OAuth-only, so `--auth api-key` writes MCP config instead. 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/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..9128dd4 --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,291 @@ +import { Command } from "commander"; +import { access } from "node:fs/promises"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { resolveAuth } from "../auth.js"; +import { plainError } from "../output.js"; +import { + AGENT_NAMES, + SKILL_NAME, + SKILLS_REPO, + getAgent, + type AgentName, + type McpAuth, + 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"; + +type Mode = "auto" | "plugin" | "mcp"; +type AuthMode = McpAuth["mode"]; +type Method = "plugin" | "mcp"; + +interface SetupFlags extends Partial> { + mode: string; + auth: string; + project?: boolean; + yes?: boolean; + ref: string; + dryRun?: boolean; + json?: boolean; + email?: string; + apiKey?: string; +} + +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( + "--auth ", + "oauth (browser consent on first use) or api-key (saved login, --email/--api-key, or UPSTASH_EMAIL/UPSTASH_API_KEY)", + "oauth", + ) + .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("--ref ", `Git ref of ${SKILLS_REPO} to install from`, "main") + .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; +} + +function ask(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +async function promptAgents(detected: AgentName[]): Promise { + process.stderr.write("Which agents should be connected to Upstash?\n"); + AGENT_NAMES.forEach((name, i) => { + const tag = detected.includes(name) ? " (detected)" : ""; + process.stderr.write(` ${i + 1}) ${getAgent(name).displayName}${tag}\n`); + }); + const defaults = detected.map((n) => AGENT_NAMES.indexOf(n) + 1).join(","); + const answer = await ask(`Numbers, comma-separated${defaults ? ` [${defaults}]` : ""}: `); + if (!answer) return detected; + const picked = new Set(); + for (const part of answer.split(/[\s,]+/).filter(Boolean)) { + const name = AGENT_NAMES[Number(part) - 1] ?? AGENT_NAMES.find((n) => n === part); + if (!name) throw plainError(`Unknown choice: ${part}`); + picked.add(name); + } + return [...picked]; +} + +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); + const interactive = process.stdin.isTTY && process.stderr.isTTY && !flags.yes && !flags.json; + const agents = interactive ? await promptAgents(detected) : detected; + if (agents.length === 0) { + const list = AGENT_NAMES.map((n) => `--${n}`).join(" "); + throw plainError(`No agents selected${interactive ? "" : " or detected"}. Pass one or more of: ${list}`); + } + return agents; +} + +function resolveMcpAuth(flags: SetupFlags, cmd: Command): McpAuth { + // Passing --email/--api-key is a clear signal, unless --auth says otherwise. + const explicitKey = Boolean(flags.email || flags.apiKey); + const mode: AuthMode = + cmd.getOptionValueSource("auth") === "default" && explicitKey ? "api-key" : (flags.auth as AuthMode); + if (mode === "oauth") return { mode }; + const { email, apiKey } = resolveAuth(cmd); + return { mode, token: `${email}:${apiKey}` }; +} + +/** 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, + auth: McpAuth, +): { 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." }; + } + if (auth.mode === "api-key") { + return { method: "mcp", note: "The plugin authenticates with OAuth only; wrote MCP config with your API key instead." }; + } + return { method: "plugin" }; +} + +async function setupMcp( + name: AgentName, + scope: Scope, + auth: McpAuth, + dryRun: boolean, + repo: () => Promise, +): Promise<{ ok: boolean; steps: Step[] }> { + const agent = getAgent(name); + const steps: Step[] = []; + const mcpLabel = `MCP server upstash (${auth.mode === "api-key" ? "API key" : "OAuth"})`; + 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, auth); + 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 }; +} + +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 (!["oauth", "api-key"].includes(flags.auth)) throw plainError(`--auth must be oauth or api-key (got ${flags.auth})`); + + const scope: Scope = flags.project ? "project" : "global"; + const dryRun = Boolean(flags.dryRun); + const auth = resolveMcpAuth(flags, cmd); + const agents = await resolveAgents(flags, scope); + + // One download serves every agent that needs files. + let repoPromise: Promise | undefined; + const repo = (): Promise => (repoPromise ??= fetchSkillsRepo(flags.ref)); + + const results: AgentResult[] = []; + for (const name of agents) { + const agent = getAgent(name); + const choice = chooseMethod(name, mode, scope, auth); + const notes = choice.note ? [choice.note] : []; + + if (choice.method === "plugin" && agent.plugin) { + const res = await installPlugin(agent.plugin.kind, { scope, ref: flags.ref, 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.`); + } + results.push({ agent: name, name: agent.displayName, method: "plugin", ok: res.ok, steps: res.steps, notes: [...notes, ...res.notes] }); + continue; + } + 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, auth, dryRun, repo); + results.push({ agent: name, name: agent.displayName, method: "mcp", ok: res.ok, steps: res.steps, notes }); + } + + if (results.some((r) => !r.ok)) process.exitCode = 1; + + if (flags.json) { + console.log(JSON.stringify({ scope, auth: auth.mode, dry_run: dryRun, results }, null, 2)); + } else { + printSummary(results, scope, auth.mode, dryRun); + } + return results; +} + +const ICON: Record = { done: "+", planned: "~", failed: "x" }; + +function printSummary(results: AgentResult[], scope: Scope, auth: AuthMode, dryRun: boolean): void { + const lines: string[] = []; + const where = scope === "project" ? "this project" : "your user config"; + lines.push(`${dryRun ? "Dry run: " : ""}Upstash setup for ${where} (${auth === "oauth" ? "OAuth" : "API key"})`, ""); + 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."); + if (auth === "oauth") { + lines.push( + "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..6f977b0 --- /dev/null +++ b/src/setup/agents.ts @@ -0,0 +1,165 @@ +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"; + +/** `token` is the `email:API_KEY` pair the remote MCP accepts as a bearer token. */ +export type McpAuth = { mode: "oauth" } | { mode: "api-key"; token: string }; + +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; + buildEntry: (auth: McpAuth) => 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; + +function claudeConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR || home(".claude"); +} + +function claudeGlobalMcpPath(): string { + const dir = process.env.CLAUDE_CONFIG_DIR; + return dir ? join(dir, ".claude.json") : home(".claude.json"); +} + +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"); +} + +/** + * The header must be named `Authorization`: Codex decides a server's auth mode + * from that name, and anything else reads as "no credential" and falls back to + * OAuth. + */ +function withAuth( + entry: Record, + auth: McpAuth, + key = "headers", +): Record { + if (auth.mode !== "api-key") return entry; + return { ...entry, [key]: { Authorization: `Bearer ${auth.token}` } }; +} + +const OPENCODE_FILES = ["opencode.json", "opencode.jsonc", ".opencode.json", ".opencode.jsonc"]; + +export const AGENTS = { + claude: { + displayName: "Claude Code", + plugin: { kind: "claude", scopes: ["global", "project"] }, + mcp: { + format: "json", + paths: (s) => [s === "project" ? cwd(".mcp.json") : claudeGlobalMcpPath()], + configKey: "mcpServers", + buildEntry: (auth) => withAuth({ type: "http", url: MCP_URL }, auth), + }, + skillDir: (s) => pick(s, join(".claude", "skills"), join(claudeConfigDir(), "skills")), + detect: (s) => (s === "project" ? [cwd(".mcp.json"), cwd(".claude")] : [claudeConfigDir()]), + }, + 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", + buildEntry: (auth) => withAuth({ url: MCP_URL }, auth, "http_headers"), + }, + skillDir: (s) => pick(s, join(".agents", "skills"), home(".agents", "skills")), + detect: (s) => [pick(s, ".codex", home(".codex"))], + }, + 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", + buildEntry: (auth) => withAuth({ url: MCP_URL }, auth), + }, + skillDir: (s) => pick(s, join(".cursor", "skills"), home(".cursor", "skills")), + detect: (s) => [pick(s, ".cursor", home(".cursor"))], + }, + 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", + buildEntry: (auth) => withAuth({ httpUrl: MCP_URL }, auth), + }, + skillDir: (s) => pick(s, join(".gemini", "skills"), home(".gemini", "skills")), + detect: (s) => [pick(s, ".gemini", home(".gemini"))], + }, + vscode: { + displayName: "VS Code", + mcp: { + format: "json", + paths: (s) => [pick(s, join(".vscode", "mcp.json"), join(vscodeUserDir(), "mcp.json"))], + configKey: "servers", + buildEntry: (auth) => withAuth({ type: "http", url: MCP_URL }, auth), + }, + skillDir: (s) => pick(s, join(".agents", "skills"), home(".agents", "skills")), + detect: (s) => [pick(s, ".vscode", vscodeUserDir())], + }, + copilot: { + displayName: "GitHub Copilot CLI", + mcp: { + format: "json", + paths: (s) => [pick(s, ".mcp.json", home(".copilot", "mcp-config.json"))], + configKey: "mcpServers", + buildEntry: (auth) => withAuth({ type: "http", url: MCP_URL, tools: ["*"] }, auth), + }, + 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")]), + }, + opencode: { + displayName: "OpenCode", + mcp: { + format: "json", + paths: (s) => + OPENCODE_FILES.map((f) => (s === "project" ? cwd(f) : home(".config", "opencode", f))), + configKey: "mcp", + buildEntry: (auth) => withAuth({ type: "remote", url: MCP_URL, enabled: true }, auth), + }, + 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..f056472 --- /dev/null +++ b/src/setup/mcp-config.ts @@ -0,0 +1,179 @@ +import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { SERVER_NAME, type AgentConfig, type McpAuth, type Scope } from "./agents.js"; + +/** Drops // and /* *\/ comments outside strings, so JSONC configs (OpenCode, VS Code) parse. */ +export function stripJsonComments(text: string): string { + let out = ""; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '"') { + const start = i++; + while (i < text.length && text[i] !== '"') { + if (text[i] === "\\") i++; + i++; + } + out += text.slice(start, ++i); + } 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; +} + +async function readText(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return ""; + } +} + +export async function readJsonConfig(path: string): Promise> { + const raw = (await readText(path)).trim(); + if (!raw) return {}; + try { + const parsed = JSON.parse(stripJsonComments(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; nested objects become sub-tables + * (`[mcp_servers.upstash.http_headers]`). JSON string/array literals are valid + * TOML for the values we write. + */ +export function buildTomlTable(table: string, entry: Record): string { + const lines = [`[${table}]`]; + const subTables: string[] = []; + for (const [key, value] of Object.entries(entry)) { + if (value && typeof value === "object" && !Array.isArray(value)) { + subTables.push("", `[${table}.${tomlKey(key)}]`); + for (const [k, v] of Object.entries(value)) subTables.push(`${tomlKey(k)} = ${JSON.stringify(v)}`); + } else { + lines.push(`${tomlKey(key)} = ${JSON.stringify(value)}`); + } + } + return [...lines, ...subTables].join("\n") + "\n"; +} + +function isHeader(line: string, table: string): boolean { + const t = line.trim(); + return t === `[${table}]` || t.startsWith(`[${table}] `) || t.startsWith(`[${table}]#`); +} + +/** Replaces `[table]` and its `[table.*]` sub-tables, or appends the block. */ +export function upsertTomlTable( + existing: string, + table: string, + block: string, +): { content: string; replaced: boolean } { + const lines = existing.split("\n"); + 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 t = lines[end]!.trim(); + if (t.startsWith("[") && !t.startsWith(`[${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, + auth: McpAuth, +): Promise<{ path: string; replaced: boolean }> { + const path = await resolveMcpPath(agent, scope); + const entry = agent.mcp.buildEntry(auth); + let content: string; + let replaced: boolean; + + if (agent.mcp.format === "toml") { + const block = buildTomlTable(`${agent.mcp.configKey}.${SERVER_NAME}`, entry); + ({ content, replaced } = upsertTomlTable(await readText(path), `${agent.mcp.configKey}.${SERVER_NAME}`, block)); + } 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"); + // An API-key setup puts a credential in this file; `mode` on writeFile only + // applies when the file is created, so tighten existing files explicitly. + if (auth.mode === "api-key" && process.platform !== "win32") await chmod(path, 0o600); + 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}`; + return (await readText(path)).split("\n").some((l) => isHeader(l, table)) ? path : 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..9c2b44c --- /dev/null +++ b/src/setup/plugins.ts @@ -0,0 +1,210 @@ +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; + ref: string; + 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: [] }; +} + +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; +} + +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}`, + ); +} + +async function gemini(ctx: PluginContext): Promise { + const label = "Extension upstash"; + if (ctx.dryRun) return { ok: true, steps: [planned(label)], notes: [] }; + const args = ["extensions", "install", `https://github.com/${SKILLS_REPO}`, "--consent"]; + if (ctx.ref !== "main") args.push("--ref", ctx.ref); + let res = await ctx.run("gemini", args); + 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/. + */ +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}@${ctx.ref} 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. */ +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..5a14a06 --- /dev/null +++ b/src/setup/repo.ts @@ -0,0 +1,117 @@ +import { mkdir, 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; + +const REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; + +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; +} + +export async function fetchSkillsRepo(ref: string): Promise { + if (!REF_PATTERN.test(ref)) throw new Error(`Invalid git ref: ${ref}`); + const url = `https://codeload.github.com/${SKILLS_REPO}/tar.gz/${ref}`; + 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}@${ref}: ${reason}`); + } + if (!res.ok) throw new Error(`Could not download ${SKILLS_REPO}@${ref}: 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`). + */ +export async function writeTree(files: RepoFiles, dest: string): Promise { + if (files.size === 0) throw new Error(`Nothing to install into ${dest}`); + await rm(dest, { recursive: true, force: true }); + for (const [rel, content] of files) { + const clean = normalize(rel); + if (isAbsolute(clean) || clean === ".." || clean.startsWith(`..${sep}`)) { + throw new Error(`Refusing to write outside ${dest}: ${rel}`); + } + const target = join(dest, clean); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); + } + return files.size; +} diff --git a/tests/unit/setup.test.ts b/tests/unit/setup.test.ts new file mode 100644 index 0000000..002cda8 --- /dev/null +++ b/tests/unit/setup.test.ts @@ -0,0 +1,285 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; +import { registerSetup, setRunner } from "../../src/commands/setup.js"; +import { runCommand } from "../../src/setup/plugins.js"; +import { parseTar, stripTopDir } from "../../src/setup/repo.js"; +import { mergeServerEntry, upsertTomlTable, buildTomlTable } 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[] }> }> { + 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); + 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", http_headers: { Authorization: "Bearer t" } }); + 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\n[mcp_servers.upstash.http_headers]\nAuthorization = "Bearer t"\n', + ); + }); +}); + +// --- 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 an API-key header when credentials are passed, bypassing OAuth-only plugins", async () => { + mkdirSync(join(home, ".codex"), { recursive: true }); + writeFileSync(join(home, ".codex", "config.toml"), '[mcp_servers.other]\nurl = "y"\n'); + const { results } = await runJson(["--codex", "--cursor", "--email", "me@x.com", "--api-key", "sk"]); + expect(results.map((r) => r.method)).toEqual(["mcp", "mcp"]); + expect(results[0]!.notes[0]).toContain("OAuth only"); + expect(calls).toEqual([]); + expect(read(".codex", "config.toml")).toBe( + '[mcp_servers.other]\nurl = "y"\n\n[mcp_servers.upstash]\nurl = "https://mcp.upstash.com/mcp"\n\n[mcp_servers.upstash.http_headers]\nAuthorization = "Bearer me@x.com:sk"\n', + ); + expect(readJson(".cursor", "mcp.json").mcpServers.upstash.headers).toEqual({ Authorization: "Bearer me@x.com:sk" }); + if (process.platform !== "win32") { + expect(statSync(join(home, ".cursor", "mcp.json")).mode & 0o777).toBe(0o600); + } + }); + + 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/); + }); +}); From 734b61d7300c0c87111f4152f7964c838292b331 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 12:08:44 +0000 Subject: [PATCH 2/6] setup: interactive prompts with @clack/prompts Replaces the numbered readline prompt with an arrow-key flow when run in a terminal: pick scope, multi-select agents (detected ones pre-checked, already-connected ones marked), pick OAuth or API key (prompting for credentials when none are saved), confirm a plan, then a spinner per agent with its steps. Flags still answer questions up front; -y, --json and non-TTY runs keep the existing plain output unchanged. @clack/prompts is pinned to ~1.0.1, the last line that runs on Node 18. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LWUubWwYWp1dZTK1S9Ek95 --- README.md | 2 + package-lock.json | 36 +++++- package.json | 4 +- src/commands/setup.ts | 253 +++++++++++++++++++++++++++++---------- src/setup/ui.ts | 122 +++++++++++++++++++ tests/unit/setup.test.ts | 53 ++++++++ 6 files changed, 401 insertions(+), 69 deletions(-) create mode 100644 src/setup/ui.ts diff --git a/README.md b/README.md index 1f95a6a..cdd400c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ It picks the best install path per agent: | 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), which agents (detected ones are pre-selected), and how they sign in. 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 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/commands/setup.ts b/src/commands/setup.ts index 9128dd4..0d5fc0f 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,7 +1,6 @@ import { Command } from "commander"; import { access } from "node:fs/promises"; import { join } from "node:path"; -import { createInterface } from "node:readline"; import { resolveAuth } from "../auth.js"; import { plainError } from "../output.js"; import { @@ -16,6 +15,7 @@ import { 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 AuthMode = McpAuth["mode"]; @@ -98,45 +98,86 @@ export async function detectAgents(scope: Scope): Promise { return found; } -function ask(question: string): Promise { - const rl = createInterface({ input: process.stdin, output: process.stderr }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); +/** 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; } -async function promptAgents(detected: AgentName[]): Promise { - process.stderr.write("Which agents should be connected to Upstash?\n"); - AGENT_NAMES.forEach((name, i) => { - const tag = detected.includes(name) ? " (detected)" : ""; - process.stderr.write(` ${i + 1}) ${getAgent(name).displayName}${tag}\n`); - }); - const defaults = detected.map((n) => AGENT_NAMES.indexOf(n) + 1).join(","); - const answer = await ask(`Numbers, comma-separated${defaults ? ` [${defaults}]` : ""}: `); - if (!answer) return detected; - const picked = new Set(); - for (const part of answer.split(/[\s,]+/).filter(Boolean)) { - const name = AGENT_NAMES[Number(part) - 1] ?? AGENT_NAMES.find((n) => n === part); - if (!name) throw plainError(`Unknown choice: ${part}`); - picked.add(name); - } - return [...picked]; +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); - const interactive = process.stdin.isTTY && process.stderr.isTTY && !flags.yes && !flags.json; - const agents = interactive ? await promptAgents(detected) : detected; - if (agents.length === 0) { - const list = AGENT_NAMES.map((n) => `--${n}`).join(" "); - throw plainError(`No agents selected${interactive ? "" : " or detected"}. Pass one or more of: ${list}`); + 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, + ); +} + +async function promptAuth(flags: SetupFlags, cmd: Command): Promise { + if (cmd.getOptionValueSource("auth") !== "default" || flags.email || flags.apiKey) { + return resolveMcpAuth(flags, cmd); + } + const mode = await ui.pickOne( + "How should agents sign in to Upstash?", + [ + { value: "oauth", label: "OAuth", hint: "browser consent on first use, recommended" }, + { value: "api-key", label: "API key", hint: "stored in the agent's MCP config; plugins need OAuth" }, + ], + "oauth", + ); + if (mode === "oauth") return { mode }; + try { + const { email, apiKey } = resolveAuth(cmd); + ui.info(`Using the saved credentials for ${ui.bold(email)}`); + return { mode, token: `${email}:${apiKey}` }; + } catch { + const email = await ui.askText("Upstash account email", { placeholder: "you@example.com" }); + const apiKey = await ui.askText( + `Management API key ${ui.dim("(console.upstash.com/account/api)")}`, + { secret: true }, + ); + return { mode, token: `${email}:${apiKey}` }; } - return agents; } function resolveMcpAuth(flags: SetupFlags, cmd: Command): McpAuth { @@ -209,51 +250,76 @@ async function setupMcp( return { ok: steps.every((s) => s.status !== "failed"), steps }; } +interface AgentContext { + mode: Mode; + scope: Scope; + auth: McpAuth; + dryRun: boolean; + ref: string; + repo: () => Promise; +} + +async function setupAgent(name: AgentName, ctx: AgentContext): Promise { + const { mode, scope, auth, dryRun, repo } = ctx; + const agent = getAgent(name); + const choice = chooseMethod(name, mode, scope, auth); + const notes = choice.note ? [choice.note] : []; + + if (choice.method === "plugin" && agent.plugin) { + const res = await installPlugin(agent.plugin.kind, { scope, ref: ctx.ref, 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, auth, 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(ref: string): () => Promise { + let repoPromise: Promise | undefined; + return () => (repoPromise ??= fetchSkillsRepo(ref)); +} + 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 (!["oauth", "api-key"].includes(flags.auth)) throw plainError(`--auth must be oauth or api-key (got ${flags.auth})`); + if (!isInteractive(flags)) return runPlain(cmd, 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(cmd: Command, flags: SetupFlags, mode: Mode): Promise { const scope: Scope = flags.project ? "project" : "global"; const dryRun = Boolean(flags.dryRun); const auth = resolveMcpAuth(flags, cmd); const agents = await resolveAgents(flags, scope); - - // One download serves every agent that needs files. - let repoPromise: Promise | undefined; - const repo = (): Promise => (repoPromise ??= fetchSkillsRepo(flags.ref)); + const ctx: AgentContext = { mode, scope, auth, dryRun, ref: flags.ref, repo: lazyRepo(flags.ref) }; const results: AgentResult[] = []; - for (const name of agents) { - const agent = getAgent(name); - const choice = chooseMethod(name, mode, scope, auth); - const notes = choice.note ? [choice.note] : []; - - if (choice.method === "plugin" && agent.plugin) { - const res = await installPlugin(agent.plugin.kind, { scope, ref: flags.ref, 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.`); - } - results.push({ agent: name, name: agent.displayName, method: "plugin", ok: res.ok, steps: res.steps, notes: [...notes, ...res.notes] }); - continue; - } - 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, auth, dryRun, repo); - results.push({ agent: name, name: agent.displayName, method: "mcp", ok: res.ok, steps: res.steps, notes }); - } - + for (const name of agents) results.push(await setupAgent(name, ctx)); if (results.some((r) => !r.ok)) process.exitCode = 1; if (flags.json) { @@ -264,6 +330,65 @@ export async function runSetup(cmd: Command): Promise { 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"); + const auth = await promptAuth(flags, cmd); + + if (!dryRun) { + const width = Math.max(...agents.map((n) => getAgent(n).displayName.length)); + const plan = agents.map((n) => { + const { method } = chooseMethod(n, mode, scope, auth); + 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, auth, dryRun, ref: flags.ref, repo: lazyRepo(flags.ref) }; + 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."]; + if (auth.mode === "oauth") { + next.push( + "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, auth: AuthMode, dryRun: boolean): void { diff --git a/src/setup/ui.ts b/src/setup/ui.ts new file mode 100644 index 0000000..9afb55d --- /dev/null +++ b/src/setup/ui.ts @@ -0,0 +1,122 @@ +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 async function askText(message: string, opts: { placeholder?: string; secret?: boolean } = {}): Promise { + const validate = (v: string | undefined): string | undefined => (v?.trim() ? undefined : "Required"); + const value = opts.secret + ? await p.password({ ...io, message, validate }) + : await p.text({ ...io, message, placeholder: opts.placeholder, validate }); + return answer(value).trim(); +} + +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 info = (message: string): void => p.log.info(message, io); +export const warn = (message: string): void => p.log.warn(message, 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; +export const bold = pc.bold; +export const cyan = pc.cyan; diff --git a/tests/unit/setup.test.ts b/tests/unit/setup.test.ts index 002cda8..2a28516 100644 --- a/tests/unit/setup.test.ts +++ b/tests/unit/setup.test.ts @@ -3,10 +3,12 @@ import { Command } from "commander"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, 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 } from "../../src/setup/repo.js"; +import { setPromptIO } from "../../src/setup/ui.js"; import { mergeServerEntry, upsertTomlTable, buildTomlTable } from "../../src/setup/mcp-config.js"; // --- a tiny tar writer, so the fixtures need no binaries --------------------- @@ -105,6 +107,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); setRunner(runCommand); + setPromptIO({}); process.chdir(origCwd); process.env.HOME = origHome; process.exitCode = 0; @@ -282,4 +285,54 @@ describe("setup", () => { 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, agents and auth, 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). + const screen = await interactive( + [], + [ENTER, DOWN.repeat(6) + SPACE + ENTER, 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 and prompts for missing API-key credentials", async () => { + process.chdir(home); + const screen = await interactive(["--codex", "--project"], [DOWN + ENTER, "me@x.com" + ENTER, "sk" + ENTER, 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")).toContain('Authorization = "Bearer me@x.com:sk"'); + }); + + it("changes nothing when the plan is declined", async () => { + await interactive(["--opencode"], [ENTER, ENTER, DOWN + ENTER]); + expect(process.exitCode).toBe(130); + expect(existsSync(join(home, ".config"))).toBe(false); + }); + }); }); From 11720569cae09cb2b8c2a9808a4817c3932c6098 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:01:54 +0000 Subject: [PATCH 3/6] setup: link the vendor docs behind each agent's paths and commands Every agent entry and plugin installer now cites the documentation page its config paths, entry shape and CLI commands come from. Where the docs are silent (where .claude.json lives under CLAUDE_CONFIG_DIR, the dotted OpenCode file names) the comment says so and what the code relies on instead. Comments only; no behavior change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LWUubWwYWp1dZTK1S9Ek95 --- src/setup/agents.ts | 51 ++++++++++++++++++++++++++++++++++++++++++++ src/setup/plugins.ts | 16 +++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/setup/agents.ts b/src/setup/agents.ts index 6f977b0..ebd338c 100644 --- a/src/setup/agents.ts +++ b/src/setup/agents.ts @@ -36,15 +36,21 @@ 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"); @@ -67,9 +73,20 @@ function withAuth( return { ...entry, [key]: { Authorization: `Bearer ${auth.token}` } }; } +/** 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"` + `headers`) + * 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"] }, @@ -82,6 +99,12 @@ export const AGENTS = { 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`, `http_headers`; + * 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"] }, @@ -94,6 +117,11 @@ export const AGENTS = { 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"] }, @@ -106,6 +134,12 @@ export const AGENTS = { 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`, `headers`) + * 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"] }, @@ -118,6 +152,13 @@ export const AGENTS = { 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: { @@ -129,6 +170,11 @@ export const AGENTS = { 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: { @@ -142,6 +188,11 @@ export const AGENTS = { // 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: { diff --git a/src/setup/plugins.ts b/src/setup/plugins.ts index 9c2b44c..93b0432 100644 --- a/src/setup/plugins.ts +++ b/src/setup/plugins.ts @@ -97,6 +97,7 @@ async function viaCli( 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( @@ -114,6 +115,10 @@ async function claude(ctx: PluginContext): Promise { 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, @@ -127,6 +132,7 @@ function codex(ctx: PluginContext): Promise { ); } +/** https://geminicli.com/docs/extensions/reference/#install-an-extension (`install --ref --consent`, `update `) */ async function gemini(ctx: PluginContext): Promise { const label = "Extension upstash"; if (ctx.dryRun) return { ok: true, steps: [planned(label)], notes: [] }; @@ -152,6 +158,10 @@ export function cursorPluginDir(): string { * 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(); @@ -194,7 +204,11 @@ async function fileIncludes(path: string, needle: string): Promise { } } -/** Best-effort check for a user-level Upstash plugin, to warn about a second MCP server. */ +/** + * 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) { From 2d02b2a070682001d5f95963d344c922dd58ec91 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:27:06 +0000 Subject: [PATCH 4/6] setup: OAuth only, no API keys in agent configs; fix review findings Drop --auth api-key and the API-key prompts. Setup now never writes a credential: every agent gets the bare server entry and signs in with OAuth on first use. Rerunning setup replaces an `upstash` entry left by an older API-key setup, so its header is removed too. Also fixes the other review findings: - Only a missing config file reads as empty; other read errors (EACCES, EISDIR) now stop the write instead of overwriting the file from {}. - JSONC configs with trailing commas parse. - TOML table matching resolves quoted keys, so [mcp_servers."upstash"] is replaced instead of declared a second time. - Skill/plugin trees are validated and written to a staging directory before the old install is removed. - --ref with a non-default branch uses MCP + skill for Claude Code and Codex, whose plugin installs cannot pin a ref, instead of silently installing the default branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LWUubWwYWp1dZTK1S9Ek95 --- README.md | 5 +- src/commands/setup.ts | 110 +++++++++------------------------ src/setup/agents.ts | 40 ++++-------- src/setup/mcp-config.ts | 127 ++++++++++++++++++++++++++++----------- src/setup/repo.ts | 27 ++++++--- src/setup/ui.ts | 12 ---- tests/unit/setup.test.ts | 89 ++++++++++++++++++--------- 7 files changed, 213 insertions(+), 197 deletions(-) diff --git a/README.md b/README.md index cdd400c..26ae255 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ It picks the best install path per agent: | 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), which agents (detected ones are pre-selected), and how they sign in. Flags answer a question up front; `-y` or `--json` skip the prompts entirely. +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. @@ -41,11 +41,10 @@ Agents are detected from their config directories, or chosen with flags (`--clau 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 --auth api-key # API key header instead of OAuth (uses `upstash login` credentials) upstash setup --dry-run # show what would change ``` -The MCP server authenticates with OAuth by default: on first use your agent opens a consent page where you pick the account (turn read-only off if the agent should change resources). Plugins are OAuth-only, so `--auth api-key` writes MCP config instead. Rerunning `setup` updates what it installed. +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 diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 0d5fc0f..a7a366a 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,7 +1,6 @@ import { Command } from "commander"; import { access } from "node:fs/promises"; import { join } from "node:path"; -import { resolveAuth } from "../auth.js"; import { plainError } from "../output.js"; import { AGENT_NAMES, @@ -9,7 +8,6 @@ import { SKILLS_REPO, getAgent, type AgentName, - type McpAuth, type Scope, } from "../setup/agents.js"; import { hasMcpEntry, resolveMcpPath, writeMcpEntry } from "../setup/mcp-config.js"; @@ -18,19 +16,15 @@ import { fetchSkillsRepo, subtree, writeTree, type RepoFiles } from "../setup/re import * as ui from "../setup/ui.js"; type Mode = "auto" | "plugin" | "mcp"; -type AuthMode = McpAuth["mode"]; type Method = "plugin" | "mcp"; interface SetupFlags extends Partial> { mode: string; - auth: string; project?: boolean; yes?: boolean; ref: string; dryRun?: boolean; json?: boolean; - email?: string; - apiKey?: string; } export interface AgentResult { @@ -61,11 +55,6 @@ export function registerSetup(program: Command): void { command .option("--mode ", "auto (plugin where supported, else MCP + skill), plugin, or mcp", "auto") - .option( - "--auth ", - "oauth (browser consent on first use) or api-key (saved login, --email/--api-key, or UPSTASH_EMAIL/UPSTASH_API_KEY)", - "oauth", - ) .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("--ref ", `Git ref of ${SKILLS_REPO} to install from`, "main") @@ -153,49 +142,12 @@ async function promptAgents(flags: SetupFlags, scope: Scope): Promise { - if (cmd.getOptionValueSource("auth") !== "default" || flags.email || flags.apiKey) { - return resolveMcpAuth(flags, cmd); - } - const mode = await ui.pickOne( - "How should agents sign in to Upstash?", - [ - { value: "oauth", label: "OAuth", hint: "browser consent on first use, recommended" }, - { value: "api-key", label: "API key", hint: "stored in the agent's MCP config; plugins need OAuth" }, - ], - "oauth", - ); - if (mode === "oauth") return { mode }; - try { - const { email, apiKey } = resolveAuth(cmd); - ui.info(`Using the saved credentials for ${ui.bold(email)}`); - return { mode, token: `${email}:${apiKey}` }; - } catch { - const email = await ui.askText("Upstash account email", { placeholder: "you@example.com" }); - const apiKey = await ui.askText( - `Management API key ${ui.dim("(console.upstash.com/account/api)")}`, - { secret: true }, - ); - return { mode, token: `${email}:${apiKey}` }; - } -} - -function resolveMcpAuth(flags: SetupFlags, cmd: Command): McpAuth { - // Passing --email/--api-key is a clear signal, unless --auth says otherwise. - const explicitKey = Boolean(flags.email || flags.apiKey); - const mode: AuthMode = - cmd.getOptionValueSource("auth") === "default" && explicitKey ? "api-key" : (flags.auth as AuthMode); - if (mode === "oauth") return { mode }; - const { email, apiKey } = resolveAuth(cmd); - return { mode, token: `${email}:${apiKey}` }; -} - /** 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, - auth: McpAuth, + ref: string, ): { method: Method; note?: string } { const plugin = getAgent(name).plugin; if (mode === "mcp") return { method: "mcp" }; @@ -207,8 +159,9 @@ function chooseMethod( if (!plugin.scopes.includes(scope)) { return { method: "mcp", note: "Plugins install per user, not per project; wrote project-level MCP + skill instead." }; } - if (auth.mode === "api-key") { - return { method: "mcp", note: "The plugin authenticates with OAuth only; wrote MCP config with your API key instead." }; + // Claude Code and Codex install from the marketplace's default branch, so they cannot pin a ref. + if (ref !== "main" && (plugin.kind === "claude" || plugin.kind === "codex")) { + return { method: "mcp", note: `The plugin installs from the marketplace's default branch; installed MCP + skill from ${ref} instead.` }; } return { method: "plugin" }; } @@ -216,13 +169,12 @@ function chooseMethod( async function setupMcp( name: AgentName, scope: Scope, - auth: McpAuth, dryRun: boolean, repo: () => Promise, ): Promise<{ ok: boolean; steps: Step[] }> { const agent = getAgent(name); const steps: Step[] = []; - const mcpLabel = `MCP server upstash (${auth.mode === "api-key" ? "API key" : "OAuth"})`; + const mcpLabel = "MCP server upstash"; const skillPath = join(agent.skillDir(scope), SKILL_NAME); const skillLabel = `Skill ${SKILL_NAME}`; @@ -233,7 +185,7 @@ async function setupMcp( } try { - const { path, replaced } = await writeMcpEntry(agent, scope, auth); + 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) }); @@ -253,16 +205,15 @@ async function setupMcp( interface AgentContext { mode: Mode; scope: Scope; - auth: McpAuth; dryRun: boolean; ref: string; repo: () => Promise; } async function setupAgent(name: AgentName, ctx: AgentContext): Promise { - const { mode, scope, auth, dryRun, repo } = ctx; + const { mode, scope, dryRun, repo } = ctx; const agent = getAgent(name); - const choice = chooseMethod(name, mode, scope, auth); + const choice = chooseMethod(name, mode, scope, ctx.ref); const notes = choice.note ? [choice.note] : []; if (choice.method === "plugin" && agent.plugin) { @@ -284,7 +235,7 @@ async function setupAgent(name: AgentName, ctx: AgentContext): 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 (!["oauth", "api-key"].includes(flags.auth)) throw plainError(`--auth must be oauth or api-key (got ${flags.auth})`); - if (!isInteractive(flags)) return runPlain(cmd, flags, mode); + if (!isInteractive(flags)) return runPlain(flags, mode); try { return await runInteractive(cmd, flags, mode); } catch (err) { @@ -311,21 +261,20 @@ export async function runSetup(cmd: Command): Promise { } } -async function runPlain(cmd: Command, flags: SetupFlags, mode: Mode): Promise { +async function runPlain(flags: SetupFlags, mode: Mode): Promise { const scope: Scope = flags.project ? "project" : "global"; const dryRun = Boolean(flags.dryRun); - const auth = resolveMcpAuth(flags, cmd); const agents = await resolveAgents(flags, scope); - const ctx: AgentContext = { mode, scope, auth, dryRun, ref: flags.ref, repo: lazyRepo(flags.ref) }; + const ctx: AgentContext = { mode, scope, dryRun, ref: flags.ref, repo: lazyRepo(flags.ref) }; 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, auth: auth.mode, dry_run: dryRun, results }, null, 2)); + console.log(JSON.stringify({ scope, dry_run: dryRun, results }, null, 2)); } else { - printSummary(results, scope, auth.mode, dryRun); + printSummary(results, scope, dryRun); } return results; } @@ -337,12 +286,11 @@ async function runInteractive(cmd: Command, flags: SetupFlags, mode: Mode): Prom const scope = await promptScope(cmd); const agents = await promptAgents(flags, scope); if (agents.length === 0) throw noAgentsError("selected"); - const auth = await promptAuth(flags, cmd); if (!dryRun) { const width = Math.max(...agents.map((n) => getAgent(n).displayName.length)); const plan = agents.map((n) => { - const { method } = chooseMethod(n, mode, scope, auth); + const { method } = chooseMethod(n, mode, scope, flags.ref); const what = method === "plugin" ? "Upstash plugin" : `MCP server + ${SKILL_NAME} skill`; return `${getAgent(n).displayName.padEnd(width)} ${ui.dim(what)}`; }); @@ -350,7 +298,7 @@ async function runInteractive(cmd: Command, flags: SetupFlags, mode: Mode): Prom if (!(await ui.confirm("Continue?"))) throw new ui.SetupCancelled(); } - const ctx: AgentContext = { mode, scope, auth, dryRun, ref: flags.ref, repo: lazyRepo(flags.ref) }; + const ctx: AgentContext = { mode, scope, dryRun, ref: flags.ref, repo: lazyRepo(flags.ref) }; const results: AgentResult[] = []; for (const name of agents) { const label = getAgent(name).displayName; @@ -372,13 +320,11 @@ async function runInteractive(cmd: Command, flags: SetupFlags, mode: Mode): Prom } 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."]; - if (auth.mode === "oauth") { - next.push( - "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.", - ); - } + 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 @@ -391,10 +337,10 @@ async function runInteractive(cmd: Command, flags: SetupFlags, mode: Mode): Prom const ICON: Record = { done: "+", planned: "~", failed: "x" }; -function printSummary(results: AgentResult[], scope: Scope, auth: AuthMode, dryRun: boolean): void { +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} (${auth === "oauth" ? "OAuth" : "API key"})`, ""); + 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) { @@ -405,12 +351,10 @@ function printSummary(results: AgentResult[], scope: Scope, auth: AuthMode, dryR lines.push(""); } if (!dryRun && results.some((r) => r.ok)) { - lines.push("Restart your agents to pick up the changes."); - if (auth === "oauth") { - lines.push( - "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.", - ); - } + 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 index ebd338c..326a842 100644 --- a/src/setup/agents.ts +++ b/src/setup/agents.ts @@ -9,9 +9,6 @@ export const PLUGIN_ID = "upstash@upstash"; export type Scope = "global" | "project"; -/** `token` is the `email:API_KEY` pair the remote MCP accepts as a bearer token. */ -export type McpAuth = { mode: "oauth" } | { mode: "api-key"; token: string }; - export type PluginKind = "claude" | "codex" | "cursor" | "gemini"; export interface AgentConfig { @@ -23,7 +20,8 @@ export interface AgentConfig { /** Candidate config files; the first that exists wins, otherwise the first is created. */ paths: (scope: Scope) => string[]; configKey: string; - buildEntry: (auth: McpAuth) => Record; + /** 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; @@ -59,20 +57,6 @@ export function vscodeUserDir(platform: NodeJS.Platform = process.platform): str return join(process.env.XDG_CONFIG_HOME || home(".config"), "Code", "User"); } -/** - * The header must be named `Authorization`: Codex decides a server's auth mode - * from that name, and anything else reads as "no credential" and falls back to - * OAuth. - */ -function withAuth( - entry: Record, - auth: McpAuth, - key = "headers", -): Record { - if (auth.mode !== "api-key") return entry; - return { ...entry, [key]: { Authorization: `Bearer ${auth.token}` } }; -} - /** 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"]; @@ -83,7 +67,7 @@ const OPENCODE_FILES = ["opencode.json", "opencode.jsonc", ".opencode.json", ".o */ export const AGENTS = { /** - * MCP: https://code.claude.com/docs/en/mcp (user scope: ~/.claude.json, project scope: .mcp.json, `type: "http"` + `headers`) + * 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 */ @@ -94,13 +78,13 @@ export const AGENTS = { format: "json", paths: (s) => [s === "project" ? cwd(".mcp.json") : claudeGlobalMcpPath()], configKey: "mcpServers", - buildEntry: (auth) => withAuth({ type: "http", url: MCP_URL }, auth), + 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`, `http_headers`; + * 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 @@ -112,7 +96,7 @@ export const AGENTS = { format: "toml", paths: (s) => [pick(s, join(".codex", "config.toml"), home(".codex", "config.toml"))], configKey: "mcp_servers", - buildEntry: (auth) => withAuth({ url: MCP_URL }, auth, "http_headers"), + entry: { url: MCP_URL }, }, skillDir: (s) => pick(s, join(".agents", "skills"), home(".agents", "skills")), detect: (s) => [pick(s, ".codex", home(".codex"))], @@ -129,13 +113,13 @@ export const AGENTS = { format: "json", paths: (s) => [pick(s, join(".cursor", "mcp.json"), home(".cursor", "mcp.json"))], configKey: "mcpServers", - buildEntry: (auth) => withAuth({ url: MCP_URL }, auth), + 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`, `headers`) + * 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 @@ -147,7 +131,7 @@ export const AGENTS = { format: "json", paths: (s) => [pick(s, join(".gemini", "settings.json"), home(".gemini", "settings.json"))], configKey: "mcpServers", - buildEntry: (auth) => withAuth({ httpUrl: MCP_URL }, auth), + entry: { httpUrl: MCP_URL }, }, skillDir: (s) => pick(s, join(".gemini", "skills"), home(".gemini", "skills")), detect: (s) => [pick(s, ".gemini", home(".gemini"))], @@ -165,7 +149,7 @@ export const AGENTS = { format: "json", paths: (s) => [pick(s, join(".vscode", "mcp.json"), join(vscodeUserDir(), "mcp.json"))], configKey: "servers", - buildEntry: (auth) => withAuth({ type: "http", url: MCP_URL }, auth), + entry: { type: "http", url: MCP_URL }, }, skillDir: (s) => pick(s, join(".agents", "skills"), home(".agents", "skills")), detect: (s) => [pick(s, ".vscode", vscodeUserDir())], @@ -181,7 +165,7 @@ export const AGENTS = { format: "json", paths: (s) => [pick(s, ".mcp.json", home(".copilot", "mcp-config.json"))], configKey: "mcpServers", - buildEntry: (auth) => withAuth({ type: "http", url: MCP_URL, tools: ["*"] }, auth), + 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 @@ -200,7 +184,7 @@ export const AGENTS = { paths: (s) => OPENCODE_FILES.map((f) => (s === "project" ? cwd(f) : home(".config", "opencode", f))), configKey: "mcp", - buildEntry: (auth) => withAuth({ type: "remote", url: MCP_URL, enabled: true }, auth), + entry: { type: "remote", url: MCP_URL, enabled: true }, }, skillDir: (s) => pick(s, join(".agents", "skills"), home(".config", "opencode", "skills")), detect: (s) => diff --git a/src/setup/mcp-config.ts b/src/setup/mcp-config.ts index f056472..6dd652d 100644 --- a/src/setup/mcp-config.ts +++ b/src/setup/mcp-config.ts @@ -1,20 +1,56 @@ -import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { SERVER_NAME, type AgentConfig, type McpAuth, type Scope } from "./agents.js"; +import { SERVER_NAME, type AgentConfig, type Scope } from "./agents.js"; -/** Drops // and /* *\/ comments outside strings, so JSONC configs (OpenCode, VS Code) parse. */ -export function stripJsonComments(text: string): string { +/** 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 start = i++; - while (i < text.length && text[i] !== '"') { - if (text[i] === "\\") i++; - i++; - } - out += text.slice(start, ++i); + 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] === "*") { @@ -29,11 +65,13 @@ export function stripJsonComments(text: string): string { 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 { - return ""; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw err; } } @@ -41,7 +79,7 @@ export async function readJsonConfig(path: string): Promise; } @@ -71,28 +109,49 @@ export function mergeServerEntry( const tomlKey = (key: string): string => (/^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key)); -/** - * Serializes a flat entry as a TOML table; nested objects become sub-tables - * (`[mcp_servers.upstash.http_headers]`). JSON string/array literals are valid - * TOML for the values we write. - */ +/** 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}]`]; - const subTables: string[] = []; - for (const [key, value] of Object.entries(entry)) { - if (value && typeof value === "object" && !Array.isArray(value)) { - subTables.push("", `[${table}.${tomlKey(key)}]`); - for (const [k, v] of Object.entries(value)) subTables.push(`${tomlKey(k)} = ${JSON.stringify(v)}`); - } else { - lines.push(`${tomlKey(key)} = ${JSON.stringify(value)}`); - } + for (const [key, value] of Object.entries(entry)) lines.push(`${tomlKey(key)} = ${JSON.stringify(value)}`); + return lines.join("\n") + "\n"; +} + +/** + * 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); + if (!m) return undefined; + const keys: string[] = []; + const re = /\s*("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)\s*(\.|$)/y; + let pos = 0; + const body = m[1]!; + 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 [...lines, ...subTables].join("\n") + "\n"; + return pos >= body.length ? keys : undefined; } +const isTableHeader = (line: string): boolean => /^\s*\[/.test(line) && (tableKeys(line) !== undefined || /^\s*\[\[/.test(line)); + function isHeader(line: string, table: string): boolean { - const t = line.trim(); - return t === `[${table}]` || t.startsWith(`[${table}] `) || t.startsWith(`[${table}]#`); + 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 && want.every((k, i) => keys[i] === k); } /** Replaces `[table]` and its `[table.*]` sub-tables, or appends the block. */ @@ -109,8 +168,8 @@ export function upsertTomlTable( } let end = start + 1; while (end < lines.length) { - const t = lines[end]!.trim(); - if (t.startsWith("[") && !t.startsWith(`[${table}.`)) break; + const line = lines[end]!; + if (isTableHeader(line) && !isSubTable(line, table)) break; end++; } const before = lines.slice(0, start).join("\n").trimEnd(); @@ -139,10 +198,9 @@ export function resolveMcpPath(agent: AgentConfig, scope: Scope): Promise { const path = await resolveMcpPath(agent, scope); - const entry = agent.mcp.buildEntry(auth); + const entry = agent.mcp.entry; let content: string; let replaced: boolean; @@ -157,9 +215,6 @@ export async function writeMcpEntry( await mkdir(dirname(path), { recursive: true }); await writeFile(path, content, "utf8"); - // An API-key setup puts a credential in this file; `mode` on writeFile only - // applies when the file is created, so tighten existing files explicitly. - if (auth.mode === "api-key" && process.platform !== "win32") await chmod(path, 0o600); return { path, replaced }; } diff --git a/src/setup/repo.ts b/src/setup/repo.ts index 5a14a06..0fd0e4f 100644 --- a/src/setup/repo.ts +++ b/src/setup/repo.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; +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"; @@ -99,19 +99,32 @@ export function subtree(files: RepoFiles, prefix: string): RepoFiles { /** * Replaces `dest` with exactly `files`, so files removed upstream do not linger. - * `dest` is always a directory this CLI owns (named `upstash`). + * `dest` is always a directory this CLI owns (named `upstash`). Every path is + * checked and the tree is written to a staging directory first, so a bad + * archive or a failed write 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}`); - await rm(dest, { recursive: true, force: true }); - for (const [rel, content] of files) { + 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}`); } - const target = join(dest, clean); - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, content); + 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); + } + await rm(dest, { recursive: true, force: true }); + await rename(staging, dest); + } catch (err) { + await rm(staging, { recursive: true, force: true }); + throw err; } return files.size; } diff --git a/src/setup/ui.ts b/src/setup/ui.ts index 9afb55d..52d31e0 100644 --- a/src/setup/ui.ts +++ b/src/setup/ui.ts @@ -56,20 +56,10 @@ export async function confirm(message: string): Promise { return answer(await p.confirm({ ...io, message, initialValue: true })); } -export async function askText(message: string, opts: { placeholder?: string; secret?: boolean } = {}): Promise { - const validate = (v: string | undefined): string | undefined => (v?.trim() ? undefined : "Required"); - const value = opts.secret - ? await p.password({ ...io, message, validate }) - : await p.text({ ...io, message, placeholder: opts.placeholder, validate }); - return answer(value).trim(); -} - 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 info = (message: string): void => p.log.info(message, io); -export const warn = (message: string): void => p.log.warn(message, io); export const spinner = (): p.SpinnerResult => p.spinner(io); /** `/home/me/.cursor/mcp.json` → `~/.cursor/mcp.json`. */ @@ -118,5 +108,3 @@ export function printSteps(steps: Step[], notes: string[]): void { } export const dim = pc.dim; -export const bold = pc.bold; -export const cyan = pc.cyan; diff --git a/tests/unit/setup.test.ts b/tests/unit/setup.test.ts index 2a28516..ddbad29 100644 --- a/tests/unit/setup.test.ts +++ b/tests/unit/setup.test.ts @@ -1,15 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Command } from "commander"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, 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 } from "../../src/setup/repo.js"; +import { parseTar, stripTopDir, writeTree } from "../../src/setup/repo.js"; import { setPromptIO } from "../../src/setup/ui.js"; -import { mergeServerEntry, upsertTomlTable, buildTomlTable } from "../../src/setup/mcp-config.js"; +import { mergeServerEntry, upsertTomlTable, buildTomlTable, stripJsonc } from "../../src/setup/mcp-config.js"; // --- a tiny tar writer, so the fixtures need no binaries --------------------- @@ -155,12 +155,34 @@ describe("config merging", () => { }); it("appends a TOML table to a file that lacks it", () => { - const block = buildTomlTable("mcp_servers.upstash", { url: "u", http_headers: { Authorization: "Bearer t" } }); + 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\n[mcp_servers.upstash.http_headers]\nAuthorization = "Bearer t"\n', + 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("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); }); }); @@ -211,20 +233,34 @@ describe("setup", () => { expect(existsSync(join(home, ".codex", "config.toml"))).toBe(false); }); - it("writes an API-key header when credentials are passed, bypassing OAuth-only plugins", async () => { + 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'); - const { results } = await runJson(["--codex", "--cursor", "--email", "me@x.com", "--api-key", "sk"]); - expect(results.map((r) => r.method)).toEqual(["mcp", "mcp"]); - expect(results[0]!.notes[0]).toContain("OAuth only"); - expect(calls).toEqual([]); - expect(read(".codex", "config.toml")).toBe( - '[mcp_servers.other]\nurl = "y"\n\n[mcp_servers.upstash]\nurl = "https://mcp.upstash.com/mcp"\n\n[mcp_servers.upstash.http_headers]\nAuthorization = "Bearer me@x.com:sk"\n', + 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', ); - expect(readJson(".cursor", "mcp.json").mcpServers.upstash.headers).toEqual({ Authorization: "Bearer me@x.com:sk" }); - if (process.platform !== "win32") { - expect(statSync(join(home, ".cursor", "mcp.json")).mode & 0o777).toBe(0o600); - } + 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("uses MCP + skill when --ref pins a branch the Claude and Codex plugins cannot install", async () => { + const { results } = await runJson(["--claude", "--codex", "--gemini", "--ref", "v2"]); + expect(results.map((r) => [r.agent, r.method])).toEqual([ + ["claude", "mcp"], + ["codex", "mcp"], + ["gemini", "plugin"], + ]); + expect(results[0]!.notes[0]).toContain("from v2"); + expect(calls).toContainEqual(["gemini", "extensions", "install", "https://github.com/upstash/skills", "--consent", "--ref", "v2"]); + expect(String(vi.mocked(fetch).mock.calls[0]![0])).toContain("/tar.gz/v2"); }); it("sets up detected agents with --yes", async () => { @@ -307,30 +343,27 @@ describe("setup", () => { return screen; } - it("asks for scope, agents and auth, then sets up the picked agents", async () => { + 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). - const screen = await interactive( - [], - [ENTER, DOWN.repeat(6) + SPACE + ENTER, ENTER, ENTER], - ); + // 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 and prompts for missing API-key credentials", async () => { + it("skips questions answered by flags", async () => { process.chdir(home); - const screen = await interactive(["--codex", "--project"], [DOWN + ENTER, "me@x.com" + ENTER, "sk" + ENTER, ENTER]); + 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")).toContain('Authorization = "Bearer me@x.com:sk"'); + 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, ENTER, DOWN + ENTER]); + await interactive(["--opencode"], [ENTER, DOWN + ENTER]); expect(process.exitCode).toBe(130); expect(existsSync(join(home, ".config"))).toBe(false); }); From d7a49d115d26aa0d22358448a47fd4abbdd6e237 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:24:58 +0000 Subject: [PATCH 5/6] setup: drop --ref, keep the old skill install until the swap succeeds - Remove --ref. Everything installs from the default branch of upstash/skills, so no user-supplied string reaches an agent CLI (Gemini's install runs through a shell on Windows) or the download URL. - writeTree moves the previous install to a backup before renaming the staged tree into place, and restores it if that rename fails. - Codex config: refuse to append [mcp_servers.upstash] when the server is already defined inline or with dotted keys (mcp_servers.upstash = {...}, or upstash = {...} under [mcp_servers]), which would declare the table twice and break the file. The step fails with a message saying how to fix the file, and hasMcpEntry recognises those forms too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LWUubWwYWp1dZTK1S9Ek95 --- src/commands/setup.ts | 23 ++++-------- src/setup/mcp-config.ts | 80 +++++++++++++++++++++++++++++++++------- src/setup/plugins.ts | 9 ++--- src/setup/repo.ts | 36 +++++++++++++----- tests/unit/setup.test.ts | 49 ++++++++++++++++++------ 5 files changed, 139 insertions(+), 58 deletions(-) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index a7a366a..f28301a 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -5,7 +5,6 @@ import { plainError } from "../output.js"; import { AGENT_NAMES, SKILL_NAME, - SKILLS_REPO, getAgent, type AgentName, type Scope, @@ -22,7 +21,6 @@ interface SetupFlags extends Partial> { mode: string; project?: boolean; yes?: boolean; - ref: string; dryRun?: boolean; json?: boolean; } @@ -57,7 +55,6 @@ export function registerSetup(program: Command): void { .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("--ref ", `Git ref of ${SKILLS_REPO} to install from`, "main") .option("--dry-run", "Show what would change without writing anything") .option("--json", "Print the result as JSON") .action(async (_flags: unknown, cmd: Command) => { @@ -147,7 +144,6 @@ function chooseMethod( name: AgentName, mode: Mode, scope: Scope, - ref: string, ): { method: Method; note?: string } { const plugin = getAgent(name).plugin; if (mode === "mcp") return { method: "mcp" }; @@ -159,10 +155,6 @@ function chooseMethod( if (!plugin.scopes.includes(scope)) { return { method: "mcp", note: "Plugins install per user, not per project; wrote project-level MCP + skill instead." }; } - // Claude Code and Codex install from the marketplace's default branch, so they cannot pin a ref. - if (ref !== "main" && (plugin.kind === "claude" || plugin.kind === "codex")) { - return { method: "mcp", note: `The plugin installs from the marketplace's default branch; installed MCP + skill from ${ref} instead.` }; - } return { method: "plugin" }; } @@ -206,18 +198,17 @@ interface AgentContext { mode: Mode; scope: Scope; dryRun: boolean; - ref: string; 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, ctx.ref); + 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, ref: ctx.ref, dryRun, run: runner, repo }); + 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; @@ -240,9 +231,9 @@ async function setupAgent(name: AgentName, ctx: AgentContext): Promise Promise { +function lazyRepo(): () => Promise { let repoPromise: Promise | undefined; - return () => (repoPromise ??= fetchSkillsRepo(ref)); + return () => (repoPromise ??= fetchSkillsRepo()); } export async function runSetup(cmd: Command): Promise { @@ -265,7 +256,7 @@ 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, ref: flags.ref, repo: lazyRepo(flags.ref) }; + const ctx: AgentContext = { mode, scope, dryRun, repo: lazyRepo() }; const results: AgentResult[] = []; for (const name of agents) results.push(await setupAgent(name, ctx)); @@ -290,7 +281,7 @@ async function runInteractive(cmd: Command, flags: SetupFlags, mode: Mode): Prom if (!dryRun) { const width = Math.max(...agents.map((n) => getAgent(n).displayName.length)); const plan = agents.map((n) => { - const { method } = chooseMethod(n, mode, scope, flags.ref); + 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)}`; }); @@ -298,7 +289,7 @@ async function runInteractive(cmd: Command, flags: SetupFlags, mode: Mode): Prom if (!(await ui.confirm("Continue?"))) throw new ui.SetupCancelled(); } - const ctx: AgentContext = { mode, scope, dryRun, ref: flags.ref, repo: lazyRepo(flags.ref) }; + const ctx: AgentContext = { mode, scope, dryRun, repo: lazyRepo() }; const results: AgentResult[] = []; for (const name of agents) { const label = getAgent(name).displayName; diff --git a/src/setup/mcp-config.ts b/src/setup/mcp-config.ts index 6dd652d..28d5917 100644 --- a/src/setup/mcp-config.ts +++ b/src/setup/mcp-config.ts @@ -116,18 +116,11 @@ export function buildTomlTable(table: string, entry: Record): s return lines.join("\n") + "\n"; } -/** - * 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); - if (!m) return undefined; +/** 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; - const body = m[1]!; while (pos < body.length) { re.lastIndex = pos; const k = re.exec(body); @@ -137,7 +130,50 @@ function tableKeys(line: string): string[] | undefined { pos = re.lastIndex; if (!k[2]) break; } - return pos >= body.length ? keys : undefined; + 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)); @@ -151,16 +187,23 @@ function isHeader(line: string, table: string): boolean { function isSubTable(line: string, table: string): boolean { const keys = tableKeys(line); const want = table.split("."); - return keys !== undefined && keys.length > want.length && want.every((k, i) => keys[i] === k); + return keys !== undefined && keys.length > want.length && startsWith(keys, want); } -/** Replaces `[table]` and its `[table.*]` sub-tables, or appends the block. */ +/** + * 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(); @@ -206,7 +249,11 @@ export async function writeMcpEntry( if (agent.mcp.format === "toml") { const block = buildTomlTable(`${agent.mcp.configKey}.${SERVER_NAME}`, entry); - ({ content, replaced } = upsertTomlTable(await readText(path), `${agent.mcp.configKey}.${SERVER_NAME}`, block)); + 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"; @@ -223,7 +270,12 @@ export async function hasMcpEntry(agent: AgentConfig, scope: Scope): Promise isHeader(l, table)) ? path : undefined; + 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]; diff --git a/src/setup/plugins.ts b/src/setup/plugins.ts index 93b0432..c0315e9 100644 --- a/src/setup/plugins.ts +++ b/src/setup/plugins.ts @@ -60,7 +60,6 @@ function planned(label: string): Step { export interface PluginContext { scope: Scope; - ref: string; dryRun: boolean; run: Runner; /** Lazily downloads upstash/skills; only the Cursor installer needs the files. */ @@ -132,13 +131,11 @@ function codex(ctx: PluginContext): Promise { ); } -/** https://geminicli.com/docs/extensions/reference/#install-an-extension (`install --ref --consent`, `update `) */ +/** 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: [] }; - const args = ["extensions", "install", `https://github.com/${SKILLS_REPO}`, "--consent"]; - if (ctx.ref !== "main") args.push("--ref", ctx.ref); - let res = await ctx.run("gemini", args); + 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"]); @@ -175,7 +172,7 @@ async function cursor(ctx: PluginContext): Promise { 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}@${ctx.ref} has no .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 }; diff --git a/src/setup/repo.ts b/src/setup/repo.ts index 0fd0e4f..b3173b3 100644 --- a/src/setup/repo.ts +++ b/src/setup/repo.ts @@ -6,8 +6,6 @@ import { SKILLS_REPO } from "./agents.js"; /** Repo-relative path → file contents. */ export type RepoFiles = Map; -const REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; - function cString(buf: Buffer, start: number, length: number): string { const slice = buf.subarray(start, start + length); const nul = slice.indexOf(0); @@ -73,17 +71,17 @@ export function stripTopDir(files: RepoFiles): RepoFiles { return out; } -export async function fetchSkillsRepo(ref: string): Promise { - if (!REF_PATTERN.test(ref)) throw new Error(`Invalid git ref: ${ref}`); - const url = `https://codeload.github.com/${SKILLS_REPO}/tar.gz/${ref}`; +/** 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}@${ref}: ${reason}`); + throw new Error(`Could not download ${SKILLS_REPO}: ${reason}`); } - if (!res.ok) throw new Error(`Could not download ${SKILLS_REPO}@${ref}: HTTP ${res.status}`); + if (!res.ok) throw new Error(`Could not download ${SKILLS_REPO}: HTTP ${res.status}`); return stripTopDir(parseTar(gunzipSync(Buffer.from(await res.arrayBuffer())))); } @@ -100,8 +98,9 @@ export function subtree(files: RepoFiles, prefix: string): RepoFiles { /** * 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, so a bad - * archive or a failed write leaves the previous install in place. + * 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}`); @@ -120,11 +119,28 @@ export async function writeTree(files: RepoFiles, dest: string): Promise await mkdir(dirname(target), { recursive: true }); await writeFile(target, content); } - await rm(dest, { recursive: true, force: true }); + } 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/tests/unit/setup.test.ts b/tests/unit/setup.test.ts index ddbad29..3d784ab 100644 --- a/tests/unit/setup.test.ts +++ b/tests/unit/setup.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Command } from "commander"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +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"; @@ -83,7 +83,7 @@ async function run(argv: string[]): Promise { return out.join("\n"); } -async function runJson(argv: string[]): Promise<{ results: Array<{ agent: string; method: string; ok: boolean; notes: string[] }> }> { +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"])); } @@ -168,6 +168,20 @@ describe("config merging", () => { 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" } }); @@ -184,6 +198,14 @@ describe("writeTree", () => { 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 ----------------------------------------------------------- @@ -251,16 +273,19 @@ describe("setup", () => { await expect(run(["--claude", "--auth", "api-key"])).rejects.toThrow(/unknown option '--auth'/); }); - it("uses MCP + skill when --ref pins a branch the Claude and Codex plugins cannot install", async () => { - const { results } = await runJson(["--claude", "--codex", "--gemini", "--ref", "v2"]); - expect(results.map((r) => [r.agent, r.method])).toEqual([ - ["claude", "mcp"], - ["codex", "mcp"], - ["gemini", "plugin"], - ]); - expect(results[0]!.notes[0]).toContain("from v2"); - expect(calls).toContainEqual(["gemini", "extensions", "install", "https://github.com/upstash/skills", "--consent", "--ref", "v2"]); - expect(String(vi.mocked(fetch).mock.calls[0]![0])).toContain("/tar.gz/v2"); + 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 () => { From 0dda809764b5bc42aa5becd0da5f4f26fecb5a86 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:54:41 +0000 Subject: [PATCH 6/6] setup: verify Codex TOML edits with a parser, honor CODEX_HOME, report failed Claude updates Addresses the review on d500d1d: - The Codex config is still edited line by line to keep comments and formatting, but the result is now parsed with smol-toml and compared with the expected document before it is written. Layouts the line edit can't rewrite safely, such as an inline parent table (mcp_servers = { other = {...} }) or the server defined inline or with dotted keys, now fail the step with a clear message instead of producing an invalid file. This replaces the hand-written inline/dotted detection. - Header lines inside multi-line strings are no longer taken for tables. - Every [mcp_servers.upstash] and [mcp_servers.upstash.*] section is removed wherever it sits, so a non-adjacent http_headers sub-table from an older API-key setup no longer survives. - hasMcpEntry reads TOML configs with the parser too. - Codex user config, detection and the plugin check use CODEX_HOME, defaulting to ~/.codex. - A failed `claude plugin update` is reported as a failed step and a non-zero exit instead of a silent success; the plugin stays installed, so there is no MCP fallback. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LWUubWwYWp1dZTK1S9Ek95 --- package-lock.json | 15 +++- package.json | 3 +- src/commands/setup.ts | 2 +- src/setup/agents.ts | 9 ++- src/setup/mcp-config.ts | 162 +++++++++++++++++++++++++-------------- src/setup/plugins.ts | 21 +++-- tests/unit/setup.test.ts | 90 +++++++++++++++++++--- 7 files changed, 223 insertions(+), 79 deletions(-) diff --git a/package-lock.json b/package-lock.json index ecb637a..ada0576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "commander": "^13.0.0", "dotenv": "^16.4.5", "mime": "4.1.0", - "picocolors": "^1.1.1" + "picocolors": "^1.1.1", + "smol-toml": "^1.9.0" }, "bin": { "upstash": "dist/cli.js" @@ -1289,6 +1290,18 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "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 1961227..ed3cf19 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "commander": "^13.0.0", "dotenv": "^16.4.5", "mime": "4.1.0", - "picocolors": "^1.1.1" + "picocolors": "^1.1.1", + "smol-toml": "^1.9.0" }, "devDependencies": { "@types/node": "^20.10.0", diff --git a/src/commands/setup.ts b/src/commands/setup.ts index f28301a..c66138b 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -209,7 +209,7 @@ async function setupAgent(name: AgentName, ctx: AgentContext): Promise [pick(s, join(".codex", "config.toml"), home(".codex", "config.toml"))], + paths: (s) => [pick(s, join(".codex", "config.toml"), join(codexHome(), "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"))], + detect: (s) => [pick(s, ".codex", codexHome())], }, /** * MCP: https://cursor.com/docs/context/mcp#configuration-locations (~/.cursor/mcp.json, .cursor/mcp.json) diff --git a/src/setup/mcp-config.ts b/src/setup/mcp-config.ts index 28d5917..e412063 100644 --- a/src/setup/mcp-config.ts +++ b/src/setup/mcp-config.ts @@ -1,5 +1,6 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; +import { parse as parseToml } from "smol-toml"; 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. */ @@ -147,78 +148,127 @@ 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. + * For each line, whether it starts inside a multi-line string, so text there + * that looks like `[table]` is never taken for a header. */ -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; +function linesInString(lines: string[]): boolean[] { + let open: string | undefined; + return lines.map((line) => { + const inside = open !== undefined; + for (const [delim] of line.matchAll(/"""|'''/g)) { + if (open === undefined) open = delim; + else if (open === delim) open = undefined; } - 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 inside; + }); +} + +/** Sets `value` at `path`, creating plain objects along the way. */ +function setPath(target: Record, path: string[], value: unknown): void { + let node = target; + for (const key of path.slice(0, -1)) { + const next = node[key]; + node = (node[key] = next && typeof next === "object" && !Array.isArray(next) ? next : {}) as Record; } - return false; + node[path.at(-1)!] = value; } -const isTableHeader = (line: string): boolean => /^\s*\[/.test(line) && (tableKeys(line) !== undefined || /^\s*\[\[/.test(line)); +/** Structural equality for parsed TOML: ignores object prototypes, compares dates by their TOML text. */ +function sameToml(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a instanceof Date || b instanceof Date) { + return a instanceof Date && b instanceof Date && String(a) === String(b); + } + if (Array.isArray(a) || Array.isArray(b)) { + return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => sameToml(v, b[i])); + } + if (!a || !b || typeof a !== "object" || typeof b !== "object") return false; + const ka = Object.keys(a); + const kb = Object.keys(b); + return ( + ka.length === kb.length && + ka.every((k) => Object.hasOwn(b, k) && sameToml((a as Record)[k], (b as Record)[k])) + ); +} -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 getPath(target: unknown, path: string[]): unknown { + let node = target; + for (const key of path) { + if (!node || typeof node !== "object") return undefined; + node = (node as Record)[key]; + } + return node; } -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); +function parseTomlOrThrow(text: string): Record { + try { + return parseToml(text) as Record; + } catch (err) { + const reason = err instanceof Error ? err.message.split("\n")[0] : String(err); + throw new Error(`not valid TOML (${reason}); fix it and rerun`); + } } /** - * 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. + * Sets `[table]` to `entry`: removes every `[table]` / `[table.*]` section + * wherever it sits, writes the new block where the first one was (or at the + * end), and leaves every other line, comments included, as it was. + * + * The edit is line-based to keep the file's formatting, so the result is + * parsed and compared with the expected document before it is returned. A + * layout this cannot rewrite safely (the table, or a parent table, defined + * inline or with dotted keys) throws instead of producing a broken file. */ export function upsertTomlTable( existing: string, table: string, - block: string, + entry: Record, ): { content: string; replaced: boolean } { + const path = table.split("."); + const before = parseTomlOrThrow(existing); + const replaced = getPath(before, path) !== undefined; + 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 inString = linesInString(lines); + const headers = lines.flatMap((line, i) => (!inString[i] && /^\s*\[/.test(line) ? [i] : [])); + const block = buildTomlTable(table, entry).trimEnd().split("\n"); + + const out: string[] = lines.slice(0, headers[0] ?? lines.length); + let inserted = false; + headers.forEach((start, n) => { + const end = headers[n + 1] ?? lines.length; + const keys = tableKeys(lines[start]!); + if (keys && startsWith(keys, path)) { + if (!inserted) out.push(...block, ...(end < lines.length ? [""] : [])); + inserted = true; + return; + } + out.push(...lines.slice(start, end)); + }); + + let content: string; + if (inserted) { + content = out.join("\n").trimEnd() + "\n"; + } else { const base = existing.trimEnd(); - return { content: (base ? `${base}\n\n` : "") + block, replaced: false }; + content = (base ? `${base}\n\n` : "") + block.join("\n") + "\n"; + } + + // A fresh parse rather than a clone, so TOML dates keep their class. + const expected = parseTomlOrThrow(existing); + setPath(expected, path, entry); + let after: unknown; + try { + after = parseToml(content); + } catch { + after = undefined; } - let end = start + 1; - while (end < lines.length) { - const line = lines[end]!; - if (isTableHeader(line) && !isSubTable(line, table)) break; - end++; + if (!sameToml(after, expected)) { + throw new Error( + `can't add [${table}] safely: it, or a parent table, is defined inline or with dotted keys. Remove that definition or add the table by hand, then rerun`, + ); } - 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 }; + return { content, replaced }; } async function firstExisting(candidates: string[]): Promise { @@ -248,9 +298,8 @@ export async function writeMcpEntry( 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)); + ({ content, replaced } = upsertTomlTable(await readText(path), `${agent.mcp.configKey}.${SERVER_NAME}`, entry)); } catch (err) { throw new Error(`${path}: ${err instanceof Error ? err.message : String(err)}`); } @@ -269,10 +318,9 @@ export async function writeMcpEntry( 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; + const config = parseToml(await readText(path)); + return getPath(config, [agent.mcp.configKey, SERVER_NAME]) !== undefined ? path : undefined; } catch { return undefined; } diff --git a/src/setup/plugins.ts b/src/setup/plugins.ts index c0315e9..cd6a81d 100644 --- a/src/setup/plugins.ts +++ b/src/setup/plugins.ts @@ -2,7 +2,7 @@ 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 { PLUGIN_ID, SKILLS_REPO, codexHome, type PluginKind, type Scope } from "./agents.js"; import { subtree, writeTree, type RepoFiles } from "./repo.js"; export type StepStatus = "done" | "planned" | "failed"; @@ -18,6 +18,8 @@ export interface PluginResult { ok: boolean; /** The agent's CLI binary, when it is not on PATH and there was nothing to call. */ missing?: string; + /** Set when the plugin is installed although a later step failed, so falling back to MCP would duplicate it. */ + installed?: boolean; steps: Step[]; notes: string[]; } @@ -109,9 +111,18 @@ async function claude(ctx: PluginContext): Promise { ["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; + if (!result.ok || ctx.dryRun) return result; + // `install` reports success without upgrading an existing install, so a + // failed update can leave an old plugin in place: report it rather than + // claim success. The plugin is installed either way, so no MCP fallback. + const update = await ctx.run("claude", ["plugin", "update", PLUGIN_ID, "--scope", scope]); + if (update.ok) return result; + return { + ok: false, + installed: true, + steps: [...result.steps, { label: `Update ${PLUGIN_ID}`, status: "failed", detail: reason(update.output) }], + notes: result.notes, + }; } /** @@ -212,7 +223,7 @@ export async function isPluginInstalled(kind: PluginKind): Promise { case "claude": return fileIncludes(join(claudeDir, "plugins", "installed_plugins.json"), `"${PLUGIN_ID}"`); case "codex": - return fileIncludes(join(homedir(), ".codex", "config.toml"), `[plugins."${PLUGIN_ID}"]`); + return fileIncludes(join(codexHome(), "config.toml"), `[plugins."${PLUGIN_ID}"]`); case "cursor": return access(cursorPluginDir()).then(() => true, () => false); case "gemini": diff --git a/tests/unit/setup.test.ts b/tests/unit/setup.test.ts index 3d784ab..383512c 100644 --- a/tests/unit/setup.test.ts +++ b/tests/unit/setup.test.ts @@ -9,7 +9,7 @@ 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"; +import { mergeServerEntry, upsertTomlTable, stripJsonc } from "../../src/setup/mcp-config.js"; // --- a tiny tar writer, so the fixtures need no binaries --------------------- @@ -94,6 +94,7 @@ beforeEach(() => { home = mkdtempSync(join(tmpdir(), "upstash-setup-")); process.env.HOME = home; delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CODEX_HOME; delete process.env.UPSTASH_EMAIL; delete process.env.UPSTASH_API_KEY; calls = []; @@ -148,38 +149,80 @@ describe("config merging", () => { "url = \"y\"", "", ].join("\n"); - const block = buildTomlTable("mcp_servers.upstash", { url: "new" }); - const { content, replaced } = upsertTomlTable(existing, "mcp_servers.upstash", block); + const { content, replaced } = upsertTomlTable(existing, "mcp_servers.upstash", { url: "new" }); 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); + const { content, replaced } = upsertTomlTable('model = "o3"\n', "mcp_servers.upstash", { url: "u" }); 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" })); + const { content, replaced } = upsertTomlTable(existing, "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" }); + it("refuses layouts it cannot rewrite safely: inline or dotted definitions, inline parent tables", () => { for (const existing of [ 'mcp_servers.upstash = { url = "x" }\n', 'mcp_servers.upstash.url = "x"\n', '[mcp_servers]\n"upstash" = { url = "x" }\n', + "mcp_servers = { other = { url = 'https://example.com/mcp' } }\n", ]) { - expect(() => upsertTomlTable(existing, "mcp_servers.upstash", block)).toThrow(/defined inline/); + expect(() => upsertTomlTable(existing, "mcp_servers.upstash", { url: "new" })).toThrow(/can't add \[mcp_servers.upstash\] safely/); } - // 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); + expect(() => upsertTomlTable("not = [valid", "mcp_servers.upstash", { url: "new" })).toThrow(/not valid TOML/); + // Dotted keys inside the table's own section and other servers are fine. + const ok = '[mcp_servers.upstash]\nurl = "old"\nhttp_headers.X = "1"\n\n[mcp_servers]\nother = { url = "y" }\n'; + expect(upsertTomlTable(ok, "mcp_servers.upstash", { url: "new" })).toEqual({ + content: '[mcp_servers.upstash]\nurl = "new"\n\n[mcp_servers]\nother = { url = "y" }\n', + replaced: true, + }); + }); + + it("ignores table-like lines inside multi-line strings", () => { + const existing = [ + 'developer_instructions = """', + "Example config:", + "[mcp_servers.upstash]", + 'url = "in a string"', + '"""', + "", + "[profiles.dev]", + 'model = "o3"', + "", + ].join("\n"); + const { content, replaced } = upsertTomlTable(existing, "mcp_servers.upstash", { url: "new" }); + expect(replaced).toBe(false); + expect(content).toBe(existing.trimEnd() + '\n\n[mcp_servers.upstash]\nurl = "new"\n'); + }); + + it("removes upstash sub-tables wherever they sit, and keeps TOML dates", () => { + const existing = [ + "updated = 2026-10-05T09:00:00Z", + "", + "[mcp_servers.upstash]", + 'url = "old"', + "", + "[mcp_servers.other]", + 'url = "y"', + "", + "[mcp_servers.upstash.http_headers]", + "Authorization = 'Bearer expired'", + "", + "[profiles.dev]", + 'model = "o3"', + "", + ].join("\n"); + const { content } = upsertTomlTable(existing, "mcp_servers.upstash", { url: "new" }); + expect(content).toBe( + 'updated = 2026-10-05T09:00:00Z\n\n[mcp_servers.upstash]\nurl = "new"\n\n[mcp_servers.other]\nurl = "y"\n\n[profiles.dev]\nmodel = "o3"\n', + ); }); it("reads JSONC with comments and trailing commas, leaving strings alone", () => { @@ -273,6 +316,29 @@ describe("setup", () => { await expect(run(["--claude", "--auth", "api-key"])).rejects.toThrow(/unknown option '--auth'/); }); + it("reports a failed Claude plugin update instead of claiming success", async () => { + setRunner(async (bin, args) => { + calls.push([bin, ...args]); + const update = bin === "claude" && args[0] === "plugin" && args[1] === "update"; + return { ok: !update, missing: false, output: update ? "Error: network unreachable" : "" }; + }); + const { results } = await runJson(["--claude"]); + expect(results[0]).toMatchObject({ agent: "claude", method: "plugin", ok: false }); + expect(results[0]!.steps.at(-1)).toMatchObject({ status: "failed", detail: "Error: network unreachable" }); + expect(process.exitCode).toBe(1); + // The plugin is installed, so no MCP fallback that would load the server twice. + expect(existsSync(join(home, ".claude.json"))).toBe(false); + }); + + it("uses CODEX_HOME for Codex's user config and detection", async () => { + process.env.CODEX_HOME = join(home, "codex-home"); + mkdirSync(process.env.CODEX_HOME); + const { results } = await runJson(["--yes", "--mode", "mcp"]); + expect(results.map((r) => r.agent)).toEqual(["codex"]); + expect(read("codex-home", "config.toml")).toBe('[mcp_servers.upstash]\nurl = "https://mcp.upstash.com/mcp"\n'); + expect(existsSync(join(home, ".codex"))).toBe(false); + }); + it("has no --ref option", async () => { await expect(run(["--gemini", "--ref", "v2"])).rejects.toThrow(/unknown option '--ref'/); expect(calls).toEqual([]);