From 6f6c939624573d5a548c67300dae35800fd236c6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 14:08:56 +0700 Subject: [PATCH] Pin ACP catalog entries to current versions --- docs/development.md | 17 + docs/release.md | 3 + package.json | 3 + packages/app/src/data/acp-provider-catalog.ts | 70 +-- scripts/check-acp-catalog-version-drift.mjs | 452 ++++++++++++++++++ 5 files changed, 510 insertions(+), 35 deletions(-) create mode 100644 scripts/check-acp-catalog-version-drift.mjs diff --git a/docs/development.md b/docs/development.md index 982d80996..c9a646ff9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -204,6 +204,23 @@ For tighter loops, you can rebuild a single workspace: - Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`. - Changed app build dependencies: `npm run build:app-deps`. +## ACP provider catalog versions + +The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`, +and `uvx`) to exact package versions. Run the drift checker regularly — and +before releases — so catalog installs do not sit on stale agent versions: + +```bash +npm run acp:version-drift # report stale/non-exact package pins +npm run acp:version-drift:check # same, exits non-zero on drift +npm run acp:version-drift:update # rewrite catalog pins to latest exact versions +``` + +The checker updates only package-runner catalog entries. Providers that use a +preinstalled binary such as `opencode acp`, `cursor-agent acp`, or `goose acp` +are reported as skipped because their versions are owned by the user's local +install. + ## CLI reference Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing. diff --git a/docs/release.md b/docs/release.md index 0bd6f09ef..df66d5c7d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -9,6 +9,9 @@ A release has exactly two steps. The agent does the first, the user authorizes t **Preparation** (local, reversible — agent does this): - format, lint, typecheck all green +- ACP provider catalog drift checked with `npm run acp:version-drift:check`; + if stale package-runner pins are intentional, say so explicitly, otherwise run + `npm run acp:version-drift:update` and commit the updated catalog - draft the changelog, show it to the user, wait for review - run the pre-release sanity check, surface findings to the user - confirm CI is green diff --git a/package.json b/package.json index cc860e355..72ecd62eb 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,9 @@ "lint": "oxlint", "lint:fix": "oxlint --fix", "knip": "knip", + "acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs", + "acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift", + "acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update", "start": "npm run start --workspace=@getpaseo/server", "android": "npm run android --workspace=@getpaseo/app", "android:development": "npm run android:development --workspace=@getpaseo/app", diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index c9bcf3a0d..47e1a90d7 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -17,10 +17,10 @@ const CATALOG_DATA = [ title: "Agoragentic", description: "Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.", - version: "1.3.0", + version: "1.3.2", iconId: "agoragentic-acp", installLink: "https://agoragentic.com", - command: ["npx", "-y", "agoragentic-mcp@1.3.0", "--acp"], + command: ["npx", "-y", "agoragentic-mcp@1.3.2", "--acp"], }, { id: "amp-acp", @@ -36,10 +36,10 @@ const CATALOG_DATA = [ title: "Auggie CLI", description: "Augment Code's powerful software agent, backed by industry-leading context engine", - version: "0.26.0", + version: "0.28.0", iconId: "auggie", installLink: "https://www.augmentcode.com/", - command: ["npx", "-y", "@augmentcode/auggie@0.26.0", "--acp"], + command: ["npx", "-y", "@augmentcode/auggie@0.28.0", "--acp"], env: { AUGMENT_DISABLE_AUTO_UPDATE: "1", }, @@ -57,29 +57,29 @@ const CATALOG_DATA = [ id: "claude-acp", title: "Claude Agent", description: "ACP wrapper for Anthropic's Claude", - version: "0.32.0", + version: "0.40.0", iconId: "claude-acp", installLink: "https://github.com/agentclientprotocol/claude-agent-acp", - command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.32.0"], + command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.40.0"], }, { id: "cline", title: "Cline", description: "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", - version: "3", + version: "3.0.15", iconId: "cline", installLink: "https://cline.bot/cli", - command: ["npx", "-y", "cline@3", "--acp"], + command: ["npx", "-y", "cline@3.0.15", "--acp"], }, { id: "codebuddy-code", title: "Codebuddy Code", description: "Tencent Cloud's official intelligent coding tool", - version: "2.95.0", + version: "2.101.0", iconId: "codebuddy-code", installLink: "https://www.codebuddy.cn/cli/", - command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.95.0", "--acp"], + command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.101.0", "--acp"], }, { id: "codex-acp", @@ -130,10 +130,10 @@ const CATALOG_DATA = [ id: "deepagents", title: "DeepAgents", description: "Batteries-included AI coding and general purpose agent powered by LangChain.", - version: "0.1.7", + version: "0.1.12", iconId: "deepagents", installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview", - command: ["npx", "-y", "deepagents-acp@0.1.7"], + command: ["npx", "-y", "deepagents-acp@0.1.12"], }, { id: "deepseek-tui", @@ -157,29 +157,29 @@ const CATALOG_DATA = [ id: "dimcode", title: "DimCode", description: "A coding agent that puts leading models at your command.", - version: "0.0.66", + version: "0.0.76", iconId: "dimcode", installLink: "https://dimcode.dev/docs/acp.html", - command: ["npx", "-y", "dimcode@0.0.66", "acp"], + command: ["npx", "-y", "dimcode@0.0.76", "acp"], }, { id: "dirac", title: "Dirac", description: "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", - version: "0.3.36", + version: "0.3.44", iconId: "dirac", installLink: "https://dirac.run", - command: ["npx", "-y", "dirac-cli@0.3.36", "--acp"], + command: ["npx", "-y", "dirac-cli@0.3.44", "--acp"], }, { id: "factory-droid", title: "Factory Droid", description: "Factory Droid - AI coding agent powered by Factory AI", - version: "0.119.0", + version: "0.139.0", iconId: "factory-droid", installLink: "https://factory.ai/product/cli", - command: ["npx", "-y", "droid@0.119.0", "exec", "--output-format", "acp-daemon"], + command: ["npx", "-y", "droid@0.139.0", "exec", "--output-format", "acp-daemon"], env: { DROID_DISABLE_AUTO_UPDATE: "true", FACTORY_DROID_AUTO_UPDATE_ENABLED: "false", @@ -189,38 +189,38 @@ const CATALOG_DATA = [ id: "fast-agent", title: "fast-agent", description: "Code and build agents with comprehensive multi-provider support", - version: "0.7.0", + version: "0.7.15", iconId: "fast-agent", installLink: "https://fast-agent.ai/acp/", - command: ["uvx", "fast-agent-acp@latest", "-x"], + command: ["uvx", "--from", "fast-agent-acp==0.7.15", "fast-agent-acp", "-x"], }, { id: "gemini", title: "Gemini CLI", description: "Google's official CLI for Gemini", - version: "0.41.1", + version: "0.45.0", iconId: "gemini", installLink: "https://geminicli.com", - command: ["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"], + command: ["npx", "-y", "@google/gemini-cli@0.45.0", "--acp"], }, { id: "github-copilot-cli", title: "GitHub Copilot", description: "GitHub's AI pair programmer", - version: "1.0.41", + version: "1.0.59", iconId: "github-copilot-cli", installLink: "https://github.com/features/copilot/cli/", - command: ["npx", "-y", "@github/copilot@1.0.41", "--acp"], + command: ["npx", "-y", "@github/copilot@1.0.59", "--acp"], }, { id: "glm-acp-agent", title: "GLM Agent", description: "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", - version: "1.1.2", + version: "1.1.4", iconId: "glm-acp-agent", installLink: "https://github.com/stefandevo/glm-acp-agent", - command: ["npx", "-y", "glm-acp-agent@1.1.2"], + command: ["npx", "-y", "glm-acp-agent@1.1.4"], }, { id: "goose", @@ -294,7 +294,7 @@ const CATALOG_DATA = [ version: "0.1.44", iconId: "minion-code", installLink: "https://github.com/femto/minion-code", - command: ["uvx", "minion-code@latest", "acp"], + command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"], }, { id: "mistral-vibe", @@ -309,10 +309,10 @@ const CATALOG_DATA = [ id: "nova", title: "Nova", description: "Nova by Compass AI - a fully-fledged software engineer at your command", - version: "1.1.4", + version: "1.1.14", iconId: "nova", installLink: "https://www.compassap.ai/portfolio/nova.html", - command: ["npx", "-y", "@compass-ai/nova@1.1.4", "acp"], + command: ["npx", "-y", "@compass-ai/nova@1.1.14", "acp"], }, { id: "opencode", @@ -327,10 +327,10 @@ const CATALOG_DATA = [ id: "pi-acp", title: "pi ACP", description: "ACP adapter for pi coding agent", - version: "0.0.26", + version: "0.0.27", iconId: "pi-acp", installLink: "https://github.com/svkozak/pi-acp", - command: ["npx", "-y", "pi-acp@0.0.26"], + command: ["npx", "-y", "pi-acp@0.0.27"], }, { id: "poolside", @@ -345,19 +345,19 @@ const CATALOG_DATA = [ id: "qoder", title: "Qoder CLI", description: "AI coding assistant with agentic capabilities", - version: "0.2.6", + version: "1.0.12", iconId: "qoder", installLink: "https://qoder.com", - command: ["npx", "-y", "@qoder-ai/qodercli@0.2.6", "--acp"], + command: ["npx", "-y", "@qoder-ai/qodercli@1.0.12", "--acp"], }, { id: "qwen-code", title: "Qwen Code", description: "Alibaba's Qwen coding assistant", - version: "0.15.6", + version: "0.17.0", iconId: "qwen-code", installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview", - command: ["npx", "-y", "@qwen-code/qwen-code@0.15.6", "--acp", "--experimental-skills"], + command: ["npx", "-y", "@qwen-code/qwen-code@0.17.0", "--acp", "--experimental-skills"], }, { id: "sigit", diff --git a/scripts/check-acp-catalog-version-drift.mjs b/scripts/check-acp-catalog-version-drift.mjs new file mode 100644 index 000000000..a6b31a955 --- /dev/null +++ b/scripts/check-acp-catalog-version-drift.mjs @@ -0,0 +1,452 @@ +#!/usr/bin/env node + +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const CATALOG_PATH = new URL("../packages/app/src/data/acp-provider-catalog.ts", import.meta.url); +const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+].*)?$/; +const HELP_TEXT = `Usage: npm run acp:version-drift [-- --json] [-- --fail-on-drift] [-- --no-network] [-- --update] + +Checks package-runner ACP catalog entries for exact latest registry pins. + +By default this is report-only. Add --fail-on-drift to exit non-zero when drift is found. +Add --update to rewrite the catalog package-runner entries to the latest exact versions.`; + +function parseArgs(argv) { + const options = { + json: false, + failOnDrift: false, + noNetwork: false, + update: false, + }; + + for (const arg of argv) { + if (arg === "--json") { + options.json = true; + } else if (arg === "--fail-on-drift") { + options.failOnDrift = true; + } else if (arg === "--no-network") { + options.noNetwork = true; + } else if (arg === "--update") { + options.update = true; + } else if (arg === "--help" || arg === "-h") { + console.log(HELP_TEXT); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}\n\n${HELP_TEXT}`); + } + } + + if (options.update && options.noNetwork) { + throw new Error("--update requires registry access; remove --no-network"); + } + + return options; +} + +function getCatalogBodyRange(source) { + const startToken = "const CATALOG_DATA = ["; + const startIndex = source.indexOf(startToken); + if (startIndex === -1) { + throw new Error("Could not find CATALOG_DATA in ACP provider catalog"); + } + + const arrayStartIndex = source.indexOf("[", startIndex); + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = arrayStartIndex; index < source.length; index += 1) { + const char = source[index]; + + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + } else if (char === "[") { + depth += 1; + } else if (char === "]") { + depth -= 1; + if (depth === 0) { + return { start: arrayStartIndex + 1, end: index }; + } + } + } + + throw new Error("Could not parse CATALOG_DATA array"); +} + +function extractEntryBlocks(source) { + const range = getCatalogBodyRange(source); + const blocks = []; + let blockStart = null; + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = range.start; index < range.end; index += 1) { + const char = source[index]; + + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + } else if (char === "{") { + if (depth === 0) { + blockStart = index; + } + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0 && blockStart !== null) { + blocks.push({ + start: blockStart, + end: index + 1, + block: source.slice(blockStart, index + 1), + }); + blockStart = null; + } + } + } + + return blocks; +} + +function getStringField(block, fieldName) { + const match = new RegExp(`${fieldName}:\\s*"([^"]+)"`).exec(block); + return match?.[1] ?? null; +} + +function getCommandField(block) { + const match = /command:\s*(\[[^\]]+\])/.exec(block); + if (!match?.[1]) { + return null; + } + return JSON.parse(match[1]); +} + +function parseCatalogEntries(source) { + return extractEntryBlocks(source).map((entryBlock) => ({ + id: getStringField(entryBlock.block, "id"), + title: getStringField(entryBlock.block, "title"), + version: getStringField(entryBlock.block, "version"), + command: getCommandField(entryBlock.block), + start: entryBlock.start, + end: entryBlock.end, + })); +} + +function findNpmPackageSpec(command) { + const argsStartIndex = command[0] === "npm" && command[1] === "exec" ? 2 : 1; + for (let index = argsStartIndex; index < command.length; index += 1) { + const arg = command[index]; + if (arg === "--") { + continue; + } + if (arg.startsWith("-")) { + continue; + } + return { registry: "npm", spec: arg, index }; + } + return null; +} + +function findUvxPackageSpec(command) { + const fromIndex = command.indexOf("--from"); + if (fromIndex !== -1 && command[fromIndex + 1]) { + return { registry: "pypi", spec: command[fromIndex + 1], index: fromIndex + 1, usesFrom: true }; + } + + for (let index = 1; index < command.length; index += 1) { + const arg = command[index]; + if (arg.startsWith("-")) { + continue; + } + return { registry: "pypi", spec: arg, index, usesFrom: false }; + } + + return null; +} + +function findPackageRunnerSpec(command) { + if (!Array.isArray(command) || command.length === 0) { + return null; + } + + if (command[0] === "npx" || command[0] === "npm") { + return findNpmPackageSpec(command); + } + if (command[0] === "uvx") { + return findUvxPackageSpec(command); + } + return null; +} + +function parsePackageSpec(spec) { + if (!spec) { + return null; + } + + const pythonRequirementMatch = /^([A-Za-z0-9_.-]+)(==.+|>=.+)$/.exec(spec); + if (pythonRequirementMatch?.[1] && pythonRequirementMatch[2]) { + return { + packageName: pythonRequirementMatch[1], + selector: pythonRequirementMatch[2], + }; + } + + if (spec.startsWith("@")) { + const versionSeparatorIndex = spec.indexOf("@", 1); + if (versionSeparatorIndex === -1) { + return { packageName: spec, selector: null }; + } + return { + packageName: spec.slice(0, versionSeparatorIndex), + selector: spec.slice(versionSeparatorIndex + 1), + }; + } + + const versionSeparatorIndex = spec.lastIndexOf("@"); + if (versionSeparatorIndex === -1) { + return { packageName: spec, selector: null }; + } + return { + packageName: spec.slice(0, versionSeparatorIndex), + selector: spec.slice(versionSeparatorIndex + 1), + }; +} + +function getPinnedVersion(selector) { + if (!selector) { + return null; + } + if (EXACT_VERSION_PATTERN.test(selector)) { + return selector; + } + if (selector.startsWith("==") && EXACT_VERSION_PATTERN.test(selector.slice(2))) { + return selector.slice(2); + } + return null; +} + +async function getLatestNpmVersion(packageName) { + const { stdout } = await execFileAsync("npm", ["view", packageName, "version"], { + encoding: "utf8", + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + return stdout.trim(); +} + +async function getLatestPypiVersion(packageName) { + const response = await fetch(`https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`, { + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new Error(`PyPI responded ${response.status}`); + } + const metadata = await response.json(); + if (!metadata?.info?.version || typeof metadata.info.version !== "string") { + throw new Error("PyPI response did not include info.version"); + } + return metadata.info.version; +} + +async function getLatestVersion(registry, packageName) { + if (registry === "npm") { + return getLatestNpmVersion(packageName); + } + if (registry === "pypi") { + return getLatestPypiVersion(packageName); + } + throw new Error(`Unsupported package registry: ${registry}`); +} + +function buildStatus({ selector, catalogVersion, latestVersion, registryError }) { + const pinnedVersion = getPinnedVersion(selector); + const reasons = []; + + if (registryError) { + reasons.push("registry version lookup failed"); + } + if (!selector) { + reasons.push("missing package version selector"); + } else if (!pinnedVersion) { + reasons.push("command selector is not an exact version pin"); + } + + if (latestVersion) { + if (pinnedVersion && pinnedVersion !== latestVersion) { + reasons.push("command selector is not latest registry version"); + } + if (catalogVersion !== latestVersion) { + reasons.push("catalog version is not latest registry version"); + } + } + + return reasons; +} + +function buildUpdatedPackageSpec(spec, packageName, latestVersion) { + if (spec.registry === "pypi" && spec.usesFrom) { + return `${packageName}==${latestVersion}`; + } + return `${packageName}@${latestVersion}`; +} + +function buildUpdatedCommand(command, spec, parsedSpec, latestVersion) { + const updatedCommand = [...command]; + updatedCommand[spec.index] = buildUpdatedPackageSpec(spec, parsedSpec.packageName, latestVersion); + return updatedCommand; +} + +async function inspectEntry(entry, options) { + const spec = findPackageRunnerSpec(entry.command); + const parsedSpec = parsePackageSpec(spec?.spec); + + if (!spec || !parsedSpec) { + return { + id: entry.id, + title: entry.title, + command: entry.command, + checked: false, + status: "skipped", + reasons: ["not a supported package-runner command"], + }; + } + + let latestVersion = null; + let registryError = null; + if (!options.noNetwork) { + try { + latestVersion = await getLatestVersion(spec.registry, parsedSpec.packageName); + } catch (error) { + registryError = error instanceof Error ? error.message : String(error); + } + } + + const reasons = buildStatus({ + selector: parsedSpec.selector, + catalogVersion: entry.version, + latestVersion, + registryError, + }); + + return { + id: entry.id, + title: entry.title, + registry: spec.registry, + packageName: parsedSpec.packageName, + selector: parsedSpec.selector, + catalogVersion: entry.version, + latestVersion, + currentCommand: entry.command, + updatedCommand: latestVersion + ? buildUpdatedCommand(entry.command, spec, parsedSpec, latestVersion) + : null, + checked: true, + status: reasons.length === 0 ? "ok" : "drift", + reasons, + registryError, + start: entry.start, + end: entry.end, + }; +} + +function serializeCommand(command) { + return `[${command.map((arg) => JSON.stringify(arg)).join(", ")}]`; +} + +function applyUpdates(source, results) { + let updatedSource = source; + const updates = results + .filter((result) => result.checked && result.latestVersion && result.updatedCommand) + .sort((left, right) => right.start - left.start); + + for (const result of updates) { + const block = updatedSource.slice(result.start, result.end); + const updatedBlock = block + .replace(/version:\s*"[^"]+"/, `version: "${result.latestVersion}"`) + .replace(/command:\s*\[[^\]]+\]/, `command: ${serializeCommand(result.updatedCommand)}`); + updatedSource = `${updatedSource.slice(0, result.start)}${updatedBlock}${updatedSource.slice(result.end)}`; + } + + return updatedSource; +} + +function printReport(results, options) { + const reportResults = results.map(({ start: _start, end: _end, ...result }) => result); + if (options.json) { + console.log(JSON.stringify(reportResults, null, 2)); + return; + } + + const checked = reportResults.filter((result) => result.checked); + const drift = checked.filter((result) => result.status === "drift"); + const skipped = reportResults.filter((result) => !result.checked); + + console.log("ACP catalog version drift"); + console.log("========================="); + console.log(`checked: ${checked.length}`); + console.log(`drift: ${drift.length}`); + console.log(`skipped: ${skipped.length}`); + + if (drift.length > 0) { + console.log("\nDrift / stale pins:"); + for (const result of drift) { + const latest = result.latestVersion ?? (options.noNetwork ? "not checked" : "unknown"); + const recommended = result.updatedCommand?.join(" ") ?? "unavailable"; + console.log( + `- ${result.id}: ${result.registry}:${result.packageName}@${result.selector ?? ""} ` + + `(catalog ${result.catalogVersion ?? "n/a"}, latest ${latest}) -> ${recommended}`, + ); + for (const reason of result.reasons) { + console.log(` - ${reason}`); + } + } + } + + if (skipped.length > 0) { + console.log("\nSkipped non-package-runner commands:"); + for (const result of skipped) { + console.log(`- ${result.id}: ${result.command?.join(" ") ?? ""}`); + } + } +} + +const options = parseArgs(process.argv.slice(2)); +const source = await readFile(CATALOG_PATH, "utf8"); +const entries = parseCatalogEntries(source); +const results = await Promise.all(entries.map((entry) => inspectEntry(entry, options))); +const hasDrift = results.some((result) => result.checked && result.status === "drift"); + +if (options.update) { + await writeFile(CATALOG_PATH, applyUpdates(source, results)); +} + +printReport(results, options); + +if (options.failOnDrift && hasDrift) { + process.exitCode = 1; +}