From 194ea420cd11cddc71b744a2f9c25c8171f241c4 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 08:15:45 -0700 Subject: [PATCH 1/5] Add `neon plugins` to install the Neon agent plugin into coding agents --- .changeset/cli-neon-plugins.md | 6 + packages/cli/README.md | 29 ++ packages/cli/src/commands/auth.ts | 5 +- packages/cli/src/commands/index.ts | 2 + packages/cli/src/commands/plugins.test.ts | 429 ++++++++++++++++++++++ packages/cli/src/commands/plugins.ts | 200 ++++++++++ packages/cli/src/context.test.ts | 14 + packages/cli/src/context.ts | 5 +- packages/cli/src/index.ts | 2 + packages/cli/src/plugins/plan.test.ts | 285 ++++++++++++++ packages/cli/src/plugins/plan.ts | 135 +++++++ packages/cli/src/plugins/run.test.ts | 94 +++++ packages/cli/src/plugins/run.ts | 121 ++++++ packages/cli/src/plugins/targets.test.ts | 102 +++++ packages/cli/src/plugins/targets.ts | 95 +++++ packages/cli/src/plugins/wizard.test.ts | 30 ++ packages/cli/src/plugins/wizard.ts | 51 +++ 17 files changed, 1602 insertions(+), 3 deletions(-) create mode 100644 .changeset/cli-neon-plugins.md create mode 100644 packages/cli/src/commands/plugins.test.ts create mode 100644 packages/cli/src/commands/plugins.ts create mode 100644 packages/cli/src/plugins/plan.test.ts create mode 100644 packages/cli/src/plugins/plan.ts create mode 100644 packages/cli/src/plugins/run.test.ts create mode 100644 packages/cli/src/plugins/run.ts create mode 100644 packages/cli/src/plugins/targets.test.ts create mode 100644 packages/cli/src/plugins/targets.ts create mode 100644 packages/cli/src/plugins/wizard.test.ts create mode 100644 packages/cli/src/plugins/wizard.ts diff --git a/.changeset/cli-neon-plugins.md b/.changeset/cli-neon-plugins.md new file mode 100644 index 00000000..4ffc89f8 --- /dev/null +++ b/.changeset/cli-neon-plugins.md @@ -0,0 +1,6 @@ +--- +"neon": minor +"neonctl": minor +--- + +Add `neon plugins` to install the Neon agent plugin into coding agents. A TTY asks agents, then confirms. `-y` installs into detected agents, project-scoped. `--agent` names specific agents. `--global` is user-level. diff --git a/packages/cli/README.md b/packages/cli/README.md index 03670a51..bf34e934 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -791,6 +791,34 @@ On a TTY the command asks which agents and which skills, then shows a summary to `--agent` names match `neon mcp`, minus agents that cannot install skills: `antigravity`, `cline`, `cline-cli`, `claude-code`, `claude-desktop`, `codex`, `cursor`, `gemini-cli`, `goose`, `github-copilot-cli`, `grok-build`, `opencode`, `vscode`, `windsurf`, `zed`. `mcporter` is a known MCP name that is then skipped. +## Install the Neon plugin (`plugins`) + +`neon plugins` installs the Neon agent plugin (`neon-postgres`) by running `npx plugins add`. It does not call the Neon API. + +```bash +# Interactive: project-scoped, then agents, then confirm. +$ neon plugins + +# Skip prompts. Project-scoped, every detected agent. +$ neon plugins -y + +$ neon plugins --agent cursor --agent claude-code + +# User-level install. +$ neon plugins --global +$ neon plugins --global --agent vscode +``` + +On a TTY the command asks which agents, then shows a summary to confirm. Detected agents start selected from project-folder markers such as `.cursor`. There is one plugin (`neon-postgres`); there is no plugin picker and no `update` subcommand. + +`-y` skips those questions and installs into every detected agent. `--agent` names specific agents and skips the agent picker. Without a TTY, pass `-y` or `--agent`. `--agent` alone is enough because the plugin is fixed. + +Default scope is project-scoped (`-s project` on the plugins CLI). `--global` is user-level (`-s user`). Cursor and Claude Code still store the plugin cache under `~/.claude/plugins`; project-scoped vs user-level is the scope field the plugins CLI records. VS Code, GitHub Copilot CLI, and Grok Build only install user-level: they are skipped at the default scope with a warning, and `--agent vscode` without `--global` fails if nothing else is selected. + +`--agent` names with a plugins mapping: `claude-code`, `claude-desktop`, `codex`, `cursor`, `github-copilot-cli`, `grok-build`, `vscode`. `claude-desktop` installs as Claude Code. `mcporter` is a known MCP name that is then skipped. + +The plugins CLI installs every plugin it finds in the Neon plugin package. Today that is `neon-postgres`. + ## Snapshots (`snapshots`) `neon snapshots` (alias `neon snapshot`) manages **snapshots** — point-in-time backups of a branch that you can list, rename, expire, restore into a branch, or schedule automatically. Snapshots are a Beta Neon feature and were previously only available in the Console and REST API; this command group brings them to the CLI. @@ -1171,6 +1199,7 @@ Id Name Project Created At Last Used At Last | bootstrap | | Scaffold a project from a template | | init | | Set up a project for a coding agent | | mcp | | Install the Neon MCP server | +| plugins | | Install the Neon plugin | | skills | `update` | Install Neon agent skills | | bucket | `create`, `list`, `delete`, `object list`, `object get`, `object put`, `object delete` (incl. `--recursive`) | Manage buckets and their objects | | [completion](https://neon.com/docs/reference/cli-completion) | | Generate a completion script | diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 5af36a9e..757f3545 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -40,6 +40,7 @@ import { isCurrentBranchProbe, isMcpCommand, isMcpOauth, + isPluginsCommand, isProfileCommand, isSkillsCommand, } from "../context.js"; @@ -431,8 +432,8 @@ export const ensureAuth = async ( return; } - // Neon authentication is unrelated to the child skills CLI. - if (isSkillsCommand(props)) { + // Neon authentication is unrelated to the child skills and plugins CLIs. + if (isSkillsCommand(props) || isPluginsCommand(props)) { return; } diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index ef5980e9..0e97fd4e 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -24,6 +24,7 @@ import * as neonAuth from "./neon_auth.js"; import * as open from "./open.js"; import * as operations from "./operations.js"; import * as orgs from "./orgs.js"; +import * as plugins from "./plugins.js"; import * as profile from "./profile.js"; import * as projects from "./projects.js"; import * as psql from "./psql.js"; @@ -61,6 +62,7 @@ export default [ open, init, mcp, + plugins, skills, dataApi, functions, diff --git a/packages/cli/src/commands/plugins.test.ts b/packages/cli/src/commands/plugins.test.ts new file mode 100644 index 00000000..a10da9f3 --- /dev/null +++ b/packages/cli/src/commands/plugins.test.ts @@ -0,0 +1,429 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect } from "vitest"; + +import { PLUGIN_SOURCE } from "../plugins/run.js"; +import { test } from "../test_utils/fixtures"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function scratch(): { + home: string; + cwd: string; + bin: string; + argvFile: string; + envFile: string; +} { + const home = mkdtempSync(join(tmpdir(), "neon-plugins-home-")); + const cwd = mkdtempSync(join(tmpdir(), "neon-plugins-cwd-")); + const bin = mkdtempSync(join(tmpdir(), "neon-plugins-bin-")); + dirs.push(home, cwd, bin); + const argvFile = join(bin, "argv.json"); + const envFile = join(bin, "env.json"); + writeFileSync( + join(bin, "npx"), + `#!/usr/bin/env node +const { readFileSync, writeFileSync } = require("node:fs"); +const argvFile = process.env.PLUGINS_ARGV_FILE; +let all = []; +try { + const parsed = JSON.parse(readFileSync(argvFile, "utf8")); + all = Array.isArray(parsed) && parsed.every((item) => Array.isArray(item)) + ? parsed + : [parsed]; +} catch {} +const args = process.argv.slice(2); +all.push(args); +writeFileSync(argvFile, JSON.stringify(all)); +writeFileSync(process.env.PLUGINS_ENV_FILE, JSON.stringify({ + cwd: process.cwd(), + hasDisable: Object.prototype.hasOwnProperty.call(process.env, "DISABLE_TELEMETRY"), + hasDnt: Object.prototype.hasOwnProperty.call(process.env, "DO_NOT_TRACK"), + hasNeonKey: Object.prototype.hasOwnProperty.call(process.env, "NEON_API_KEY"), +})); +if (process.env.PLUGINS_CHILD_STDOUT) { + process.stdout.write(process.env.PLUGINS_CHILD_STDOUT); +} +if (process.env.PLUGINS_CHILD_STDERR) { + process.stderr.write(process.env.PLUGINS_CHILD_STDERR); +} +const failTarget = process.env.PLUGINS_FAIL_TARGET; +if (failTarget) { + const t = args.indexOf("-t"); + if (t !== -1 && args[t + 1] === failTarget) { + process.exit(1); + } +} +if (process.env.PLUGINS_CHILD_EXIT) { + process.exit(Number(process.env.PLUGINS_CHILD_EXIT)); +} +`, + ); + chmodSync(join(bin, "npx"), 0o755); + mkdirSync(join(cwd, ".cursor")); + return { home, cwd, bin, argvFile, envFile }; +} + +function runOptions( + home: string, + cwd: string, + bin: string, + extra: Record = {}, +) { + return { + cwd, + env: { + HOME: home, + CI: "true", + PATH: `${bin}:${join(process.cwd(), "mocks/bin")}:${process.env.PATH}`, + PLUGINS_ARGV_FILE: join(bin, "argv.json"), + PLUGINS_ENV_FILE: join(bin, "env.json"), + DISABLE_TELEMETRY: "1", + DO_NOT_TRACK: "1", + NEON_API_KEY: "should-not-leak", + ...extra, + }, + snapshot: false as const, + apiKey: false as const, + output: "json" as const, + }; +} + +describe("neon plugins", () => { + test("installs neon-postgres into detected project agents", async ({ + testCliCommand, + }) => { + const { home, cwd, bin, argvFile, envFile } = scratch(); + const { stdout } = await testCliCommand( + ["plugins", "-y"], + runOptions(home, cwd, bin), + ); + expect(JSON.parse(stdout)).toEqual([ + { + scope: "project-scoped", + plugin: "neon-postgres", + agent: "cursor", + status: "installed", + }, + ]); + expect(JSON.parse(readFileSync(argvFile, "utf8"))).toEqual([ + [ + "-y", + "plugins", + "add", + PLUGIN_SOURCE, + "-t", + "cursor", + "-s", + "project", + "-y", + ], + ]); + expect(JSON.parse(readFileSync(envFile, "utf8"))).toMatchObject({ + cwd: realpathSync(cwd), + hasDisable: false, + hasDnt: false, + hasNeonKey: false, + }); + }); + + test("installs without -y when --agent is set", async ({ + testCliCommand, + }) => { + const { home, cwd, bin, argvFile } = scratch(); + const { stdout } = await testCliCommand( + ["plugins", "--agent", "cursor"], + runOptions(home, cwd, bin), + ); + expect(JSON.parse(stdout)[0].status).toBe("installed"); + expect(JSON.parse(readFileSync(argvFile, "utf8"))).toHaveLength(1); + }); + + test("does not spawn without -y or --agent", async ({ testCliCommand }) => { + const { home, cwd, bin, argvFile } = scratch(); + const { stderr } = await testCliCommand(["plugins"], { + ...runOptions(home, cwd, bin), + code: 1, + }); + expect(stderr).toMatch(/Pass -y to install into detected agents/); + expect(() => readFileSync(argvFile, "utf8")).toThrow(); + }); + + test("spawns once per target and dedupes Claude", async ({ + testCliCommand, + }) => { + const { home, cwd, bin, argvFile } = scratch(); + const { stdout } = await testCliCommand( + [ + "plugins", + "--agent", + "cursor", + "--agent", + "claude", + "--agent", + "claude-desktop", + ], + runOptions(home, cwd, bin), + ); + expect(JSON.parse(stdout)).toEqual([ + { + scope: "project-scoped", + plugin: "neon-postgres", + agent: "cursor", + status: "installed", + }, + { + scope: "project-scoped", + plugin: "neon-postgres", + agent: "claude-code", + status: "installed", + }, + ]); + expect(JSON.parse(readFileSync(argvFile, "utf8"))).toEqual([ + [ + "-y", + "plugins", + "add", + PLUGIN_SOURCE, + "-t", + "cursor", + "-s", + "project", + "-y", + ], + [ + "-y", + "plugins", + "add", + PLUGIN_SOURCE, + "-t", + "claude-code", + "-s", + "project", + "-y", + ], + ]); + }); + + test("rejects unknown agents and --agent *", async ({ testCliCommand }) => { + const { home, cwd, bin } = scratch(); + const { stderr: unknownAgent } = await testCliCommand( + ["plugins", "-y", "--agent", "eve"], + { ...runOptions(home, cwd, bin), code: 1 }, + ); + expect(unknownAgent).toMatch(/Unknown agent: "eve"/); + const { stderr: star } = await testCliCommand( + ["plugins", "-y", "--agent", "*"], + { ...runOptions(home, cwd, bin), code: 1 }, + ); + expect(star).toMatch(/does not accept --agent \*/); + }); + + test("rejects unknown options and subcommands", async ({ + testCliCommand, + }) => { + const { home, cwd, bin, argvFile } = scratch(); + const { stderr: pluginFlag } = await testCliCommand( + ["plugins", "--plugin", "neon-postgres"], + { ...runOptions(home, cwd, bin), code: 1 }, + ); + expect(pluginFlag).toMatch(/Unknown argument: plugin/); + expect(() => readFileSync(argvFile, "utf8")).toThrow(); + const { stderr: update } = await testCliCommand(["plugins", "update"], { + ...runOptions(home, cwd, bin), + code: 1, + }); + expect(update).toMatch(/Unknown command: update/); + expect(update).not.toMatch(/neondatabase\/agent-skills/); + }); + + test("skips auth and context enrichment", async ({ testCliCommand }) => { + const { home, cwd, bin } = scratch(); + writeFileSync( + join(cwd, ".neon"), + JSON.stringify({ projectId: "proj-from-neon" }), + ); + const { stdout, stderr } = await testCliCommand( + ["plugins", "--agent", "cursor"], + runOptions(home, cwd, bin), + ); + expect(stderr).not.toMatch(/Cannot run interactive auth/); + expect(stderr).not.toMatch(/Authentication required/); + expect(JSON.parse(stdout)[0].status).toBe("installed"); + }); + + test("install --global passes -s user", async ({ testCliCommand }) => { + const { home, cwd, bin, argvFile } = scratch(); + const { stdout } = await testCliCommand( + ["plugins", "-y", "--global", "--agent", "cursor"], + runOptions(home, cwd, bin), + ); + expect(JSON.parse(readFileSync(argvFile, "utf8"))[0]).toContain("user"); + expect(JSON.parse(stdout)[0].scope).toBe("user-level"); + }); + + test("vscode requires --global", async ({ testCliCommand }) => { + const { home, cwd, bin, argvFile } = scratch(); + const { stderr } = await testCliCommand( + ["plugins", "--agent", "vscode"], + { ...runOptions(home, cwd, bin), code: 1 }, + ); + expect(stderr).toMatch(/Pass --global/); + expect(() => readFileSync(argvFile, "utf8")).toThrow(); + const { stdout } = await testCliCommand( + ["plugins", "--global", "--agent", "vscode"], + runOptions(home, cwd, bin), + ); + expect(JSON.parse(stdout)[0]).toMatchObject({ + scope: "user-level", + agent: "vscode", + status: "installed", + }); + expect(JSON.parse(readFileSync(argvFile, "utf8"))[0]).toEqual([ + "-y", + "plugins", + "add", + PLUGIN_SOURCE, + "-t", + "vscode", + "-s", + "user", + "-y", + ]); + }); + + test("failed install keeps the child dump out of the table", async ({ + testCliCommand, + }) => { + const { home, cwd, bin } = scratch(); + const dump = + "npm error code ENOENT npm error syscall spawn sh npm error path /tmp/x"; + const { stdout, stderr } = await testCliCommand( + ["plugins", "--agent", "cursor"], + { + ...runOptions(home, cwd, bin, { + PLUGINS_CHILD_EXIT: "1", + PLUGINS_CHILD_STDERR: dump, + }), + code: 1, + }, + ); + const row = JSON.parse(stdout)[0]; + expect(row.status).toBe("failed"); + expect(row.error).toBe("plugins CLI failed"); + expect(row.error).not.toContain("syscall"); + expect(stderr).toMatch(/Retry with: neon plugins --agent cursor -y/); + expect(stderr).not.toMatch(/neondatabase\/agent-skills/); + expect(stderr.match(/Retry with:/g)?.length).toBe(1); + expect(stderr).not.toMatch(/Command failed with exit code/); + expect(stderr.match(/syscall spawn sh/g)?.length).toBe(1); + }); + + test("partial failure retries only failed targets", async ({ + testCliCommand, + }) => { + const { home, cwd, bin } = scratch(); + const { stdout, stderr } = await testCliCommand( + ["plugins", "--agent", "cursor", "--agent", "claude-code"], + { + ...runOptions(home, cwd, bin, { + PLUGINS_FAIL_TARGET: "claude-code", + PLUGINS_CHILD_STDERR: "boom", + }), + code: 1, + }, + ); + const rows = JSON.parse(stdout); + expect(rows[0].status).toBe("installed"); + expect(rows[1].status).toBe("failed"); + expect(stderr).toMatch( + /Retry with: neon plugins --agent claude-code -y/, + ); + expect(stderr).not.toMatch(/--agent cursor/); + }); + + test("silent child failure does not print the npx argv", async ({ + testCliCommand, + }) => { + const { home, cwd, bin } = scratch(); + const { stderr } = await testCliCommand( + ["plugins", "--agent", "cursor"], + { + ...runOptions(home, cwd, bin, { + PLUGINS_CHILD_EXIT: "1", + }), + code: 1, + }, + ); + expect(stderr).toMatch(/plugins CLI failed/); + expect(stderr).toMatch(/Retry with: neon plugins --agent cursor -y/); + expect(stderr).not.toMatch(/neondatabase\/agent-skills/); + expect(stderr).not.toMatch(/Command failed with exit code/); + }); + + test("failed --global retry keeps user-level scope", async ({ + testCliCommand, + }) => { + const { home, cwd, bin } = scratch(); + const { stderr } = await testCliCommand( + ["plugins", "-y", "--global", "--agent", "cursor"], + { + ...runOptions(home, cwd, bin, { + PLUGINS_CHILD_EXIT: "1", + PLUGINS_CHILD_STDERR: "boom", + }), + code: 1, + }, + ); + expect(stderr).toMatch( + /Retry with: neon plugins --agent cursor --global -y/, + ); + expect(stderr).not.toMatch(/npx /); + }); + + test("names a missing npx", async ({ testCliCommand }) => { + const { home, cwd } = scratch(); + const empty = mkdtempSync(join(tmpdir(), "neon-plugins-empty-")); + dirs.push(empty); + const { stderr } = await testCliCommand( + ["plugins", "--agent", "cursor"], + { + ...runOptions(home, cwd, empty, { PATH: empty }), + code: 1, + }, + ); + expect(stderr).toMatch(/needs npx \(Node\.js\) to run the plugins CLI/); + expect(stderr).not.toMatch(/neondatabase\/agent-skills/); + }); + + test("help lists install flags and hides the source", async ({ + testCliCommand, + }) => { + const { home, cwd, bin } = scratch(); + const { stdout, stderr } = await testCliCommand(["plugins", "--help"], { + ...runOptions(home, cwd, bin), + }); + const text = `${stdout}\n${stderr}`; + expect(text).toMatch(/--agent/); + expect(text).toMatch(/--global/); + expect(text).not.toMatch(/--plugin/); + expect(text).not.toMatch(/plugins update/); + expect(text).not.toMatch(/neondatabase\/agent-skills/); + expect(text).not.toMatch(/-y, -y/); + }); +}); diff --git a/packages/cli/src/commands/plugins.ts b/packages/cli/src/commands/plugins.ts new file mode 100644 index 00000000..8b941cfc --- /dev/null +++ b/packages/cli/src/commands/plugins.ts @@ -0,0 +1,200 @@ +import type yargs from "yargs"; + +import { getAgentDisplayName } from "../init/agents.js"; +import { log } from "../log.js"; +import { resolvePluginsPlan } from "../plugins/plan.js"; +import { + NEON_PLUGIN_NAME, + neonPluginsRetryCommand, + pluginsAddArgs, + runPluginsCli, +} from "../plugins/run.js"; +import { confirmPluginsInstall } from "../plugins/wizard.js"; +import type { CommonProps } from "../types.js"; +import { canPickAgentsInteractively } from "../utils/agent_picker.js"; +import { noPassthrough } from "../utils/flags.js"; +import { writer } from "../writer.js"; + +type PluginsProps = CommonProps & { + yes?: boolean; + global?: boolean; + agent?: string[]; +}; + +type PluginsInstallRow = { + scope: string; + plugin: string; + agent: string; + status: "installed" | "failed"; + error?: string; +}; + +const scopeLabel = (scope: "global" | "project"): string => + scope === "project" ? "project-scoped" : "user-level"; + +const coerceAgents = (value: unknown): string[] => { + if (value === undefined) return []; + const list = Array.isArray(value) ? value : [value]; + if (list.length === 0) { + throw new Error( + "--agent needs a value. Pass one, or omit the flag entirely.", + ); + } + return list.map((item) => { + if (typeof item !== "string" || item.trim() === "") { + throw new Error( + "--agent needs a value. Pass one, or omit the flag entirely.", + ); + } + return item; + }); +}; + +export const command = "plugins"; +export const describe = "Install the Neon plugin into coding agents"; + +export const builder = (argv: yargs.Argv) => + argv + .usage("$0 plugins [options]") + .options({ + yes: { + alias: "y", + type: "boolean", + default: false, + describe: "Skip prompts", + }, + global: { + type: "boolean", + default: false, + describe: + "Install user-level (plugins CLI -s user). Default is project-scoped (-s project)", + }, + agent: { + alias: "a", + type: "array", + string: true, + describe: + "Coding agent to install into (repeatable). Skips the agent picker", + coerce: coerceAgents, + }, + }) + .example( + "$0 plugins", + "Interactive: project-scoped, agents, then confirm", + ) + .example( + "$0 plugins -y", + "Project-scoped, detected agents, skip prompts", + ) + .example( + "$0 plugins --agent cursor --agent claude-code", + "Install into specific agents", + ) + .example("$0 plugins --global", "Install user-level") + .strict() + .check(noPassthrough("plugins")); + +export const handler = async (props: PluginsProps) => { + const cwd = process.cwd(); + const yes = props.yes === true; + const interactive = canPickAgentsInteractively() && !yes; + const plan = await resolvePluginsPlan({ + global: props.global === true, + agents: props.agent ?? [], + yes, + cwd, + interactive, + }); + for (const agent of plan.skipped) { + log.warning( + "Skipping %s: no plugins mapping.", + getAgentDisplayName(agent), + ); + } + for (const agent of plan.userScopeSkipped) { + log.warning( + "Skipping %s: plugins are user-level. Pass --global.", + getAgentDisplayName(agent), + ); + } + + if (interactive) { + const ok = await confirmPluginsInstall({ + scope: plan.scope, + agents: plan.agents, + }); + if (!ok) { + log.info("Aborted. Nothing was written."); + return; + } + } + + const rows: PluginsInstallRow[] = []; + const failed: { agent: string; message: string }[] = []; + const scope = scopeLabel(plan.scope); + for (const mapped of plan.targets) { + const args = pluginsAddArgs({ + target: mapped.target, + global: plan.scope === "global", + }); + try { + await runPluginsCli({ + args, + cwd, + }); + rows.push({ + scope, + plugin: NEON_PLUGIN_NAME, + agent: mapped.agent, + status: "installed", + }); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + rows.push({ + scope, + plugin: NEON_PLUGIN_NAME, + agent: mapped.agent, + status: "failed", + error: "plugins CLI failed", + }); + failed.push({ + agent: mapped.agent, + message, + }); + } + } + + const out = writer(props); + out.write(rows, { + fields: ["scope", "plugin", "agent", "status", "error"], + title: "Plugins", + }); + out.end(); + + if (failed.length === 0) { + log.info( + plan.scope === "project" + ? "Installed the Neon plugin (project-scoped)." + : "Installed the Neon plugin (user-level).", + ); + return; + } + const first = failed[0]; + if (first === undefined) { + throw new Error("Failed to install the Neon plugin."); + } + const retry = neonPluginsRetryCommand({ + agents: failed.map((row) => row.agent), + global: plan.scope === "global", + }); + if (first.message.includes("needs npx (Node.js)")) { + throw new Error(first.message); + } + if (failed.length === rows.length) { + throw new Error(`${first.message}\nRetry with: ${retry}`); + } + throw new Error( + `Failed to install the Neon plugin for: ${failed.map((row) => row.agent).join(", ")}.\n${first.message}\nRetry with: ${retry}`, + ); +}; diff --git a/packages/cli/src/context.test.ts b/packages/cli/src/context.test.ts index 8aab66d7..b3284ed4 100644 --- a/packages/cli/src/context.test.ts +++ b/packages/cli/src/context.test.ts @@ -15,6 +15,7 @@ import { ensureGitignored, isCurrentBranchProbe, isMcpOauth, + isPluginsCommand, isSkillsCommand, walkContextFile, } from "./context.js"; @@ -84,6 +85,19 @@ describe("isSkillsCommand", () => { test("false for other commands", () => { expect(isSkillsCommand({ _: ["mcp"] })).toBe(false); expect(isSkillsCommand({ _: ["init"] })).toBe(false); + expect(isSkillsCommand({ _: ["plugins"] })).toBe(false); + }); +}); + +describe("isPluginsCommand", () => { + test("true for plugins", () => { + expect(isPluginsCommand({ _: ["plugins"] })).toBe(true); + }); + + test("false for other commands", () => { + expect(isPluginsCommand({ _: ["skills"] })).toBe(false); + expect(isPluginsCommand({ _: ["mcp"] })).toBe(false); + expect(isPluginsCommand({ _: ["init"] })).toBe(false); }); }); diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index acc74daa..88a9b1d4 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -96,6 +96,9 @@ export const isMcpCommand = (args: { _: (string | number)[] }): boolean => export const isSkillsCommand = (args: { _: (string | number)[] }): boolean => args._[0] === "skills"; +export const isPluginsCommand = (args: { _: (string | number)[] }): boolean => + args._[0] === "plugins"; + /** Raw argv is required because auth middleware runs before MCP flags are parsed. */ export const isMcpOauth = (args: { _: (string | number)[] }): boolean => isMcpCommand(args) && argvEnablesMcpOauth(process.argv); @@ -224,7 +227,7 @@ export const enrichFromContext = ( if (isMcpCommand(args)) { return; } - if (isSkillsCommand(args)) { + if (isSkillsCommand(args) || isPluginsCommand(args)) { return; } const context = readContextFile(args.contextFile); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1c23951d..883bab2c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -58,6 +58,8 @@ const NO_SUBCOMMANDS_VERBS = [ "mcp", + "plugins", + "skills", "dev", diff --git a/packages/cli/src/plugins/plan.test.ts b/packages/cli/src/plugins/plan.test.ts new file mode 100644 index 00000000..2f259ca3 --- /dev/null +++ b/packages/cli/src/plugins/plan.test.ts @@ -0,0 +1,285 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { + assertPluginsCanRun, + type ResolvePluginsPlanOptions, + resolvePluginsPlan, +} from "./plan.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function tmpDir(): string { + const dir = mkdtempSync(join(tmpdir(), "neon-plugins-plan-")); + dirs.push(dir); + return dir; +} + +function planOptions( + cwd: string, + overrides: Partial = {}, +): ResolvePluginsPlanOptions { + return { + global: false, + agents: ["cursor"], + yes: false, + cwd, + interactive: true, + pickAgents: async () => { + throw new Error("agent prompt"); + }, + ...overrides, + }; +} + +describe("assertPluginsCanRun", () => { + test("fails non-TTY without -y or --agent", () => { + expect(() => + assertPluginsCanRun({ + yes: false, + interactive: false, + hasAgents: false, + }), + ).toThrow(/Pass -y to install into detected agents, or --agent/); + }); + + test("allows named agents without -y", () => { + expect(() => + assertPluginsCanRun({ + yes: false, + interactive: false, + hasAgents: true, + }), + ).not.toThrow(); + }); + + test("allows -y or a TTY", () => { + expect(() => + assertPluginsCanRun({ + yes: true, + interactive: false, + hasAgents: false, + }), + ).not.toThrow(); + expect(() => + assertPluginsCanRun({ + yes: false, + interactive: true, + hasAgents: false, + }), + ).not.toThrow(); + }); +}); + +describe("resolvePluginsPlan", () => { + test("-y is project-scoped, specified agents, and calls no prompts", async () => { + const cwd = tmpDir(); + const plan = await resolvePluginsPlan( + planOptions(cwd, { + yes: true, + interactive: false, + }), + ); + expect(plan).toEqual({ + scope: "project", + agents: ["cursor"], + skipped: [], + userScopeSkipped: [], + targets: [{ agent: "cursor", target: "cursor" }], + }); + }); + + test("non-TTY without -y succeeds when --agent is set", async () => { + const cwd = tmpDir(); + const plan = await resolvePluginsPlan( + planOptions(cwd, { + yes: false, + interactive: false, + agents: ["cursor"], + }), + ); + expect(plan.agents).toEqual(["cursor"]); + expect(plan.targets).toEqual([{ agent: "cursor", target: "cursor" }]); + }); + + test("non-TTY without -y or --agent fails", async () => { + const cwd = tmpDir(); + await expect( + resolvePluginsPlan( + planOptions(cwd, { + yes: false, + interactive: false, + agents: [], + }), + ), + ).rejects.toThrow( + /Pass -y to install into detected agents, or --agent/, + ); + }); + + test("--global is user-level", async () => { + const cwd = tmpDir(); + const plan = await resolvePluginsPlan( + planOptions(cwd, { yes: true, interactive: false, global: true }), + ); + expect(plan.scope).toBe("global"); + }); + + test("rejects plugins-CLI-only agent names", async () => { + const cwd = tmpDir(); + await expect( + resolvePluginsPlan( + planOptions(cwd, { + yes: true, + interactive: false, + agents: ["eve"], + }), + ), + ).rejects.toThrow(/Unknown agent: "eve"/); + await expect( + resolvePluginsPlan( + planOptions(cwd, { + yes: true, + interactive: false, + agents: ["kimi"], + }), + ), + ).rejects.toThrow(/Unknown agent: "kimi"/); + }); + + test("rejects --agent *", async () => { + const cwd = tmpDir(); + await expect( + resolvePluginsPlan( + planOptions(cwd, { + yes: true, + interactive: false, + agents: ["*"], + }), + ), + ).rejects.toThrow(/does not accept --agent \*/); + }); + + test("skips MCP agents that cannot install plugins", async () => { + const cwd = tmpDir(); + const plan = await resolvePluginsPlan( + planOptions(cwd, { + yes: true, + interactive: false, + agents: ["cursor", "mcporter"], + }), + ); + expect(plan.agents).toEqual(["cursor"]); + expect(plan.skipped).toEqual(["mcporter"]); + expect(plan.userScopeSkipped).toEqual([]); + }); + + test("skips user-level-only agents at project scope when others remain", async () => { + const cwd = tmpDir(); + const plan = await resolvePluginsPlan( + planOptions(cwd, { + yes: true, + interactive: false, + agents: ["cursor", "vscode"], + }), + ); + expect(plan.agents).toEqual(["cursor"]); + expect(plan.userScopeSkipped).toEqual(["vscode"]); + expect(plan.targets).toEqual([{ agent: "cursor", target: "cursor" }]); + }); + + test("fails when every selected agent is user-level-only at project scope", async () => { + const cwd = tmpDir(); + await expect( + resolvePluginsPlan( + planOptions(cwd, { + yes: true, + interactive: false, + agents: ["vscode"], + }), + ), + ).rejects.toThrow(/Pass --global/); + }); + + test("installs vscode at user-level", async () => { + const cwd = tmpDir(); + const plan = await resolvePluginsPlan( + planOptions(cwd, { + yes: true, + interactive: false, + global: true, + agents: ["vscode"], + }), + ); + expect(plan.agents).toEqual(["vscode"]); + expect(plan.targets).toEqual([{ agent: "vscode", target: "vscode" }]); + }); + + test("dedupes Claude Desktop onto claude-code", async () => { + const cwd = tmpDir(); + const plan = await resolvePluginsPlan( + planOptions(cwd, { + yes: true, + interactive: false, + agents: ["claude", "claude-desktop"], + }), + ); + expect(plan.agents).toEqual(["claude-code", "claude-desktop"]); + expect(plan.targets).toEqual([ + { agent: "claude-code", target: "claude-code" }, + ]); + }); + + test("fails when every selected agent lacks a plugins mapping", async () => { + const cwd = tmpDir(); + await expect( + resolvePluginsPlan( + planOptions(cwd, { + yes: true, + interactive: false, + agents: ["mcporter"], + }), + ), + ).rejects.toThrow(/None of the selected agents can install plugins/); + }); + + test("interactive asks agents", async () => { + const cwd = tmpDir(); + mkdirSync(join(cwd, ".cursor")); + let asked = false; + const plan = await resolvePluginsPlan( + planOptions(cwd, { + agents: [], + pickAgents: async (options) => { + asked = true; + expect(options.selected).toEqual(["cursor"]); + return ["cursor"]; + }, + }), + ); + expect(asked).toBe(true); + expect(plan.scope).toBe("project"); + expect(plan.agents).toEqual(["cursor"]); + }); + + test("non-interactive project with no folder agents fails", async () => { + const cwd = tmpDir(); + await expect( + resolvePluginsPlan( + planOptions(cwd, { + agents: [], + yes: true, + interactive: false, + }), + ), + ).rejects.toThrow(/No coding agents detected in this project/); + }); +}); diff --git a/packages/cli/src/plugins/plan.ts b/packages/cli/src/plugins/plan.ts new file mode 100644 index 00000000..69e3f345 --- /dev/null +++ b/packages/cli/src/plugins/plan.ts @@ -0,0 +1,135 @@ +import { + getAgentDisplayName, + tryResolveAddMcpAgentId, +} from "../init/agents.js"; +import type { AgentType } from "../mcp/agents.js"; +import { + agentChoicesFrom, + type PickAgentsOptions, + pickAgentsInteractively, + resolveAgentSelection, +} from "../utils/agent_picker.js"; +import { + detectPluginsAgents, + getPluginsTargetName, + isUserScopeOnlyPluginsTarget, + mappedPluginsTargets, + type PluginsInstallScope, + type PluginsMappedTarget, + pluginsInstallableAgents, + pluginsMappedAgents, +} from "./targets.js"; + +export type PluginsPlan = { + scope: PluginsInstallScope; + agents: AgentType[]; + skipped: AgentType[]; + userScopeSkipped: AgentType[]; + targets: PluginsMappedTarget[]; +}; + +export type ResolvePluginsPlanOptions = { + global: boolean; + agents: readonly string[]; + yes: boolean; + cwd: string; + interactive: boolean; + pickAgents?: (options: PickAgentsOptions) => Promise; +}; + +export const assertPluginsCanRun = (options: { + yes: boolean; + interactive: boolean; + hasAgents: boolean; +}): void => { + if (options.yes || options.interactive || options.hasAgents) { + return; + } + throw new Error( + "No interactive terminal. Pass -y to install into detected agents, or --agent .", + ); +}; + +export async function resolvePluginsPlan( + options: ResolvePluginsPlanOptions, +): Promise { + assertPluginsCanRun({ + yes: options.yes, + interactive: options.interactive, + hasAgents: options.agents.length > 0, + }); + const prompt = options.interactive && !options.yes; + const scope: PluginsInstallScope = options.global ? "global" : "project"; + const available = pluginsInstallableAgents(scope); + const detected = await detectPluginsAgents({ + scope, + cwd: options.cwd, + }); + const selected = await resolveAgentSelection({ + specified: options.agents, + choices: agentChoicesFrom(available, detected), + detected, + message: + "Which coding agents should get the Neon plugin? (space to toggle, enter to confirm)", + nonInteractiveMessage: + scope === "project" + ? `No coding agents detected in this project. Pass --agent . Supported agents: ${pluginsMappedAgents().join(", ")}` + : `No coding agents detected. Pass --agent . Supported agents: ${pluginsMappedAgents().join(", ")}`, + resolveSpecified: (raw) => { + if (raw === "*") { + throw new Error( + "neon plugins does not accept --agent *. Pass --agent for each coding agent, or omit --agent to use detected agents.", + ); + } + const id = tryResolveAddMcpAgentId(raw); + if (!id) { + throw new Error( + `Unknown agent: "${raw}". Supported agents: ${pluginsMappedAgents().join(", ")}`, + ); + } + return id; + }, + pick: prompt + ? (options.pickAgents ?? pickAgentsInteractively) + : undefined, + interactive: prompt, + }); + + const agents: AgentType[] = []; + const skipped: AgentType[] = []; + const userScopeSkipped: AgentType[] = []; + for (const id of selected) { + const target = getPluginsTargetName(id); + if (target === undefined) { + skipped.push(id); + continue; + } + if (scope === "project" && isUserScopeOnlyPluginsTarget(target)) { + userScopeSkipped.push(id); + continue; + } + agents.push(id); + } + + if (agents.length === 0) { + if (userScopeSkipped.length > 0 && skipped.length === 0) { + const names = userScopeSkipped + .map((id) => getAgentDisplayName(id)) + .join(", "); + throw new Error( + `${names}: plugins are user-level. Pass --global. Project-scoped agents: ${pluginsInstallableAgents("project").join(", ")}`, + ); + } + throw new Error( + `None of the selected agents can install plugins. Supported agents: ${available.join(", ")}`, + ); + } + + return { + scope, + agents, + skipped, + userScopeSkipped, + targets: mappedPluginsTargets(agents, scope), + }; +} diff --git a/packages/cli/src/plugins/run.test.ts b/packages/cli/src/plugins/run.test.ts new file mode 100644 index 00000000..d1677146 --- /dev/null +++ b/packages/cli/src/plugins/run.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "vitest"; + +import { + neonPluginsRetryCommand, + PLUGIN_SOURCE, + pluginsAddArgs, + pluginsChildEnv, +} from "./run.js"; + +describe("pluginsAddArgs", () => { + test("adds the Neon plugin into one mapped target", () => { + const args = pluginsAddArgs({ + target: "cursor", + global: false, + }); + expect(args).toEqual([ + "-y", + "plugins", + "add", + PLUGIN_SOURCE, + "-t", + "cursor", + "-s", + "project", + "-y", + ]); + expect(args.join(" ")).not.toMatch(/-t \*/); + expect(args.filter((part) => part === "-t")).toHaveLength(1); + }); + + test("passes -s user for user-level installs", () => { + expect(pluginsAddArgs({ target: "vscode", global: true })).toEqual([ + "-y", + "plugins", + "add", + PLUGIN_SOURCE, + "-t", + "vscode", + "-s", + "user", + "-y", + ]); + }); + + test("rejects an empty target", () => { + expect(() => pluginsAddArgs({ target: "", global: false })).toThrow( + /needs a -t target/, + ); + }); +}); + +describe("pluginsChildEnv", () => { + test("drops telemetry blockers and keeps the rest", () => { + const env = pluginsChildEnv({ + PATH: "/usr/bin", + HOME: "/tmp/home", + DISABLE_TELEMETRY: "1", + DO_NOT_TRACK: "1", + NEON_API_KEY: "secret", + NeOn_ApI_KeY: "also-secret", + CI: "true", + }); + expect(env.PATH).toBe("/usr/bin"); + expect(env.HOME).toBe("/tmp/home"); + expect(env.CI).toBe("true"); + expect(env).not.toHaveProperty("DISABLE_TELEMETRY"); + expect(env).not.toHaveProperty("DO_NOT_TRACK"); + expect(env).not.toHaveProperty("NEON_API_KEY"); + expect(env).not.toHaveProperty("NeOn_ApI_KeY"); + }); +}); + +describe("neonPluginsRetryCommand", () => { + test("names agents and user-level scope on the neon command", () => { + expect( + neonPluginsRetryCommand({ + agents: ["cursor", "claude-code"], + global: true, + }), + ).toBe("neon plugins --agent cursor --agent claude-code --global -y"); + }); + + test("is the neon command, not npx", () => { + expect( + neonPluginsRetryCommand({ agents: ["cursor"], global: false }), + ).toBe("neon plugins --agent cursor -y"); + expect( + neonPluginsRetryCommand({ agents: ["cursor"], global: false }), + ).not.toMatch(/npx /); + expect( + neonPluginsRetryCommand({ agents: ["cursor"], global: false }), + ).not.toMatch(/neondatabase\//); + }); +}); diff --git a/packages/cli/src/plugins/run.ts b/packages/cli/src/plugins/run.ts new file mode 100644 index 00000000..b2009a30 --- /dev/null +++ b/packages/cli/src/plugins/run.ts @@ -0,0 +1,121 @@ +import { execa } from "execa"; + +const TELEMETRY_BLOCKERS = ["DISABLE_TELEMETRY", "DO_NOT_TRACK"] as const; + +export const PLUGIN_SOURCE = "neondatabase/agent-skills"; +export const NEON_PLUGIN_NAME = "neon-postgres"; + +export type PluginsCliScope = "user" | "project"; + +export const pluginsChildEnv = ( + base: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv => { + const env = { ...base }; + // The plugins CLI needs its own install ping even though Neon records the parent command. + for (const key of TELEMETRY_BLOCKERS) { + delete env[key]; + } + for (const key of Object.keys(env)) { + if (key.toUpperCase() === "NEON_API_KEY") { + delete env[key]; + } + } + return env; +}; + +export const pluginsScopeFlag = (global: boolean): PluginsCliScope => + global ? "user" : "project"; + +export const pluginsAddArgs = (options: { + target: string; + global: boolean; +}): string[] => { + if (options.target.length === 0) { + throw new Error("plugins add needs a -t target."); + } + return [ + "-y", + "plugins", + "add", + PLUGIN_SOURCE, + "-t", + options.target, + "-s", + pluginsScopeFlag(options.global), + "-y", + ]; +}; + +export const neonPluginsRetryCommand = (options: { + agents: readonly string[]; + global: boolean; +}): string => { + const args = ["plugins"]; + for (const agent of options.agents) { + args.push("--agent", agent); + } + if (options.global) { + args.push("--global"); + } + args.push("-y"); + return `neon ${args.map(quoteNpxArg).join(" ")}`; +}; + +export const quoteNpxArg = (part: string): string => + /[\s"'\\*]/.test(part) ? `'${part.replace(/'/g, `'\\''`)}'` : part; + +export type PluginsRunResult = { + stdout: string; + stderr: string; +}; + +export const runPluginsCli = async (options: { + args: readonly string[]; + cwd: string; +}): Promise => { + try { + const result = await execa("npx", options.args, { + cwd: options.cwd, + env: pluginsChildEnv(), + // extendEnv would copy DISABLE_TELEMETRY / DO_NOT_TRACK back in. + extendEnv: false, + stdio: "pipe", + timeout: 120_000, + }); + return { stdout: result.stdout, stderr: result.stderr }; + } catch (error) { + if (isCommandMissing(error)) { + throw new Error( + "neon plugins needs npx (Node.js) to run the plugins CLI. Install Node.js, then retry.", + ); + } + if (isExecaFailure(error)) { + const childOut = [error.stderr, error.stdout] + .filter((part) => typeof part === "string" && part.length > 0) + .join("\n"); + if (childOut.length > 0) { + throw new Error(`plugins CLI failed:\n${childOut}`); + } + throw new Error("plugins CLI failed."); + } + throw error; + } +}; + +const isCommandMissing = (error: unknown): boolean => + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT"; + +const isExecaFailure = ( + error: unknown, +): error is { + stderr: string; + stdout: string; + shortMessage: string; +} => + typeof error === "object" && + error !== null && + "shortMessage" in error && + typeof error.shortMessage === "string"; diff --git a/packages/cli/src/plugins/targets.test.ts b/packages/cli/src/plugins/targets.test.ts new file mode 100644 index 00000000..db36cdff --- /dev/null +++ b/packages/cli/src/plugins/targets.test.ts @@ -0,0 +1,102 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { + detectPluginsAgents, + mappedPluginsTargets, + pluginsInstallableAgents, + pluginsMappedAgents, +} from "./targets.js"; + +describe("pluginsMappedAgents", () => { + test("is the MCP roster minus agents with no plugins mapping", () => { + const ids = pluginsMappedAgents(); + expect(ids).toContain("cursor"); + expect(ids).toContain("claude-desktop"); + expect(ids).toContain("vscode"); + expect(ids).toContain("github-copilot-cli"); + expect(ids).toContain("grok-build"); + expect(ids).not.toContain("mcporter"); + expect(ids).not.toContain("eve"); + expect(ids).not.toContain("cursor-cli"); + expect(ids).not.toContain("kimi"); + }); +}); + +describe("pluginsInstallableAgents", () => { + test("project scope drops agents the plugins CLI only installs user-level", () => { + const ids = pluginsInstallableAgents("project"); + expect(ids).toContain("cursor"); + expect(ids).toContain("claude-code"); + expect(ids).not.toContain("vscode"); + expect(ids).not.toContain("github-copilot-cli"); + expect(ids).not.toContain("grok-build"); + expect(ids).not.toContain("mcporter"); + }); + + test("user-level includes vscode, Copilot CLI, and Grok", () => { + const ids = pluginsInstallableAgents("global"); + expect(ids).toContain("vscode"); + expect(ids).toContain("github-copilot-cli"); + expect(ids).toContain("grok-build"); + expect(ids).toContain("cursor"); + }); +}); + +describe("mappedPluginsTargets", () => { + test("dedupes Claude Desktop and Claude Code onto claude-code", () => { + expect( + mappedPluginsTargets( + ["claude-desktop", "claude-code", "cursor"], + "project", + ), + ).toEqual([ + { agent: "claude-desktop", target: "claude-code" }, + { agent: "cursor", target: "cursor" }, + ]); + }); + + test("maps vscode to vscode, not github-copilot", () => { + expect(mappedPluginsTargets(["vscode"], "global")).toEqual([ + { agent: "vscode", target: "vscode" }, + ]); + expect(mappedPluginsTargets(["github-copilot-cli"], "global")).toEqual([ + { agent: "github-copilot-cli", target: "github-copilot" }, + ]); + }); + + test("throws when every selected agent lacks a mapping", () => { + expect(() => mappedPluginsTargets(["mcporter"], "project")).toThrow( + /None of the selected agents can install plugins/, + ); + }); +}); + +describe("detectPluginsAgents", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("project scope detects from the project folder", async () => { + const cwd = mkdtempSync(join(tmpdir(), "neon-plugins-detect-")); + dirs.push(cwd); + mkdirSync(join(cwd, ".cursor")); + expect(await detectPluginsAgents({ scope: "project", cwd })).toContain( + "cursor", + ); + }); + + test("project scope ignores a folder with no agent markers", async () => { + const cwd = mkdtempSync(join(tmpdir(), "neon-plugins-detect-empty-")); + dirs.push(cwd); + expect(await detectPluginsAgents({ scope: "project", cwd })).toEqual( + [], + ); + }); +}); diff --git a/packages/cli/src/plugins/targets.ts b/packages/cli/src/plugins/targets.ts new file mode 100644 index 00000000..9567a957 --- /dev/null +++ b/packages/cli/src/plugins/targets.ts @@ -0,0 +1,95 @@ +import { detectProjectAgents } from "add-mcp"; + +import { + type AgentType, + detectInstalledAgents, + listMcpAgentIds, + tryResolveAddMcpAgentId, + uniqueAgentIds, +} from "../init/agents.js"; + +export type PluginsInstallScope = "global" | "project"; + +export type PluginsMappedTarget = { + agent: AgentType; + target: string; +}; + +const PLUGINS_TARGET_BY_TYPE: { [K in AgentType]?: string } = { + cursor: "cursor", + vscode: "vscode", + "claude-code": "claude-code", + "claude-desktop": "claude-code", + codex: "codex", + "github-copilot-cli": "github-copilot", + "grok-build": "grok", +}; + +const USER_SCOPE_ONLY_TARGETS = new Set(["vscode", "github-copilot", "grok"]); + +export function getPluginsTargetName(agent: string): string | undefined { + if (Object.prototype.hasOwnProperty.call(PLUGINS_TARGET_BY_TYPE, agent)) { + return PLUGINS_TARGET_BY_TYPE[agent as AgentType]; + } + const id = tryResolveAddMcpAgentId(agent); + if (!id) return undefined; + return PLUGINS_TARGET_BY_TYPE[id]; +} + +export function supportsPlugins(agent: string): boolean { + return getPluginsTargetName(agent) !== undefined; +} + +export function isUserScopeOnlyPluginsTarget(target: string): boolean { + return USER_SCOPE_ONLY_TARGETS.has(target); +} + +export const pluginsMappedAgents = (): AgentType[] => + listMcpAgentIds().filter((id) => supportsPlugins(id)); + +export const pluginsInstallableAgents = ( + scope: PluginsInstallScope, +): AgentType[] => + pluginsMappedAgents().filter((id) => { + if (scope === "global") { + return true; + } + const target = getPluginsTargetName(id); + return target !== undefined && !isUserScopeOnlyPluginsTarget(target); + }); + +export const detectPluginsAgents = async (options: { + scope: PluginsInstallScope; + cwd: string; +}): Promise => { + const detected = + options.scope === "project" + ? detectProjectAgents(options.cwd) + : await detectInstalledAgents(); + return uniqueAgentIds(detected).filter((id) => supportsPlugins(id)); +}; + +export const mappedPluginsTargets = ( + agents: readonly AgentType[], + scope: PluginsInstallScope, +): PluginsMappedTarget[] => { + const mapped: PluginsMappedTarget[] = []; + const seen = new Set(); + for (const agent of agents) { + const target = getPluginsTargetName(agent); + if (target === undefined || seen.has(target)) { + continue; + } + if (scope === "project" && isUserScopeOnlyPluginsTarget(target)) { + continue; + } + seen.add(target); + mapped.push({ agent, target }); + } + if (mapped.length === 0) { + throw new Error( + `None of the selected agents can install plugins. Supported agents: ${pluginsInstallableAgents(scope).join(", ")}`, + ); + } + return mapped; +}; diff --git a/packages/cli/src/plugins/wizard.test.ts b/packages/cli/src/plugins/wizard.test.ts new file mode 100644 index 00000000..a23f533a --- /dev/null +++ b/packages/cli/src/plugins/wizard.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; + +import { pluginsInstallSummary } from "./wizard.js"; + +describe("pluginsInstallSummary", () => { + test("names project-scoped, agents, and the plugin id", () => { + expect( + pluginsInstallSummary({ + scope: "project", + agents: ["cursor"], + }), + ).toBe( + [ + "Config project-scoped", + "Agents Cursor", + "Plugin neon-postgres", + ].join("\n"), + ); + }); + + test("names user-level without repos", () => { + const text = pluginsInstallSummary({ + scope: "global", + agents: ["cursor", "claude-code"], + }); + expect(text).toContain("user-level"); + expect(text).toContain("neon-postgres"); + expect(text).not.toContain("neondatabase/"); + }); +}); diff --git a/packages/cli/src/plugins/wizard.ts b/packages/cli/src/plugins/wizard.ts new file mode 100644 index 00000000..fabab20a --- /dev/null +++ b/packages/cli/src/plugins/wizard.ts @@ -0,0 +1,51 @@ +import prompts from "prompts"; + +import { getAgentDisplayName } from "../init/agents.js"; +import type { AgentType } from "../mcp/agents.js"; +import { canPickAgentsInteractively } from "../utils/agent_picker.js"; +import { NEON_PLUGIN_NAME } from "./run.js"; +import type { PluginsInstallScope } from "./targets.js"; + +const restoreCursorOnAbort = (state: { aborted: boolean }) => { + if (state.aborted) { + process.stdout.write("\x1B[?25h"); + process.stdout.write("\n"); + process.exit(1); + } +}; + +export const pluginsInstallSummary = (options: { + scope: PluginsInstallScope; + agents: readonly AgentType[]; +}): string => { + const rows: [string, string][] = [ + [ + "Config", + options.scope === "project" ? "project-scoped" : "user-level", + ], + ["Agents", options.agents.map(getAgentDisplayName).join(", ")], + ["Plugin", NEON_PLUGIN_NAME], + ]; + const labelWidth = Math.max(...rows.map(([label]) => label.length)); + return rows + .map(([label, value]) => `${label.padEnd(labelWidth)} ${value}`) + .join("\n"); +}; + +export const confirmPluginsInstall = async (options: { + scope: PluginsInstallScope; + agents: readonly AgentType[]; +}): Promise => { + if (!canPickAgentsInteractively()) { + return true; + } + process.stdout.write(`\n${pluginsInstallSummary(options)}\n\n`); + const { ok } = await prompts({ + onState: restoreCursorOnAbort, + type: "confirm", + name: "ok", + message: "Install the Neon plugin into these agents?", + initial: true, + }); + return ok === true; +}; From b4d7a362cc7063631bb0aa4f09ffefc8ebe403cb Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 08:39:11 -0700 Subject: [PATCH 2/5] Match neon plugins copy to the plugins CLI scope field Use project/user in the table and summary, list only agents that can install at the current scope, and name a silent timeout instead of leaking the child argv. --- .changeset/cli-neon-plugins.md | 2 +- packages/cli/README.md | 6 ++-- packages/cli/src/commands/plugins.test.ts | 23 +++++++++---- packages/cli/src/commands/plugins.ts | 24 +++++-------- packages/cli/src/plugins/plan.ts | 9 +++-- packages/cli/src/plugins/run.test.ts | 42 +++++++++++++++++++++++ packages/cli/src/plugins/run.ts | 26 ++++++++++---- packages/cli/src/plugins/wizard.test.ts | 10 +++--- packages/cli/src/plugins/wizard.ts | 5 +-- 9 files changed, 100 insertions(+), 47 deletions(-) diff --git a/.changeset/cli-neon-plugins.md b/.changeset/cli-neon-plugins.md index 4ffc89f8..55736d2b 100644 --- a/.changeset/cli-neon-plugins.md +++ b/.changeset/cli-neon-plugins.md @@ -3,4 +3,4 @@ "neonctl": minor --- -Add `neon plugins` to install the Neon agent plugin into coding agents. A TTY asks agents, then confirms. `-y` installs into detected agents, project-scoped. `--agent` names specific agents. `--global` is user-level. +Add `neon plugins` to install the Neon agent plugin into coding agents. A TTY asks agents, then confirms. `-y` installs into detected agents at project scope. `--agent` names specific agents. `--global` is user scope. diff --git a/packages/cli/README.md b/packages/cli/README.md index bf34e934..2c9f9987 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -796,10 +796,10 @@ On a TTY the command asks which agents and which skills, then shows a summary to `neon plugins` installs the Neon agent plugin (`neon-postgres`) by running `npx plugins add`. It does not call the Neon API. ```bash -# Interactive: project-scoped, then agents, then confirm. +# Interactive: agents, then confirm. $ neon plugins -# Skip prompts. Project-scoped, every detected agent. +# Skip prompts. Every detected agent. $ neon plugins -y $ neon plugins --agent cursor --agent claude-code @@ -813,7 +813,7 @@ On a TTY the command asks which agents, then shows a summary to confirm. Detecte `-y` skips those questions and installs into every detected agent. `--agent` names specific agents and skips the agent picker. Without a TTY, pass `-y` or `--agent`. `--agent` alone is enough because the plugin is fixed. -Default scope is project-scoped (`-s project` on the plugins CLI). `--global` is user-level (`-s user`). Cursor and Claude Code still store the plugin cache under `~/.claude/plugins`; project-scoped vs user-level is the scope field the plugins CLI records. VS Code, GitHub Copilot CLI, and Grok Build only install user-level: they are skipped at the default scope with a warning, and `--agent vscode` without `--global` fails if nothing else is selected. +Default scope is `project`. `--global` is `user`. Cursor and Claude Code still store the plugin cache under `~/.claude/plugins`; `project` vs `user` is the scope field the plugins CLI records, not a directory in the repo. VS Code, GitHub Copilot CLI, and Grok Build only install user-level: they are skipped at the default scope with a warning, and `--agent vscode` without `--global` fails if nothing else is selected. `--agent` names with a plugins mapping: `claude-code`, `claude-desktop`, `codex`, `cursor`, `github-copilot-cli`, `grok-build`, `vscode`. `claude-desktop` installs as Claude Code. `mcporter` is a known MCP name that is then skipped. diff --git a/packages/cli/src/commands/plugins.test.ts b/packages/cli/src/commands/plugins.test.ts index a10da9f3..e3a99067 100644 --- a/packages/cli/src/commands/plugins.test.ts +++ b/packages/cli/src/commands/plugins.test.ts @@ -115,7 +115,7 @@ describe("neon plugins", () => { ); expect(JSON.parse(stdout)).toEqual([ { - scope: "project-scoped", + scope: "project", plugin: "neon-postgres", agent: "cursor", status: "installed", @@ -182,13 +182,13 @@ describe("neon plugins", () => { ); expect(JSON.parse(stdout)).toEqual([ { - scope: "project-scoped", + scope: "project", plugin: "neon-postgres", agent: "cursor", status: "installed", }, { - scope: "project-scoped", + scope: "project", plugin: "neon-postgres", agent: "claude-code", status: "installed", @@ -227,6 +227,15 @@ describe("neon plugins", () => { { ...runOptions(home, cwd, bin), code: 1 }, ); expect(unknownAgent).toMatch(/Unknown agent: "eve"/); + expect(unknownAgent).not.toMatch(/vscode/); + expect(unknownAgent).not.toMatch(/github-copilot-cli/); + expect(unknownAgent).not.toMatch(/grok-build/); + const { stderr: unknownGlobal } = await testCliCommand( + ["plugins", "-y", "--global", "--agent", "eve"], + { ...runOptions(home, cwd, bin), code: 1 }, + ); + expect(unknownGlobal).toMatch(/Unknown agent: "eve"/); + expect(unknownGlobal).toMatch(/vscode/); const { stderr: star } = await testCliCommand( ["plugins", "-y", "--agent", "*"], { ...runOptions(home, cwd, bin), code: 1 }, @@ -274,7 +283,7 @@ describe("neon plugins", () => { runOptions(home, cwd, bin), ); expect(JSON.parse(readFileSync(argvFile, "utf8"))[0]).toContain("user"); - expect(JSON.parse(stdout)[0].scope).toBe("user-level"); + expect(JSON.parse(stdout)[0].scope).toBe("user"); }); test("vscode requires --global", async ({ testCliCommand }) => { @@ -290,7 +299,7 @@ describe("neon plugins", () => { runOptions(home, cwd, bin), ); expect(JSON.parse(stdout)[0]).toMatchObject({ - scope: "user-level", + scope: "user", agent: "vscode", status: "installed", }); @@ -424,6 +433,8 @@ describe("neon plugins", () => { expect(text).not.toMatch(/--plugin/); expect(text).not.toMatch(/plugins update/); expect(text).not.toMatch(/neondatabase\/agent-skills/); - expect(text).not.toMatch(/-y, -y/); + expect(text).not.toMatch(/-s user/); + expect(text).not.toMatch(/-s project/); + expect(text).not.toMatch(/project-scoped/); }); }); diff --git a/packages/cli/src/commands/plugins.ts b/packages/cli/src/commands/plugins.ts index 8b941cfc..2ec103c6 100644 --- a/packages/cli/src/commands/plugins.ts +++ b/packages/cli/src/commands/plugins.ts @@ -30,7 +30,7 @@ type PluginsInstallRow = { }; const scopeLabel = (scope: "global" | "project"): string => - scope === "project" ? "project-scoped" : "user-level"; + scope === "project" ? "project" : "user"; const coerceAgents = (value: unknown): string[] => { if (value === undefined) return []; @@ -66,8 +66,7 @@ export const builder = (argv: yargs.Argv) => global: { type: "boolean", default: false, - describe: - "Install user-level (plugins CLI -s user). Default is project-scoped (-s project)", + describe: "Install user-level. Default is project", }, agent: { alias: "a", @@ -78,14 +77,8 @@ export const builder = (argv: yargs.Argv) => coerce: coerceAgents, }, }) - .example( - "$0 plugins", - "Interactive: project-scoped, agents, then confirm", - ) - .example( - "$0 plugins -y", - "Project-scoped, detected agents, skip prompts", - ) + .example("$0 plugins", "Interactive: agents, then confirm") + .example("$0 plugins -y", "Detected agents, skip prompts") .example( "$0 plugins --agent cursor --agent claude-code", "Install into specific agents", @@ -175,8 +168,8 @@ export const handler = async (props: PluginsProps) => { if (failed.length === 0) { log.info( plan.scope === "project" - ? "Installed the Neon plugin (project-scoped)." - : "Installed the Neon plugin (user-level).", + ? "Installed the Neon plugin (project)." + : "Installed the Neon plugin (user).", ); return; } @@ -191,10 +184,11 @@ export const handler = async (props: PluginsProps) => { if (first.message.includes("needs npx (Node.js)")) { throw new Error(first.message); } + const detail = failed.map((row) => row.message).join("\n"); if (failed.length === rows.length) { - throw new Error(`${first.message}\nRetry with: ${retry}`); + throw new Error(`${detail}\nRetry with: ${retry}`); } throw new Error( - `Failed to install the Neon plugin for: ${failed.map((row) => row.agent).join(", ")}.\n${first.message}\nRetry with: ${retry}`, + `Failed to install the Neon plugin for: ${failed.map((row) => row.agent).join(", ")}.\n${detail}\nRetry with: ${retry}`, ); }; diff --git a/packages/cli/src/plugins/plan.ts b/packages/cli/src/plugins/plan.ts index 69e3f345..c09a3806 100644 --- a/packages/cli/src/plugins/plan.ts +++ b/packages/cli/src/plugins/plan.ts @@ -17,7 +17,6 @@ import { type PluginsInstallScope, type PluginsMappedTarget, pluginsInstallableAgents, - pluginsMappedAgents, } from "./targets.js"; export type PluginsPlan = { @@ -73,8 +72,8 @@ export async function resolvePluginsPlan( "Which coding agents should get the Neon plugin? (space to toggle, enter to confirm)", nonInteractiveMessage: scope === "project" - ? `No coding agents detected in this project. Pass --agent . Supported agents: ${pluginsMappedAgents().join(", ")}` - : `No coding agents detected. Pass --agent . Supported agents: ${pluginsMappedAgents().join(", ")}`, + ? `No coding agents detected in this project. Pass --agent . Supported agents: ${available.join(", ")}` + : `No coding agents detected. Pass --agent . Supported agents: ${available.join(", ")}`, resolveSpecified: (raw) => { if (raw === "*") { throw new Error( @@ -84,7 +83,7 @@ export async function resolvePluginsPlan( const id = tryResolveAddMcpAgentId(raw); if (!id) { throw new Error( - `Unknown agent: "${raw}". Supported agents: ${pluginsMappedAgents().join(", ")}`, + `Unknown agent: "${raw}". Supported agents: ${available.join(", ")}`, ); } return id; @@ -117,7 +116,7 @@ export async function resolvePluginsPlan( .map((id) => getAgentDisplayName(id)) .join(", "); throw new Error( - `${names}: plugins are user-level. Pass --global. Project-scoped agents: ${pluginsInstallableAgents("project").join(", ")}`, + `${names}: plugins are user-level. Pass --global. Without --global: ${pluginsInstallableAgents("project").join(", ")}`, ); } throw new Error( diff --git a/packages/cli/src/plugins/run.test.ts b/packages/cli/src/plugins/run.test.ts index d1677146..648726ae 100644 --- a/packages/cli/src/plugins/run.test.ts +++ b/packages/cli/src/plugins/run.test.ts @@ -5,6 +5,7 @@ import { PLUGIN_SOURCE, pluginsAddArgs, pluginsChildEnv, + pluginsCliFailureMessage, } from "./run.js"; describe("pluginsAddArgs", () => { @@ -70,6 +71,47 @@ describe("pluginsChildEnv", () => { }); }); +describe("pluginsCliFailureMessage", () => { + test("prefers child output over a timeout flag", () => { + expect( + pluginsCliFailureMessage({ + stdout: "", + stderr: "boom", + timedOut: true, + }), + ).toBe("plugins CLI failed:\nboom"); + }); + + test("names a timeout when the child printed nothing", () => { + expect( + pluginsCliFailureMessage({ + stdout: "", + stderr: "", + timedOut: true, + }), + ).toBe("plugins CLI timed out after 120 seconds."); + }); + + test("does not use execa shortMessage", () => { + expect( + pluginsCliFailureMessage({ + stdout: "", + stderr: "", + }), + ).toBe("plugins CLI failed."); + }); + + test("timeout text does not name the plugin source", () => { + expect( + pluginsCliFailureMessage({ + stdout: "", + stderr: "", + timedOut: true, + }), + ).not.toMatch(/neondatabase\//); + }); +}); + describe("neonPluginsRetryCommand", () => { test("names agents and user-level scope on the neon command", () => { expect( diff --git a/packages/cli/src/plugins/run.ts b/packages/cli/src/plugins/run.ts index b2009a30..46902037 100644 --- a/packages/cli/src/plugins/run.ts +++ b/packages/cli/src/plugins/run.ts @@ -90,18 +90,29 @@ export const runPluginsCli = async (options: { ); } if (isExecaFailure(error)) { - const childOut = [error.stderr, error.stdout] - .filter((part) => typeof part === "string" && part.length > 0) - .join("\n"); - if (childOut.length > 0) { - throw new Error(`plugins CLI failed:\n${childOut}`); - } - throw new Error("plugins CLI failed."); + throw new Error(pluginsCliFailureMessage(error)); } throw error; } }; +export const pluginsCliFailureMessage = (error: { + stderr: string; + stdout: string; + timedOut?: boolean; +}): string => { + const childOut = [error.stderr, error.stdout] + .filter((part) => typeof part === "string" && part.length > 0) + .join("\n"); + if (childOut.length > 0) { + return `plugins CLI failed:\n${childOut}`; + } + if (error.timedOut === true) { + return "plugins CLI timed out after 120 seconds."; + } + return "plugins CLI failed."; +}; + const isCommandMissing = (error: unknown): boolean => typeof error === "object" && error !== null && @@ -114,6 +125,7 @@ const isExecaFailure = ( stderr: string; stdout: string; shortMessage: string; + timedOut?: boolean; } => typeof error === "object" && error !== null && diff --git a/packages/cli/src/plugins/wizard.test.ts b/packages/cli/src/plugins/wizard.test.ts index a23f533a..348439b2 100644 --- a/packages/cli/src/plugins/wizard.test.ts +++ b/packages/cli/src/plugins/wizard.test.ts @@ -10,11 +10,9 @@ describe("pluginsInstallSummary", () => { agents: ["cursor"], }), ).toBe( - [ - "Config project-scoped", - "Agents Cursor", - "Plugin neon-postgres", - ].join("\n"), + ["Config project", "Agents Cursor", "Plugin neon-postgres"].join( + "\n", + ), ); }); @@ -23,7 +21,7 @@ describe("pluginsInstallSummary", () => { scope: "global", agents: ["cursor", "claude-code"], }); - expect(text).toContain("user-level"); + expect(text).toContain("user"); expect(text).toContain("neon-postgres"); expect(text).not.toContain("neondatabase/"); }); diff --git a/packages/cli/src/plugins/wizard.ts b/packages/cli/src/plugins/wizard.ts index fabab20a..64420f67 100644 --- a/packages/cli/src/plugins/wizard.ts +++ b/packages/cli/src/plugins/wizard.ts @@ -19,10 +19,7 @@ export const pluginsInstallSummary = (options: { agents: readonly AgentType[]; }): string => { const rows: [string, string][] = [ - [ - "Config", - options.scope === "project" ? "project-scoped" : "user-level", - ], + ["Config", options.scope === "project" ? "project" : "user"], ["Agents", options.agents.map(getAgentDisplayName).join(", ")], ["Plugin", NEON_PLUGIN_NAME], ]; From fa6ae0d3d77072c70d79e4715734d6957bfb9804 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 09:18:06 -0700 Subject: [PATCH 3/5] Pin the plugins source in tests and qualify the Cursor cache path Argv assertions use the GitHub shorthand rather than the production constant, so a source change fails the suite. The README notes Windows Cursor uses extensions instead of ~/.claude/plugins. --- packages/cli/README.md | 2 +- packages/cli/src/commands/plugins.test.ts | 9 ++++----- packages/cli/src/plugins/run.test.ts | 5 +++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 2c9f9987..f9b4a360 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -813,7 +813,7 @@ On a TTY the command asks which agents, then shows a summary to confirm. Detecte `-y` skips those questions and installs into every detected agent. `--agent` names specific agents and skips the agent picker. Without a TTY, pass `-y` or `--agent`. `--agent` alone is enough because the plugin is fixed. -Default scope is `project`. `--global` is `user`. Cursor and Claude Code still store the plugin cache under `~/.claude/plugins`; `project` vs `user` is the scope field the plugins CLI records, not a directory in the repo. VS Code, GitHub Copilot CLI, and Grok Build only install user-level: they are skipped at the default scope with a warning, and `--agent vscode` without `--global` fails if nothing else is selected. +Default scope is `project`. `--global` is `user`. On macOS and Linux, Cursor and Claude Code store the plugin cache under `~/.claude/plugins`; on Windows, Cursor installs into Cursor extensions. `project` vs `user` is the scope field the plugins CLI records, not a directory in the repo. VS Code, GitHub Copilot CLI, and Grok Build only install user-level: they are skipped at the default scope with a warning, and `--agent vscode` without `--global` fails if nothing else is selected. `--agent` names with a plugins mapping: `claude-code`, `claude-desktop`, `codex`, `cursor`, `github-copilot-cli`, `grok-build`, `vscode`. `claude-desktop` installs as Claude Code. `mcporter` is a known MCP name that is then skipped. diff --git a/packages/cli/src/commands/plugins.test.ts b/packages/cli/src/commands/plugins.test.ts index e3a99067..5dad4e34 100644 --- a/packages/cli/src/commands/plugins.test.ts +++ b/packages/cli/src/commands/plugins.test.ts @@ -11,7 +11,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; -import { PLUGIN_SOURCE } from "../plugins/run.js"; import { test } from "../test_utils/fixtures"; const dirs: string[] = []; @@ -126,7 +125,7 @@ describe("neon plugins", () => { "-y", "plugins", "add", - PLUGIN_SOURCE, + "neondatabase/agent-skills", "-t", "cursor", "-s", @@ -199,7 +198,7 @@ describe("neon plugins", () => { "-y", "plugins", "add", - PLUGIN_SOURCE, + "neondatabase/agent-skills", "-t", "cursor", "-s", @@ -210,7 +209,7 @@ describe("neon plugins", () => { "-y", "plugins", "add", - PLUGIN_SOURCE, + "neondatabase/agent-skills", "-t", "claude-code", "-s", @@ -307,7 +306,7 @@ describe("neon plugins", () => { "-y", "plugins", "add", - PLUGIN_SOURCE, + "neondatabase/agent-skills", "-t", "vscode", "-s", diff --git a/packages/cli/src/plugins/run.test.ts b/packages/cli/src/plugins/run.test.ts index 648726ae..97aa33d6 100644 --- a/packages/cli/src/plugins/run.test.ts +++ b/packages/cli/src/plugins/run.test.ts @@ -14,11 +14,12 @@ describe("pluginsAddArgs", () => { target: "cursor", global: false, }); + expect(PLUGIN_SOURCE).toBe("neondatabase/agent-skills"); expect(args).toEqual([ "-y", "plugins", "add", - PLUGIN_SOURCE, + "neondatabase/agent-skills", "-t", "cursor", "-s", @@ -34,7 +35,7 @@ describe("pluginsAddArgs", () => { "-y", "plugins", "add", - PLUGIN_SOURCE, + "neondatabase/agent-skills", "-t", "vscode", "-s", From 52102ee756d260fd7adcf26082e65f48aba2bb62 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 09:44:16 -0700 Subject: [PATCH 4/5] Honor the plugins timeout and name every collapsed agent Kill the npx process group so a hung plugins grandchild cannot outlive the 120 second bound, and say so even when the child already printed a banner. Deduped Claude targets list every requested agent in the table. --- packages/cli/README.md | 2 +- packages/cli/src/commands/plugins.test.ts | 2 +- packages/cli/src/commands/plugins.ts | 13 ++-- packages/cli/src/plugins/plan.test.ts | 19 ++++-- packages/cli/src/plugins/run.test.ts | 73 ++++++++++++++++++-- packages/cli/src/plugins/run.ts | 83 +++++++++++++++++++---- packages/cli/src/plugins/targets.test.ts | 11 +-- packages/cli/src/plugins/targets.ts | 18 +++-- packages/cli/src/plugins/wizard.test.ts | 2 +- packages/cli/src/plugins/wizard.ts | 2 +- 10 files changed, 182 insertions(+), 43 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index f9b4a360..134a27a7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -815,7 +815,7 @@ On a TTY the command asks which agents, then shows a summary to confirm. Detecte Default scope is `project`. `--global` is `user`. On macOS and Linux, Cursor and Claude Code store the plugin cache under `~/.claude/plugins`; on Windows, Cursor installs into Cursor extensions. `project` vs `user` is the scope field the plugins CLI records, not a directory in the repo. VS Code, GitHub Copilot CLI, and Grok Build only install user-level: they are skipped at the default scope with a warning, and `--agent vscode` without `--global` fails if nothing else is selected. -`--agent` names with a plugins mapping: `claude-code`, `claude-desktop`, `codex`, `cursor`, `github-copilot-cli`, `grok-build`, `vscode`. `claude-desktop` installs as Claude Code. `mcporter` is a known MCP name that is then skipped. +`--agent` names with a plugins mapping: `claude-code`, `claude-desktop`, `codex`, `cursor`, `github-copilot-cli`, `grok-build`, `vscode`. `claude-desktop` installs as Claude Code; naming both produces one install and lists both names in the table. `mcporter` is a known MCP name that is then skipped. The plugins CLI installs every plugin it finds in the Neon plugin package. Today that is `neon-postgres`. diff --git a/packages/cli/src/commands/plugins.test.ts b/packages/cli/src/commands/plugins.test.ts index 5dad4e34..099de7ff 100644 --- a/packages/cli/src/commands/plugins.test.ts +++ b/packages/cli/src/commands/plugins.test.ts @@ -189,7 +189,7 @@ describe("neon plugins", () => { { scope: "project", plugin: "neon-postgres", - agent: "claude-code", + agent: "claude-code, claude-desktop", status: "installed", }, ]); diff --git a/packages/cli/src/commands/plugins.ts b/packages/cli/src/commands/plugins.ts index 2ec103c6..37e98a85 100644 --- a/packages/cli/src/commands/plugins.ts +++ b/packages/cli/src/commands/plugins.ts @@ -123,13 +123,14 @@ export const handler = async (props: PluginsProps) => { } const rows: PluginsInstallRow[] = []; - const failed: { agent: string; message: string }[] = []; + const failed: { agents: string[]; message: string }[] = []; const scope = scopeLabel(plan.scope); for (const mapped of plan.targets) { const args = pluginsAddArgs({ target: mapped.target, global: plan.scope === "global", }); + const agent = mapped.agents.join(", "); try { await runPluginsCli({ args, @@ -138,7 +139,7 @@ export const handler = async (props: PluginsProps) => { rows.push({ scope, plugin: NEON_PLUGIN_NAME, - agent: mapped.agent, + agent, status: "installed", }); } catch (error) { @@ -147,12 +148,12 @@ export const handler = async (props: PluginsProps) => { rows.push({ scope, plugin: NEON_PLUGIN_NAME, - agent: mapped.agent, + agent, status: "failed", error: "plugins CLI failed", }); failed.push({ - agent: mapped.agent, + agents: mapped.agents, message, }); } @@ -178,7 +179,7 @@ export const handler = async (props: PluginsProps) => { throw new Error("Failed to install the Neon plugin."); } const retry = neonPluginsRetryCommand({ - agents: failed.map((row) => row.agent), + agents: failed.flatMap((row) => row.agents), global: plan.scope === "global", }); if (first.message.includes("needs npx (Node.js)")) { @@ -189,6 +190,6 @@ export const handler = async (props: PluginsProps) => { throw new Error(`${detail}\nRetry with: ${retry}`); } throw new Error( - `Failed to install the Neon plugin for: ${failed.map((row) => row.agent).join(", ")}.\n${detail}\nRetry with: ${retry}`, + `Failed to install the Neon plugin for: ${failed.flatMap((row) => row.agents).join(", ")}.\n${detail}\nRetry with: ${retry}`, ); }; diff --git a/packages/cli/src/plugins/plan.test.ts b/packages/cli/src/plugins/plan.test.ts index 2f259ca3..33ea11f7 100644 --- a/packages/cli/src/plugins/plan.test.ts +++ b/packages/cli/src/plugins/plan.test.ts @@ -93,7 +93,7 @@ describe("resolvePluginsPlan", () => { agents: ["cursor"], skipped: [], userScopeSkipped: [], - targets: [{ agent: "cursor", target: "cursor" }], + targets: [{ agents: ["cursor"], target: "cursor" }], }); }); @@ -107,7 +107,9 @@ describe("resolvePluginsPlan", () => { }), ); expect(plan.agents).toEqual(["cursor"]); - expect(plan.targets).toEqual([{ agent: "cursor", target: "cursor" }]); + expect(plan.targets).toEqual([ + { agents: ["cursor"], target: "cursor" }, + ]); }); test("non-TTY without -y or --agent fails", async () => { @@ -193,7 +195,9 @@ describe("resolvePluginsPlan", () => { ); expect(plan.agents).toEqual(["cursor"]); expect(plan.userScopeSkipped).toEqual(["vscode"]); - expect(plan.targets).toEqual([{ agent: "cursor", target: "cursor" }]); + expect(plan.targets).toEqual([ + { agents: ["cursor"], target: "cursor" }, + ]); }); test("fails when every selected agent is user-level-only at project scope", async () => { @@ -220,7 +224,9 @@ describe("resolvePluginsPlan", () => { }), ); expect(plan.agents).toEqual(["vscode"]); - expect(plan.targets).toEqual([{ agent: "vscode", target: "vscode" }]); + expect(plan.targets).toEqual([ + { agents: ["vscode"], target: "vscode" }, + ]); }); test("dedupes Claude Desktop onto claude-code", async () => { @@ -234,7 +240,10 @@ describe("resolvePluginsPlan", () => { ); expect(plan.agents).toEqual(["claude-code", "claude-desktop"]); expect(plan.targets).toEqual([ - { agent: "claude-code", target: "claude-code" }, + { + agents: ["claude-code", "claude-desktop"], + target: "claude-code", + }, ]); }); diff --git a/packages/cli/src/plugins/run.test.ts b/packages/cli/src/plugins/run.test.ts index 97aa33d6..2768c9d2 100644 --- a/packages/cli/src/plugins/run.test.ts +++ b/packages/cli/src/plugins/run.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, test } from "vitest"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; import { neonPluginsRetryCommand, @@ -6,6 +9,7 @@ import { pluginsAddArgs, pluginsChildEnv, pluginsCliFailureMessage, + runPluginsCli, } from "./run.js"; describe("pluginsAddArgs", () => { @@ -73,14 +77,14 @@ describe("pluginsChildEnv", () => { }); describe("pluginsCliFailureMessage", () => { - test("prefers child output over a timeout flag", () => { + test("names a timeout before child output", () => { expect( pluginsCliFailureMessage({ stdout: "", stderr: "boom", timedOut: true, }), - ).toBe("plugins CLI failed:\nboom"); + ).toBe("plugins CLI timed out after 120 seconds:\nboom"); }); test("names a timeout when the child printed nothing", () => { @@ -93,6 +97,17 @@ describe("pluginsCliFailureMessage", () => { ).toBe("plugins CLI timed out after 120 seconds."); }); + test("uses the requested timeout length", () => { + expect( + pluginsCliFailureMessage({ + stdout: "", + stderr: "", + timedOut: true, + timeoutMs: 400, + }), + ).toBe("plugins CLI timed out after 1 second."); + }); + test("does not use execa shortMessage", () => { expect( pluginsCliFailureMessage({ @@ -106,7 +121,7 @@ describe("pluginsCliFailureMessage", () => { expect( pluginsCliFailureMessage({ stdout: "", - stderr: "", + stderr: "banner", timedOut: true, }), ).not.toMatch(/neondatabase\//); @@ -135,3 +150,53 @@ describe("neonPluginsRetryCommand", () => { ).not.toMatch(/neondatabase\//); }); }); + +describe("runPluginsCli timeout", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("kills npx and a grandchild that holds the pipe", async () => { + const bin = mkdtempSync(join(tmpdir(), "neon-plugins-npx-")); + dirs.push(bin); + writeFileSync( + join(bin, "npx"), + `#!/usr/bin/env node +const { spawn } = require("node:child_process"); +process.stdout.write("plugins banner\\n"); +spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", +}); +setInterval(() => {}, 1000); +`, + ); + chmodSync(join(bin, "npx"), 0o755); + const started = Date.now(); + await expect( + runPluginsCli({ + args: [ + "-y", + "plugins", + "add", + "x", + "-t", + "cursor", + "-s", + "project", + "-y", + ], + cwd: bin, + timeoutMs: 400, + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, + }, + }), + ).rejects.toThrow(/timed out after 1 second/); + expect(Date.now() - started).toBeLessThan(4000); + }); +}); diff --git a/packages/cli/src/plugins/run.ts b/packages/cli/src/plugins/run.ts index 46902037..7b3427b9 100644 --- a/packages/cli/src/plugins/run.ts +++ b/packages/cli/src/plugins/run.ts @@ -1,9 +1,11 @@ +import { spawnSync } from "node:child_process"; import { execa } from "execa"; const TELEMETRY_BLOCKERS = ["DISABLE_TELEMETRY", "DO_NOT_TRACK"] as const; export const PLUGIN_SOURCE = "neondatabase/agent-skills"; export const NEON_PLUGIN_NAME = "neon-postgres"; +export const PLUGINS_CLI_TIMEOUT_MS = 120_000; export type PluginsCliScope = "user" | "project"; @@ -72,16 +74,30 @@ export type PluginsRunResult = { export const runPluginsCli = async (options: { args: readonly string[]; cwd: string; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; }): Promise => { + const timeoutMs = options.timeoutMs ?? PLUGINS_CLI_TIMEOUT_MS; + const subprocess = execa("npx", options.args, { + cwd: options.cwd, + env: pluginsChildEnv(options.env ?? process.env), + // extendEnv would copy DISABLE_TELEMETRY / DO_NOT_TRACK back in. + extendEnv: false, + stdio: "pipe", + // npx leaves the plugins CLI as a grandchild. execa's timeout signals + // npx and then waits on the pipe, so a hang in the grandchild never + // settles. A process-group kill reaps both. + detached: true, + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + if (subprocess.pid !== undefined) { + killProcessTree(subprocess.pid); + } + }, timeoutMs); try { - const result = await execa("npx", options.args, { - cwd: options.cwd, - env: pluginsChildEnv(), - // extendEnv would copy DISABLE_TELEMETRY / DO_NOT_TRACK back in. - extendEnv: false, - stdio: "pipe", - timeout: 120_000, - }); + const result = await subprocess; return { stdout: result.stdout, stderr: result.stderr }; } catch (error) { if (isCommandMissing(error)) { @@ -90,9 +106,20 @@ export const runPluginsCli = async (options: { ); } if (isExecaFailure(error)) { - throw new Error(pluginsCliFailureMessage(error)); + throw new Error( + pluginsCliFailureMessage({ + stderr: + typeof error.stderr === "string" ? error.stderr : "", + stdout: + typeof error.stdout === "string" ? error.stdout : "", + timedOut, + timeoutMs, + }), + ); } throw error; + } finally { + clearTimeout(timer); } }; @@ -100,19 +127,46 @@ export const pluginsCliFailureMessage = (error: { stderr: string; stdout: string; timedOut?: boolean; + timeoutMs?: number; }): string => { const childOut = [error.stderr, error.stdout] .filter((part) => typeof part === "string" && part.length > 0) .join("\n"); + if (error.timedOut === true) { + const seconds = Math.max( + 1, + Math.ceil((error.timeoutMs ?? PLUGINS_CLI_TIMEOUT_MS) / 1000), + ); + const unit = seconds === 1 ? "second" : "seconds"; + const headline = `plugins CLI timed out after ${seconds} ${unit}`; + return childOut.length > 0 + ? `${headline}:\n${childOut}` + : `${headline}.`; + } if (childOut.length > 0) { return `plugins CLI failed:\n${childOut}`; } - if (error.timedOut === true) { - return "plugins CLI timed out after 120 seconds."; - } return "plugins CLI failed."; }; +const killProcessTree = (pid: number): void => { + if (process.platform === "win32") { + spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { + stdio: "ignore", + }); + return; + } + try { + process.kill(-pid, "SIGKILL"); + } catch { + try { + process.kill(pid, "SIGKILL"); + } catch { + return; + } + } +}; + const isCommandMissing = (error: unknown): boolean => typeof error === "object" && error !== null && @@ -122,10 +176,9 @@ const isCommandMissing = (error: unknown): boolean => const isExecaFailure = ( error: unknown, ): error is { - stderr: string; - stdout: string; + stderr: unknown; + stdout: unknown; shortMessage: string; - timedOut?: boolean; } => typeof error === "object" && error !== null && diff --git a/packages/cli/src/plugins/targets.test.ts b/packages/cli/src/plugins/targets.test.ts index db36cdff..d5ee5d92 100644 --- a/packages/cli/src/plugins/targets.test.ts +++ b/packages/cli/src/plugins/targets.test.ts @@ -53,17 +53,20 @@ describe("mappedPluginsTargets", () => { "project", ), ).toEqual([ - { agent: "claude-desktop", target: "claude-code" }, - { agent: "cursor", target: "cursor" }, + { + agents: ["claude-desktop", "claude-code"], + target: "claude-code", + }, + { agents: ["cursor"], target: "cursor" }, ]); }); test("maps vscode to vscode, not github-copilot", () => { expect(mappedPluginsTargets(["vscode"], "global")).toEqual([ - { agent: "vscode", target: "vscode" }, + { agents: ["vscode"], target: "vscode" }, ]); expect(mappedPluginsTargets(["github-copilot-cli"], "global")).toEqual([ - { agent: "github-copilot-cli", target: "github-copilot" }, + { agents: ["github-copilot-cli"], target: "github-copilot" }, ]); }); diff --git a/packages/cli/src/plugins/targets.ts b/packages/cli/src/plugins/targets.ts index 9567a957..5fee81ae 100644 --- a/packages/cli/src/plugins/targets.ts +++ b/packages/cli/src/plugins/targets.ts @@ -11,7 +11,7 @@ import { export type PluginsInstallScope = "global" | "project"; export type PluginsMappedTarget = { - agent: AgentType; + agents: AgentType[]; target: string; }; @@ -74,17 +74,25 @@ export const mappedPluginsTargets = ( scope: PluginsInstallScope, ): PluginsMappedTarget[] => { const mapped: PluginsMappedTarget[] = []; - const seen = new Set(); + const byTarget = new Map(); for (const agent of agents) { const target = getPluginsTargetName(agent); - if (target === undefined || seen.has(target)) { + if (target === undefined) { continue; } if (scope === "project" && isUserScopeOnlyPluginsTarget(target)) { continue; } - seen.add(target); - mapped.push({ agent, target }); + const existing = byTarget.get(target); + if (existing !== undefined) { + if (!existing.agents.includes(agent)) { + existing.agents.push(agent); + } + continue; + } + const row: PluginsMappedTarget = { agents: [agent], target }; + byTarget.set(target, row); + mapped.push(row); } if (mapped.length === 0) { throw new Error( diff --git a/packages/cli/src/plugins/wizard.test.ts b/packages/cli/src/plugins/wizard.test.ts index 348439b2..9741e2c9 100644 --- a/packages/cli/src/plugins/wizard.test.ts +++ b/packages/cli/src/plugins/wizard.test.ts @@ -10,7 +10,7 @@ describe("pluginsInstallSummary", () => { agents: ["cursor"], }), ).toBe( - ["Config project", "Agents Cursor", "Plugin neon-postgres"].join( + ["Scope project", "Agents Cursor", "Plugin neon-postgres"].join( "\n", ), ); diff --git a/packages/cli/src/plugins/wizard.ts b/packages/cli/src/plugins/wizard.ts index 64420f67..846bc1d3 100644 --- a/packages/cli/src/plugins/wizard.ts +++ b/packages/cli/src/plugins/wizard.ts @@ -19,7 +19,7 @@ export const pluginsInstallSummary = (options: { agents: readonly AgentType[]; }): string => { const rows: [string, string][] = [ - ["Config", options.scope === "project" ? "project" : "user"], + ["Scope", options.scope === "project" ? "project" : "user"], ["Agents", options.agents.map(getAgentDisplayName).join(", ")], ["Plugin", NEON_PLUGIN_NAME], ]; From 3079ddbc569e4d100dbf8a4215fd4194563d49fb Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 09:54:03 -0700 Subject: [PATCH 5/5] Reap detached npx on Ctrl-C and make the timeout test hold the pipe detached disables execa's parent-exit cleanup, so SIGINT and SIGTERM kill the process group before the CLI exits. The timeout test's grandchild now inherits stdio, which is the hang that used to outlive execa's timeout. --- packages/cli/src/plugins/run.test.ts | 4 ++-- packages/cli/src/plugins/run.ts | 29 +++++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/plugins/run.test.ts b/packages/cli/src/plugins/run.test.ts index 2768c9d2..3511fc42 100644 --- a/packages/cli/src/plugins/run.test.ts +++ b/packages/cli/src/plugins/run.test.ts @@ -169,7 +169,7 @@ describe("runPluginsCli timeout", () => { const { spawn } = require("node:child_process"); process.stdout.write("plugins banner\\n"); spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - stdio: "ignore", + stdio: "inherit", }); setInterval(() => {}, 1000); `, @@ -196,7 +196,7 @@ setInterval(() => {}, 1000); PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, }, }), - ).rejects.toThrow(/timed out after 1 second/); + ).rejects.toThrow(/timed out after 1 second[\s\S]*plugins banner/); expect(Date.now() - started).toBeLessThan(4000); }); }); diff --git a/packages/cli/src/plugins/run.ts b/packages/cli/src/plugins/run.ts index 7b3427b9..cca796ab 100644 --- a/packages/cli/src/plugins/run.ts +++ b/packages/cli/src/plugins/run.ts @@ -86,15 +86,35 @@ export const runPluginsCli = async (options: { stdio: "pipe", // npx leaves the plugins CLI as a grandchild. execa's timeout signals // npx and then waits on the pipe, so a hang in the grandchild never - // settles. A process-group kill reaps both. + // settles. A process-group kill reaps both. Detached disables execa's + // parent-exit cleanup, so SIGINT/SIGTERM have to kill the tree too. detached: true, }); let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; + let stopped = false; + const stop = (): void => { + if (stopped) { + return; + } + stopped = true; if (subprocess.pid !== undefined) { killProcessTree(subprocess.pid); } + }; + const onSigint = (): void => { + stop(); + process.exit(130); + }; + const onSigterm = (): void => { + stop(); + process.exit(143); + }; + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + process.once("exit", stop); + const timer = setTimeout(() => { + timedOut = true; + stop(); }, timeoutMs); try { const result = await subprocess; @@ -120,6 +140,9 @@ export const runPluginsCli = async (options: { throw error; } finally { clearTimeout(timer); + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + process.removeListener("exit", stop); } };