@acpjs/registry
v0.1.5
Published
acpjs registry index fetch/cache, AgentDefinition resolution, and ensureInstalled.
Readme
@acpjs/registry
Registry index fetch/cache, AgentDefinition resolution, and the ensureInstalled install flow for acpjs. Node-only, ESM-only, requires node >= 24.
The default data source is the official ACP registry CDN: https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json. /latest/ is the only published path — there is no versioned CDN fallback point.
Installation
pnpm add @acpjs/registry@acpjs/protocol is the only runtime dependency (it provides the host event shapes used by subscribe).
Quick start
Resolve an agent, install it if necessary, and hand the resulting AgentDefinition to @acpjs/core:
import { createRegistryClient } from '@acpjs/registry'
const registry = createRegistryClient()
// Subscribe before resolving so you observe the full install lifecycle.
const unsubscribe = registry.subscribe((event) => {
if (event.type === 'install-progress') {
const { stage } = event.payload
if (stage === 'downloading' && 'downloadedBytes' in event.payload) {
const { downloadedBytes, totalBytes } = event.payload
console.log(`downloading ${downloadedBytes}/${totalBytes ?? '?'} bytes`)
} else {
console.log(`stage: ${stage}`)
}
}
})
const definition = await registry.ensureInstalled('claude-acp')
unsubscribe()
// definition: { id, command, args, env?, cwd?, meta? }
// Structurally identical to a hand-written AgentDefinition; pass it straight
// to @acpjs/core, e.g. host.spawnAgent(definition).Public API
createRegistryClient(options?: RegistryClientOptions): RegistryClient
Returns a client with the following methods:
getIndex(): Promise<RegistryIndex>— fetch (or serve from cache) the index and return{ version?, entries }, whereentriesis the list of parsedRegistryEntryobjects.getEntry(agentId): Promise<RegistryEntry | undefined>— find a single parsed entry by its registryid.ensureInstalled(agentId, options?): Promise<AgentDefinition>— four-tier resolution plus install when required (see below).getInstallArtifact(agentId): Promise<InstallArtifact | undefined>— read the recorded install artifact for the current platform/version:{ agentId, version, platform, executablePath, installedAt }. Returnsundefinedwhen no artifact exists.subscribe(listener): () => void— subscribe toinstall-progressanddiagnosticevents. These are the@acpjs/protocolhost event shapes and share one monotonically increasing hostseq. Returns an unsubscribe function.
RegistryClientOptions
Every boundary is injectable, which makes the client fully testable with no real network access:
| Option | Type | Default |
| ------------ | -------------------------------------------------------- | ---------------------------------------------- |
| fetch | (url: string) => Promise<Response> | globalThis.fetch |
| cacheDir | string | platform cache dir (see below) |
| indexUrl | string | DEFAULT_INDEX_URL |
| indexTtlMs | number | DEFAULT_INDEX_TTL_MS (3,600,000 ms / 1 hour) |
| now | () => number | Date.now |
| platform | string | process.platform |
| arch | string | process.arch |
| pathProbe | (candidates: string[]) => Promise<string \| undefined> | PATH executable probe |
Exported types
RegistryClient, RegistryClientOptions, EnsureInstalledOptions, RegistryEvent, RegistryEventListener, FetchLike, PathProbe, RegistryIndex, RegistryEntry, RegistryDistribution, PackageDistribution, BinaryTarget, PlatformKey, AgentDefinition, InstallArtifact, RegistryError, RegistryErrorCode. Plus the constants DEFAULT_INDEX_URL and DEFAULT_INDEX_TTL_MS.
A RegistryEntry has the shape { id, name, version, description, distribution, authors?, license?, icon?, repository?, website? }. A distribution may carry any combination of three forms: npx, uvx, and binary (a partial map keyed by PlatformKey).
Index fetch and cache
- The index is cached on disk at
<cacheDir>/registry-index.json. Within the TTL (default 1 hour) it is not re-fetched, and the cache is shared across client instances and processes. - On network failure (a thrown error or a non-2xx response): if a cache exists, the stale cache is served and a
diagnosticis emitted (warn, coderegistry/index-stale-fallback); if no cache exists, aRegistryError('registry/index-unavailable')is thrown. - Unparseable index entries are skipped one by one with a
diagnostic(warn, coderegistry/entry-invalid,data.idset to the entry id when present). A single bad entry never fails the whole index. Unknown top-level fields outside the schema (both on the index and within entries) are tolerated and ignored. - The index body must be an object containing an
agentsarray, otherwiseRegistryError('registry/index-invalid')is thrown.
ensureInstalled four-tier resolution
ensureInstalled resolves an agent in strict priority order and returns an AgentDefinition:
- Explicit command —
ensureInstalled(id, { command, args?, env? })produces anAgentDefinitiondirectly. No network, no index read, nometa. - Executable already on PATH — if the PATH probe finds a candidate, it is used directly.
args/envcome from the matching distribution form (see "PATH probe" below). - Package-manager run — an
npx/uvxdistribution producescommand: 'npx' | 'uvx'withargs: [package, ...args]andenvpassed through. - Binary download/install — map
process.platform/process.archto one of six platform keys, then download → extract into a versioned cache directory →chmod 755→ writeartifact.json. The target's owncmd(a post-extraction relative command, which may be a nested sub-path),args, andenvflow into theAgentDefinition.
Install state machine
resolving → (cache-hit → installed) or resolving → downloading → extracting → installed. Any failed step transitions to failed (the payload carries reason). Tiers 1–3 perform no install and emit only resolving → installed. On failure the entire install directory is removed so no half-written artifacts remain. A repeat call for the same (agentId, version, platform) hits the cache and skips the download (idempotent).
Archive formats
- Extracted:
.tar.gz,.tgz,.tar.bz2,.tbz2,.zip. - Any other suffix (for example
.exe) is treated as a raw binary and written straight to disk — there is noextractingstage for raw binaries. - Installer formats
.dmg,.pkg,.deb,.rpmare rejected withRegistryError('registry/unsupported-archive')before any download occurs.
Diagnostics and error codes
| Channel | Code | Meaning |
| ------------------- | ------------------------------- | ------------------------------------------------------------------------------- |
| diagnostic (warn) | registry/index-stale-fallback | Network failure; falling back to the stale cached index |
| diagnostic (warn) | registry/entry-invalid | Skipped an unparseable index entry |
| RegistryError | registry/index-unavailable | Network failure and no cache available |
| RegistryError | registry/index-invalid | Index body is not an object with an agents array |
| RegistryError | registry/agent-not-found | No entry with the given agentId in the index |
| RegistryError | registry/no-distribution | Entry has no usable distribution form |
| RegistryError | registry/platform-unsupported | No binary target for the current platform, or the platform key cannot be mapped |
| RegistryError | registry/unsupported-archive | Installer format (.dmg/.pkg/.deb/.rpm) |
| RegistryError | registry/download-failed | Download threw or returned a non-2xx status |
| RegistryError | registry/install-failed | Post-extraction error, e.g. cmd not found in the archive |
Every RegistryError carries a code (RegistryErrorCode) you can branch on.
Known constraints
- No checksum or signature verification. The current registry index carries no integrity values, so download integrity cannot be verified at the registry layer and relies entirely on TLS and the official CDN. The
verifyingstage of the install state machine is skipped and emits no event. If the index begins to publish integrity values this must be implemented. - Installer archive formats are not supported (
.dmg,.pkg,.deb,.rpm); they are rejected before download. - Windows binaries are written and
chmod'd like any other platform; execution semantics depend on the publishedcmd. - Node-only and ESM-only. Requires
node >= 24; usesnode:child_process(tar),node:fs,node:zlib, and the globalfetch.
Implementation-defined decisions
The ACP spec leaves several points implementation-defined. This package resolves them as follows:
- Index TTL — defaults to 3,600,000 ms (1 hour); override via
indexTtlMs. - Cache directory — defaults to an
acpjsnamespace under the platform cache convention: darwin~/Library/Caches/acpjs, linux$XDG_CACHE_HOME/acpjs(falling back to~/.cache/acpjs), windows%LOCALAPPDATA%\acpjs\Cache(falling back to~/AppData/Local/acpjs/Cache). Override viacacheDir. Install artifacts are isolated peragents/<agentId>/<version>/<platformKey>/; archive contents extract intocontents/and metadata is written toartifact.json. - Platform keys — six keys:
{darwin|linux|windows}-{aarch64|x86_64}. Mapping:win32 → windows,arm64 → aarch64,x64 → x86_64. Any otherplatform/archis unmappable and yieldsregistry/platform-unsupported. - PATH probe — candidate names are the basename of the current platform's binary
cmdand the entryid(deduplicated, in that order). The default probe walks each directory inPATHchecking for execute permission (on windows it also tries.exe/.cmdsuffixes). Override viapathProbe. On a hit,args/envcome from the current platform's binary target, or — when there is no binary target — from thenpx/uvxform. - Verifying (skipped) — see "Known constraints"; no integrity values exist in the index, so the stage is skipped.
npx/uvxprecedence — when a distribution contains both,npxis preferred. No extra flags (such as-y) are injected;argsis exactly[package, ...dist.args].- Tar extraction — performed via the system
tar -xf(bundled with macOS, Linux, and Windows 10+; gz/bz2 compression is auto-detected). Zip uses a built-in pure-JS reader (store/deflate, compression methods 0 and 8 only). Both zip entry paths andcmdresolution are guarded against directory traversal escapes. metapass-through — registry-sourcedAgentDefinitions carrymeta: { name, version, registryId, icon? }.- Download granularity — the
downloadingstage streamsresponse.bodyand emits{ stage: 'downloading', downloadedBytes, totalBytes? }after each chunk (downloadedBytesis monotonically increasing;totalBytesis taken from the responsecontent-lengthand omitted when absent, whiledownloadedBytesis still reported per chunk). Whenresponse.bodyis not streamable, it falls back to a single non-chunked read (nodownloadedBytes; only the stage marker is emitted). Empty chunks are skipped. - Subscriber isolation — a listener that throws is caught and ignored; dispatch to the remaining subscribers continues.
