Pin ACP catalog entries to current versions

This commit is contained in:
Mohamed Boudra
2026-06-03 14:08:56 +07:00
parent 5aecd36194
commit 6f6c939624
5 changed files with 510 additions and 35 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 ?? "<none>"} ` +
`(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(" ") ?? "<no command>"}`);
}
}
}
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;
}