From ab28ff11d5829b8190c845d774a71a425554d3e3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 03:15:12 +0000 Subject: [PATCH 1/4] feat(install): add the agent-bundle/install package-bound lifecycle entry (#724) --- .changeset/724-package-bound-install-entry.md | 5 + packages/agent-bundle/package.json | 4 + packages/agent-bundle/rslib.config.ts | 1 + packages/agent-bundle/src/cli.ts | 304 ++---------------- packages/agent-bundle/src/install/commands.ts | 171 ++++++++++ packages/agent-bundle/src/install/format.ts | 140 ++++++++ packages/agent-bundle/src/install/index.ts | 93 ++++++ .../agent-bundle/tests/install-cli.test.ts | 141 ++++++++ .../tests/packed-install-bin.test.ts | 180 +++++++++++ rstest.integration-tests.ts | 1 + .../en/guide/distribution/installation.mdx | 30 ++ website/docs/en/reference/api.mdx | 1 + .../zh/guide/distribution/installation.mdx | 26 ++ website/docs/zh/reference/api.mdx | 1 + website/rspress.config.ts | 1 + 15 files changed, 815 insertions(+), 284 deletions(-) create mode 100644 .changeset/724-package-bound-install-entry.md create mode 100644 packages/agent-bundle/src/install/commands.ts create mode 100644 packages/agent-bundle/src/install/index.ts create mode 100644 packages/agent-bundle/tests/install-cli.test.ts create mode 100644 packages/agent-bundle/tests/packed-install-bin.test.ts diff --git a/.changeset/724-package-bound-install-entry.md b/.changeset/724-package-bound-install-entry.md new file mode 100644 index 000000000..990e31f06 --- /dev/null +++ b/.changeset/724-package-bound-install-entry.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Add the `agent-bundle/install` entry so a published plugin can ship a package-bound installer bin without bundling the framework's installer source or parsing argv itself: `runInstallCli(argv, { from, name })` runs the `agent-bundle` CLI's own `install `, `uninstall `, and `doctor` commands — the same flags, receipts, replacement rules, `--plan`, data policy, `--json` output, and exit codes — with the bundle root pinned to the package that ships the bin (no `--from`, no artifact-root probing). The entry also exports `installBundle`, `uninstallBundle`, `runDoctor`, `formatInstallResult`, `formatUninstallResult`, the new `formatDoctorReport`, and their option and result types. The three commands are declared once and shared with the `agent-bundle` CLI. Proven from a packed consumer whose source and `node_modules` are deleted before its bin installs, replaces, plans, and uninstalls a Cursor copy (`packed-install-bin`). Fixes #724. (#728) diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 630c95912..4ec9a976d 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -73,6 +73,10 @@ "types": "./dist/meta.d.ts", "import": "./dist/meta.js" }, + "./install": { + "types": "./dist/install/index.d.ts", + "import": "./dist/install.js" + }, "./launch-env": { "types": "./dist/launch-env.d.ts", "import": "./dist/launch-env.js" diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 2d0d2ba8e..7bab509ce 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -77,6 +77,7 @@ const runtimeEntries = { 'cli-entry': './src/cli-entry.ts', 'event-ipc': './src/events/ipc.ts', 'event-project': './src/events/project.ts', + install: './src/install/index.ts', 'launch-env': './src/launch-env.ts', 'mcp-entry': './src/mcp-entry.ts', 'mcp-server-runtime': process.env['AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE'] === '1' diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index e16678df9..f536214df 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -31,26 +31,13 @@ import type { McpAppProfileId, ProjectOptions, } from './api.ts'; -import type { - installBundle, - InstallHost, - InstallMode, - InstallResult, - InstallScope, -} from './install/install.ts'; -import type { - DoctorDurableStateReport, - DoctorHost, - DoctorInstallComparison, - DoctorLifecycle, - DoctorReport, - runDoctor, -} from './install/doctor.ts'; -import type { uninstallBundle, UninstallResult } from './install/uninstall.ts'; +import type { installBundle, InstallHost } from './install/install.ts'; +import type { runDoctor } from './install/doctor.ts'; +import type { uninstallBundle } from './install/uninstall.ts'; import type { runHostMcpProxy } from './dev/host-mcp-proxy.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; import { errorMessage } from './core/errors.ts'; -import { formatInstallResult, formatUninstallResult } from './install/format.ts'; +import { collectInstallHost, registerLifecycleCommands } from './install/commands.ts'; import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; import { formatByteSize } from './core/strings.ts'; @@ -123,33 +110,6 @@ interface BuildCommandOptions extends SourceCommandOptions { readonly output?: string; } -interface InstallCommandOptions { - readonly force?: boolean; - readonly from: string; - readonly json?: boolean; - readonly replace?: boolean; - readonly mode?: InstallMode; - readonly scope: string; -} - -interface UninstallCommandOptions { - readonly confirmPurge?: boolean; - readonly force?: boolean; - readonly from: string; - readonly json?: boolean; - readonly keepData?: boolean; - readonly mode?: InstallMode; - readonly plan?: boolean; - readonly purgeData?: boolean; - readonly scope: string; -} - -interface DoctorCommandOptions { - readonly from?: string; - readonly host: readonly DoctorHost[]; - readonly json?: boolean; -} - interface EvalCommandOptions extends SourceCommandOptions { readonly artifact?: string; readonly case?: readonly string[]; @@ -233,24 +193,6 @@ const trialCount = (value: string): number => { return number; }; -const installHost = (value: string): InstallHost => { - if (value === 'claude' || value === 'codex' || value === 'cursor') return value; - throw new InvalidArgumentError('Install host must be claude, codex, or cursor.'); -}; - -const collectInstallHost = (value: string, previous: readonly InstallHost[]): readonly InstallHost[] => - [...previous, installHost(value)]; - -const installMode = (value: string): InstallMode => { - if (value === 'local' || value === 'marketplace') return value; - throw new InvalidArgumentError('Install mode must be local or marketplace.'); -}; - -const installScope = (value: string): InstallScope => { - if (value === 'user' || value === 'project' || value === 'local') return value; - throw new InvalidArgumentError('Install scope must be user, project, or local.'); -}; - const mcpAppProfile = (value: string): McpAppProfileId => { if (value === 'portable' || value === 'claude' || value === 'chatgpt') return value; throw new InvalidArgumentError('MCP App profile must be portable, claude, or chatgpt.'); @@ -264,14 +206,6 @@ const consentCapability = (value: string): ServeAppAllowCapability => { const collectConsentCapability = (value: string, previous: readonly ServeAppAllowCapability[]): readonly ServeAppAllowCapability[] => [...previous, consentCapability(value)]; -const doctorHost = (value: string): DoctorHost => { - if (value === 'claude' || value === 'codex' || value === 'cursor') return value; - throw new InvalidArgumentError('Doctor host must be claude, codex, or cursor.'); -}; - -const collectDoctorHost = (value: string, previous: readonly DoctorHost[]): readonly DoctorHost[] => - [...previous, doctorHost(value)]; - const configureSourceOptions = (command: Command): Command => command .option('--root ', 'Project root', process.cwd()) .option('--config ', 'Configuration file relative to --root') @@ -395,144 +329,6 @@ const humanPrepack = (result: Awaited>): string => [ `Prepack validated ${result.pack.files.length} file(s) for ${result.build.model.metadata.name}\n`, ].join(''); -const shortContentHash = (hash: string): string => hash.slice(0, 12); - -const humanInstall = (result: InstallResult): string => formatInstallResult(result); - -const humanUninstall = (result: UninstallResult): string => formatUninstallResult(result); - -const describeLifecycle = (lifecycle: DoctorLifecycle): string => { - const observations = (['placed', 'registered', 'enabled', 'active'] as const).map((stage) => { - const observation = lifecycle[stage]; - return observation.status === 'observed' - ? `${stage}=${observation.value ? 'yes' : 'no'}` - : `${stage}=unavailable`; - }); - return `${lifecycle.stage} (${observations.join(', ')})`; -}; - -const describeInstallComparison = (comparison: DoctorInstallComparison): string => { - const installed = (comparison.installedContentHash === undefined - ? '' - : `; installed ${comparison.installedVersion ?? 'unknown version'} ` + - `content ${shortContentHash(comparison.installedContentHash)}, ` + - `artifact content ${shortContentHash(comparison.artifactContentHash)}`) + - (comparison.enabled === false ? '; disabled by the host' : ''); - switch (comparison.status) { - case 'current': - return `current${installed}`; - case 'stale': - return `stale (same version, different content)${installed}`; - case 'version-mismatch': - return `version mismatch${installed}`; - case 'foreign': - return `foreign install${installed}`; - case 'load-failed': - return `load failed (installed ${comparison.installedVersion ?? 'unknown version'}, refused by the host: ` + - `${(comparison.errors ?? []).join(' | ')})`; - case 'not-installed': - return 'not installed'; - case 'unknown': - return 'unknown (host inventory unavailable)'; - default: { - const exhaustive: never = comparison.status; - throw new TypeError(`Unknown install comparison ${String(exhaustive)}.`); - } - } -}; - -const humanDoctor = (result: DoctorReport): string => { - const out: string[] = []; - for (const host of result.hosts) { - const detail = host.probe.version ?? host.probe.evidence; - out.push(`${host.host}: ${host.probe.status}${detail === undefined ? '' : ` (${detail})`}\n`); - out.push( - ` inventory: ${host.inventory.status}` + - `${host.inventory.status === 'known' ? ` (${host.inventory.findings.length} finding(s))` : ''}\n`, - ); - if (host.bundle !== undefined) { - const identity = host.bundle.name === undefined - ? '' - : ` ${host.bundle.name}${host.bundle.version === undefined ? '' : `@${host.bundle.version}`}`; - out.push(` bundle:${identity} ${host.bundle.state}\n`); - if (host.bundle.comparison !== undefined) { - out.push(` installed copy: ${describeInstallComparison(host.bundle.comparison)}\n`); - } - for (const validation of host.bundle.hostValidation ?? []) { - out.push( - ` host validation (${validation.copy} ${validation.pluginDirectory}` + - `${validation.scope === undefined ? '' : `, scope ${validation.scope}`}): ${validation.status}\n`, - ); - } - if (host.bundle.lifecycle !== undefined) { - out.push(` lifecycle: ${describeLifecycle(host.bundle.lifecycle)}\n`); - } - } - if (host.receipts.length > 0) { - out.push(` receipts: ${host.receipts.length} store receipt(s)\n`); - for (const receipt of host.receipts) { - out.push(` ${receipt.plugin}@${receipt.version} (${receipt.mode}, ${receipt.scope}): ${receipt.state}\n`); - } - } - const reports = [ - ...host.inventory.findings.flatMap((finding) => finding.durableStates ?? ( - finding.durableState === undefined ? [] : [finding.durableState] - )), - host.bundle?.durableState, - ].filter((report): report is DoctorDurableStateReport => report !== undefined); - const uniqueReports = [...new Map(reports.map((report) => [report.directory, report])).values()]; - for (const report of uniqueReports) { - out.push( - ` state root: ${report.directory} (${report.exists ? 'exists' : 'missing'}, ` + - `${report.writable ? 'writable' : 'not writable'}, ${report.stateSource}); ` + - `ownership: ${report.ownership}${report.ownershipReason === undefined ? '' : ` (${report.ownershipReason})`}, ` + - `${report.purgeable ? 'purgeable' : 'retained'}${ - report.servers.length === 0 ? '' : `, servers: ${report.servers.join(', ')}` - }\n`, - ); - } - const legacyReports = host.inventory.findings - .map((finding) => finding.legacyDurableState) - .filter((report): report is DoctorDurableStateReport => report !== undefined); - for (const report of [...new Map(legacyReports.map((entry) => [entry.directory, entry])).values()]) { - out.push(` legacy state: ${report.directory} (exists, ${report.writable ? 'writable' : 'not writable'})\n`); - } - if (uniqueReports.length > 0) { - const stores = uniqueReports.reduce((total, report) => total + report.summary.stores, 0); - const bytes = uniqueReports.reduce((total, report) => total + report.summary.bytes, 0); - out.push( - ` durable state: ${stores} ${stores === 1 ? 'store' : 'stores'}, ${formatByteSize(bytes)}\n`, - ); - } - // The operator `.env` layer (#469): present files and their variable counts, never a value. - const operatorEnvFiles = [ - ...host.inventory.findings.map((finding) => finding.operatorEnv), - host.bundle?.operatorEnv, - ].flatMap((report) => report?.files ?? []).filter((file) => file.state !== 'absent'); - const uniqueEnvFiles = [...new Map(operatorEnvFiles.map((file) => [file.path, file])).values()]; - if (uniqueEnvFiles.length > 0) { - out.push(` operator env: ${uniqueEnvFiles.map((file) => - `${file.path} (${file.state === 'present' ? `${String(file.variables ?? 0)} variable${file.variables === 1 ? '' : 's'}` : file.state})`).join(', ')}\n`); - } - } - if (result.web !== undefined) { - out.push(`${result.web.line}\n`); - } - out.push( - `runtime endpoints: ${result.endpoints.status}; ${result.endpoints.summary.live} live, ` + - `${result.endpoints.summary.staleSockets} stale socket(s), ` + - `${result.endpoints.summary.staleLocks} stale lock(s)\n`, - ); - for (const entry of result.diagnostics) { - out.push(`${entry.code}: ${entry.message}\nRecovery: ${entry.recovery}\n`); - } - out.push( - `Doctor summary: ${result.summary.errors} error(s), ${result.summary.warnings} warning(s), ` + - `${result.summary.infos} info(s)\n`, - ); - return out.join(''); -}; - const humanInspect = (result: Awaited>): string => { const out: string[] = []; if (result.state === 'invalid') { @@ -963,82 +759,22 @@ export const runCli = async ( await (options.json === true ? machine(result) : show(humanPrepack(result))); }); - const installCommand = program.command('install') - .description('Install a built bundle into a supported host') - .argument('', 'Destination host: claude, codex, or cursor', installHost) - .option('--from ', 'Target bundle directory or artifact root', process.cwd()) - .option('--scope ', 'Host install scope', installScope, 'user') - .option( - '--replace', - 'Replace an existing agent-bundle install of this plugin even when its version differs; ' + - 'same-version content drift is replaced automatically and foreign installs are always refused', - ) - .option('--force', 'Alias for --replace') - .option('--mode ', 'Cursor delivery mode: local (default) or marketplace', installMode) - .option('--json', 'Write one machine-readable JSON document'); - installCommand.action(async ( - host: InstallHost, - options: InstallCommandOptions, - ) => { - const install = dependencies.installBundle ?? (await import('./install/install.ts')).installBundle; - const result = await install({ - from: options.from, - host, - replace: options.replace === true || options.force === true, - ...(options.mode === undefined ? {} : { mode: options.mode }), - scope: installScope(options.scope), - }); - await (options.json === true ? machine(result) : show(humanInstall(result))); - }); - - const uninstallCommand = program.command('uninstall') - .description('Remove a receipt-owned host install of a built bundle, and nothing else') - .argument('', 'Host to uninstall from: claude, codex, or cursor', installHost) - .option('--from ', 'Target bundle directory or artifact root that identifies the plugin', process.cwd()) - .option('--scope ', 'Host install scope', installScope, 'user') - .option('--mode ', 'Cursor delivery mode to uninstall: local (default) or marketplace', installMode) - .option('--keep-data', 'Keep the plugin\'s durable runtime state (state/) in place; this is the default') - .option('--purge-data', 'Also remove the plugin\'s durable runtime state; requires --confirm-purge') - .option('--confirm-purge', 'Confirm that --purge-data may delete durable state') - .option( - '--force', - 'Proceed without an install receipt (legacy or host-only install) or when owned content no longer matches the receipt; ' + - 'foreign directories are still refused', - ) - .option('--plan', 'Print the exact paths and host registrations that would be removed without changing anything') - .option('--json', 'Write one machine-readable JSON document'); - uninstallCommand.action(async ( - host: InstallHost, - options: UninstallCommandOptions, - ) => { - const uninstall = dependencies.uninstallBundle ?? (await import('./install/uninstall.ts')).uninstallBundle; - const result = await uninstall({ - ...(options.confirmPurge === undefined ? {} : { confirmPurge: options.confirmPurge }), - ...(options.force === undefined ? {} : { force: options.force }), - from: options.from, - host, - ...(options.keepData === undefined ? {} : { keepData: options.keepData }), - ...(options.mode === undefined ? {} : { mode: options.mode }), - ...(options.plan === undefined ? {} : { plan: options.plan }), - ...(options.purgeData === undefined ? {} : { purgeData: options.purgeData }), - scope: installScope(options.scope), - }); - await (options.json === true ? machine(result) : show(humanUninstall(result))); - }); - - const doctorCommand = program.command('doctor') - .description('Inspect host installs and runtime endpoints without changing them') - .option('--host ', 'Host to inspect (repeatable)', collectDoctorHost, []) - .option('--from ', 'Target bundle directory or artifact root') - .option('--json', 'Write one machine-readable JSON document'); - doctorCommand.action(async (options: DoctorCommandOptions) => { - const doctor = dependencies.runDoctor ?? (await import('./install/doctor.ts')).runDoctor; - const result = await doctor({ - ...(options.from === undefined ? {} : { from: options.from }), - ...(options.host.length === 0 ? {} : { hosts: options.host }), - }); - await (options.json === true ? machine(result) : show(humanDoctor(result))); - if (result.diagnostics.some((entry) => entry.severity === 'error')) exitCode = 1; + registerLifecycleCommands(program, { + lifecycle: async () => { + const [install, uninstall, doctor] = await Promise.all([ + import('./install/install.ts'), + import('./install/uninstall.ts'), + import('./install/doctor.ts'), + ]); + return { + installBundle: dependencies.installBundle ?? install.installBundle, + runDoctor: dependencies.runDoctor ?? doctor.runDoctor, + uninstallBundle: dependencies.uninstallBundle ?? uninstall.uninstallBundle, + }; + }, + machine, + setExitCode: (code) => { exitCode = code; }, + show, }); const validateCommand = configureSourceOptions( diff --git a/packages/agent-bundle/src/install/commands.ts b/packages/agent-bundle/src/install/commands.ts new file mode 100644 index 000000000..999f00ffb --- /dev/null +++ b/packages/agent-bundle/src/install/commands.ts @@ -0,0 +1,171 @@ +import { Command, InvalidArgumentError } from 'commander'; + +import type { DoctorHost, runDoctor } from './doctor.ts'; +import { formatDoctorReport, formatInstallResult, formatUninstallResult } from './format.ts'; +import type { installBundle, InstallHost, InstallMode, InstallScope } from './install.ts'; +import type { uninstallBundle } from './uninstall.ts'; + +/** + * The `install`, `uninstall`, and `doctor` commands, declared once for the + * `agent-bundle` CLI and for package-bound installer bins + * (`agent-bundle/install`). The two differ only in where the bundle root + * comes from: the CLI takes `--from`, a bin pins its own package root. + */ +export interface LifecycleApi { + readonly installBundle: typeof installBundle; + readonly runDoctor: typeof runDoctor; + readonly uninstallBundle: typeof uninstallBundle; +} + +export interface LifecycleCommandOptions { + /** Pins the bundle root and hides `--from`: a package-bound bin binds the root its own package ships. */ + readonly from?: string; + /** Loads the lifecycle implementation when a command runs, so parsing `--help` never pays for it. */ + readonly lifecycle: () => Promise; + /** Writes one canonical JSON document (`--json`). */ + readonly machine: (result: unknown) => Promise; + /** Receives the exit code a command decides after writing its output. */ + readonly setExitCode: (code: number) => void; + /** Writes human-readable text. */ + readonly show: (text: string) => Promise; +} + +interface InstallCommandOptions { + readonly force?: boolean; + readonly from?: string; + readonly json?: boolean; + readonly replace?: boolean; + readonly mode?: InstallMode; + readonly scope: string; +} + +interface UninstallCommandOptions { + readonly confirmPurge?: boolean; + readonly force?: boolean; + readonly from?: string; + readonly json?: boolean; + readonly keepData?: boolean; + readonly mode?: InstallMode; + readonly plan?: boolean; + readonly purgeData?: boolean; + readonly scope: string; +} + +interface DoctorCommandOptions { + readonly from?: string; + readonly host: readonly DoctorHost[]; + readonly json?: boolean; +} + +export const installHost = (value: string): InstallHost => { + if (value === 'claude' || value === 'codex' || value === 'cursor') return value; + throw new InvalidArgumentError('Install host must be claude, codex, or cursor.'); +}; + +export const collectInstallHost = (value: string, previous: readonly InstallHost[]): readonly InstallHost[] => + [...previous, installHost(value)]; + +const installMode = (value: string): InstallMode => { + if (value === 'local' || value === 'marketplace') return value; + throw new InvalidArgumentError('Install mode must be local or marketplace.'); +}; + +const installScope = (value: string): InstallScope => { + if (value === 'user' || value === 'project' || value === 'local') return value; + throw new InvalidArgumentError('Install scope must be user, project, or local.'); +}; + +const doctorHost = (value: string): DoctorHost => { + if (value === 'claude' || value === 'codex' || value === 'cursor') return value; + throw new InvalidArgumentError('Doctor host must be claude, codex, or cursor.'); +}; + +const collectDoctorHost = (value: string, previous: readonly DoctorHost[]): readonly DoctorHost[] => + [...previous, doctorHost(value)]; + +export const registerLifecycleCommands = (program: Command, options: LifecycleCommandOptions): void => { + const { from: pinned, lifecycle, machine, setExitCode, show } = options; + const fromOption = (command: Command, help: string, defaultValue?: string): Command => + pinned === undefined ? command.option('--from ', help, defaultValue) : command; + + const installCommand = fromOption( + program.command('install') + .description('Install a built bundle into a supported host') + .argument('', 'Destination host: claude, codex, or cursor', installHost), + 'Target bundle directory or artifact root', + process.cwd(), + ) + .option('--scope ', 'Host install scope', installScope, 'user') + .option( + '--replace', + 'Replace an existing agent-bundle install of this plugin even when its version differs; ' + + 'same-version content drift is replaced automatically and foreign installs are always refused', + ) + .option('--force', 'Alias for --replace') + .option('--mode ', 'Cursor delivery mode: local (default) or marketplace', installMode) + .option('--json', 'Write one machine-readable JSON document'); + installCommand.action(async (host: InstallHost, commandOptions: InstallCommandOptions) => { + const { installBundle: install } = await lifecycle(); + const result = await install({ + from: pinned ?? commandOptions.from ?? process.cwd(), + host, + replace: commandOptions.replace === true || commandOptions.force === true, + ...(commandOptions.mode === undefined ? {} : { mode: commandOptions.mode }), + scope: installScope(commandOptions.scope), + }); + await (commandOptions.json === true ? machine(result) : show(formatInstallResult(result))); + }); + + const uninstallCommand = fromOption( + program.command('uninstall') + .description('Remove a receipt-owned host install of a built bundle, and nothing else') + .argument('', 'Host to uninstall from: claude, codex, or cursor', installHost), + 'Target bundle directory or artifact root that identifies the plugin', + process.cwd(), + ) + .option('--scope ', 'Host install scope', installScope, 'user') + .option('--mode ', 'Cursor delivery mode to uninstall: local (default) or marketplace', installMode) + .option('--keep-data', 'Keep the plugin\'s durable runtime state (state/) in place; this is the default') + .option('--purge-data', 'Also remove the plugin\'s durable runtime state; requires --confirm-purge') + .option('--confirm-purge', 'Confirm that --purge-data may delete durable state') + .option( + '--force', + 'Proceed without an install receipt (legacy or host-only install) or when owned content no longer matches the receipt; ' + + 'foreign directories are still refused', + ) + .option('--plan', 'Print the exact paths and host registrations that would be removed without changing anything') + .option('--json', 'Write one machine-readable JSON document'); + uninstallCommand.action(async (host: InstallHost, commandOptions: UninstallCommandOptions) => { + const { uninstallBundle: uninstall } = await lifecycle(); + const result = await uninstall({ + ...(commandOptions.confirmPurge === undefined ? {} : { confirmPurge: commandOptions.confirmPurge }), + ...(commandOptions.force === undefined ? {} : { force: commandOptions.force }), + from: pinned ?? commandOptions.from ?? process.cwd(), + host, + ...(commandOptions.keepData === undefined ? {} : { keepData: commandOptions.keepData }), + ...(commandOptions.mode === undefined ? {} : { mode: commandOptions.mode }), + ...(commandOptions.plan === undefined ? {} : { plan: commandOptions.plan }), + ...(commandOptions.purgeData === undefined ? {} : { purgeData: commandOptions.purgeData }), + scope: installScope(commandOptions.scope), + }); + await (commandOptions.json === true ? machine(result) : show(formatUninstallResult(result))); + }); + + const doctorCommand = fromOption( + program.command('doctor') + .description('Inspect host installs and runtime endpoints without changing them') + .option('--host ', 'Host to inspect (repeatable)', collectDoctorHost, []), + 'Target bundle directory or artifact root', + ) + .option('--json', 'Write one machine-readable JSON document'); + doctorCommand.action(async (commandOptions: DoctorCommandOptions) => { + const { runDoctor: doctor } = await lifecycle(); + const from = pinned ?? commandOptions.from; + const result = await doctor({ + ...(from === undefined ? {} : { from }), + ...(commandOptions.host.length === 0 ? {} : { hosts: commandOptions.host }), + }); + await (commandOptions.json === true ? machine(result) : show(formatDoctorReport(result))); + if (result.diagnostics.some((entry) => entry.severity === 'error')) setExitCode(1); + }); +}; diff --git a/packages/agent-bundle/src/install/format.ts b/packages/agent-bundle/src/install/format.ts index 8c9530034..2296d82fd 100644 --- a/packages/agent-bundle/src/install/format.ts +++ b/packages/agent-bundle/src/install/format.ts @@ -1,3 +1,10 @@ +import { formatByteSize } from '../core/strings.ts'; +import type { + DoctorDurableStateReport, + DoctorInstallComparison, + DoctorLifecycle, + DoctorReport, +} from './doctor.ts'; import type { InstallResult } from './install.ts'; import type { UninstallResult } from './uninstall.ts'; @@ -103,3 +110,136 @@ export const formatUninstallResult = (result: UninstallResult): string => { } return `${lines.join('\n')}\n`; }; + +const describeLifecycle = (lifecycle: DoctorLifecycle): string => { + const observations = (['placed', 'registered', 'enabled', 'active'] as const).map((stage) => { + const observation = lifecycle[stage]; + return observation.status === 'observed' + ? `${stage}=${observation.value ? 'yes' : 'no'}` + : `${stage}=unavailable`; + }); + return `${lifecycle.stage} (${observations.join(', ')})`; +}; + +const describeInstallComparison = (comparison: DoctorInstallComparison): string => { + const installed = (comparison.installedContentHash === undefined + ? '' + : `; installed ${comparison.installedVersion ?? 'unknown version'} ` + + `content ${shortContentHash(comparison.installedContentHash)}, ` + + `artifact content ${shortContentHash(comparison.artifactContentHash)}`) + + (comparison.enabled === false ? '; disabled by the host' : ''); + switch (comparison.status) { + case 'current': + return `current${installed}`; + case 'stale': + return `stale (same version, different content)${installed}`; + case 'version-mismatch': + return `version mismatch${installed}`; + case 'foreign': + return `foreign install${installed}`; + case 'load-failed': + return `load failed (installed ${comparison.installedVersion ?? 'unknown version'}, refused by the host: ` + + `${(comparison.errors ?? []).join(' | ')})`; + case 'not-installed': + return 'not installed'; + case 'unknown': + return 'unknown (host inventory unavailable)'; + default: { + const exhaustive: never = comparison.status; + throw new TypeError(`Unknown install comparison ${String(exhaustive)}.`); + } + } +}; + +/** Human-readable doctor report shared by the `agent-bundle` CLI and package-bound installer bins. */ +export const formatDoctorReport = (result: DoctorReport): string => { + const out: string[] = []; + for (const host of result.hosts) { + const detail = host.probe.version ?? host.probe.evidence; + out.push(`${host.host}: ${host.probe.status}${detail === undefined ? '' : ` (${detail})`}\n`); + out.push( + ` inventory: ${host.inventory.status}` + + `${host.inventory.status === 'known' ? ` (${host.inventory.findings.length} finding(s))` : ''}\n`, + ); + if (host.bundle !== undefined) { + const identity = host.bundle.name === undefined + ? '' + : ` ${host.bundle.name}${host.bundle.version === undefined ? '' : `@${host.bundle.version}`}`; + out.push(` bundle:${identity} ${host.bundle.state}\n`); + if (host.bundle.comparison !== undefined) { + out.push(` installed copy: ${describeInstallComparison(host.bundle.comparison)}\n`); + } + for (const validation of host.bundle.hostValidation ?? []) { + out.push( + ` host validation (${validation.copy} ${validation.pluginDirectory}` + + `${validation.scope === undefined ? '' : `, scope ${validation.scope}`}): ${validation.status}\n`, + ); + } + if (host.bundle.lifecycle !== undefined) { + out.push(` lifecycle: ${describeLifecycle(host.bundle.lifecycle)}\n`); + } + } + if (host.receipts.length > 0) { + out.push(` receipts: ${host.receipts.length} store receipt(s)\n`); + for (const receipt of host.receipts) { + out.push(` ${receipt.plugin}@${receipt.version} (${receipt.mode}, ${receipt.scope}): ${receipt.state}\n`); + } + } + const reports = [ + ...host.inventory.findings.flatMap((finding) => finding.durableStates ?? ( + finding.durableState === undefined ? [] : [finding.durableState] + )), + host.bundle?.durableState, + ].filter((report): report is DoctorDurableStateReport => report !== undefined); + const uniqueReports = [...new Map(reports.map((report) => [report.directory, report])).values()]; + for (const report of uniqueReports) { + out.push( + ` state root: ${report.directory} (${report.exists ? 'exists' : 'missing'}, ` + + `${report.writable ? 'writable' : 'not writable'}, ${report.stateSource}); ` + + `ownership: ${report.ownership}${report.ownershipReason === undefined ? '' : ` (${report.ownershipReason})`}, ` + + `${report.purgeable ? 'purgeable' : 'retained'}${ + report.servers.length === 0 ? '' : `, servers: ${report.servers.join(', ')}` + }\n`, + ); + } + const legacyReports = host.inventory.findings + .map((finding) => finding.legacyDurableState) + .filter((report): report is DoctorDurableStateReport => report !== undefined); + for (const report of [...new Map(legacyReports.map((entry) => [entry.directory, entry])).values()]) { + out.push(` legacy state: ${report.directory} (exists, ${report.writable ? 'writable' : 'not writable'})\n`); + } + if (uniqueReports.length > 0) { + const stores = uniqueReports.reduce((total, report) => total + report.summary.stores, 0); + const bytes = uniqueReports.reduce((total, report) => total + report.summary.bytes, 0); + out.push( + ` durable state: ${stores} ${stores === 1 ? 'store' : 'stores'}, ${formatByteSize(bytes)}\n`, + ); + } + // The operator `.env` layer (#469): present files and their variable counts, never a value. + const operatorEnvFiles = [ + ...host.inventory.findings.map((finding) => finding.operatorEnv), + host.bundle?.operatorEnv, + ].flatMap((report) => report?.files ?? []).filter((file) => file.state !== 'absent'); + const uniqueEnvFiles = [...new Map(operatorEnvFiles.map((file) => [file.path, file])).values()]; + if (uniqueEnvFiles.length > 0) { + out.push(` operator env: ${uniqueEnvFiles.map((file) => + `${file.path} (${file.state === 'present' ? `${String(file.variables ?? 0)} variable${file.variables === 1 ? '' : 's'}` : file.state})`).join(', ')}\n`); + } + } + if (result.web !== undefined) { + out.push(`${result.web.line}\n`); + } + out.push( + `runtime endpoints: ${result.endpoints.status}; ${result.endpoints.summary.live} live, ` + + `${result.endpoints.summary.staleSockets} stale socket(s), ` + + `${result.endpoints.summary.staleLocks} stale lock(s)\n`, + ); + for (const entry of result.diagnostics) { + out.push(`${entry.code}: ${entry.message}\nRecovery: ${entry.recovery}\n`); + } + out.push( + `Doctor summary: ${result.summary.errors} error(s), ${result.summary.warnings} warning(s), ` + + `${result.summary.infos} info(s)\n`, + ); + return out.join(''); +}; diff --git a/packages/agent-bundle/src/install/index.ts b/packages/agent-bundle/src/install/index.ts new file mode 100644 index 000000000..181f1c019 --- /dev/null +++ b/packages/agent-bundle/src/install/index.ts @@ -0,0 +1,93 @@ +import { Command, CommanderError } from 'commander'; + +import { DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; +import { stableJson } from '../core/digest.ts'; +import { errorMessage } from '../core/errors.ts'; +import { registerLifecycleCommands } from './commands.ts'; +import { runDoctor } from './doctor.ts'; +import { installBundle } from './install.ts'; +import { uninstallBundle } from './uninstall.ts'; + +export { runDoctor } from './doctor.ts'; +export type { + DoctorHost, + DoctorHostReport, + DoctorInstallComparison, + DoctorInstallComparisonStatus, + DoctorOptions, + DoctorReport, +} from './doctor.ts'; +export { formatDoctorReport, formatInstallResult, formatUninstallResult } from './format.ts'; +export { installBundle } from './install.ts'; +export type { + InstallBundleOptions, + InstallHost, + InstallMode, + InstallResult, + InstallResultState, + InstallScope, +} from './install.ts'; +export { uninstallBundle } from './uninstall.ts'; +export type { + UninstallBundleOptions, + UninstallResult, + UninstallResultState, +} from './uninstall.ts'; + +/** + * A package-bound lifecycle CLI (#724): the `agent-bundle` CLI's own + * `install`, `uninstall`, and `doctor` commands with the bundle root pinned + * to the package that ships the bin, so a published plugin can offer + * ` install ` without bundling the framework's installer source + * or parsing argv itself. + */ +export interface InstallCliOptions { + /** The bundle root the bin binds: the package root of an npm-root layout (`new URL('..', import.meta.url)` from `bin/`). */ + readonly from: string; + /** The bin name shown in `--help`; defaults to `install`. */ + readonly name?: string; + readonly stderr?: (text: string) => void; + readonly stdout?: (text: string) => void; + /** The version `--version` prints; omitted when absent. */ + readonly version?: string; +} + +const diagnosticsFor = (error: unknown): readonly Diagnostic[] => { + if (error instanceof DiagnosticError) return error.diagnostics; + return [{ code: 'AB5000', message: errorMessage(error), severity: 'error' }]; +}; + +const lifecycle = async () => ({ installBundle, runDoctor, uninstallBundle }); + +/** + * Runs `install `, `uninstall `, or `doctor` against the pinned + * bundle root and returns the process exit code: 0 on success, 1 when the + * command failed (diagnostics as one JSON line on stderr), 2 on a usage + * error, exactly like the `agent-bundle` CLI. + */ +export const runInstallCli = async (argv: readonly string[], options: InstallCliOptions): Promise => { + const stdout = options.stdout ?? ((text: string): void => void process.stdout.write(text)); + const stderr = options.stderr ?? ((text: string): void => void process.stderr.write(text)); + let exitCode = 0; + const program = new Command() + .name(options.name ?? 'install') + .exitOverride() + .showHelpAfterError(false) + .configureOutput({ writeErr: stderr, writeOut: stdout }); + if (options.version !== undefined) program.version(options.version); + registerLifecycleCommands(program, { + from: options.from, + lifecycle, + machine: async (result) => stdout(`${stableJson(result ?? null)}\n`), + setExitCode: (code) => { exitCode = code; }, + show: async (text) => stdout(text), + }); + try { + await program.parseAsync([...argv], { from: 'user' }); + return exitCode; + } catch (error) { + if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : 2; + stderr(`${stableJson(diagnosticsFor(error))}\n`); + return 1; + } +}; diff --git a/packages/agent-bundle/tests/install-cli.test.ts b/packages/agent-bundle/tests/install-cli.test.ts new file mode 100644 index 000000000..2e6e0d9d2 --- /dev/null +++ b/packages/agent-bundle/tests/install-cli.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Command } from 'commander'; +import { describe, expect, it } from '@rstest/core'; + +import { registerLifecycleCommands, type LifecycleApi } from '../src/install/commands.ts'; +import { runInstallCli } from '../src/install/index.ts'; + +const capture = () => { + const stdout: string[] = []; + const stderr: string[] = []; + return { + stderr: () => stderr.join(''), + stdout: () => stdout.join(''), + sinks: { stderr: (text: string) => void stderr.push(text), stdout: (text: string) => void stdout.push(text) }, + }; +}; + +/** Drives the shared command declarations with a fake lifecycle and a pinned root, the way `runInstallCli` does. */ +const runPinned = async (argv: readonly string[], api: Partial) => { + const out = capture(); + let exitCode = 0; + const program = new Command().name('demo-install').exitOverride().configureOutput({ + writeErr: out.sinks.stderr, + writeOut: out.sinks.stdout, + }); + registerLifecycleCommands(program, { + from: '/pkg', + lifecycle: async () => api as LifecycleApi, + machine: async (result) => out.sinks.stdout(`${JSON.stringify(result)}\n`), + setExitCode: (code) => { exitCode = code; }, + show: async (text) => out.sinks.stdout(text), + }); + const code = await program.parseAsync([...argv], { from: 'user' }).then(() => exitCode, (error: unknown) => error); + return { code, ...out }; +}; + +describe('package-bound lifecycle commands', () => { + it('pins the bundle root and forwards install flags to installBundle', async () => { + const calls: unknown[] = []; + const { code, stdout } = await runPinned( + ['install', 'cursor', '--mode', 'marketplace', '--scope', 'project', '--force', '--json'], + { + installBundle: async (options) => { + calls.push(options); + return { bundleRoot: options.from, host: options.host, plugin: 'demo', state: 'installed', version: '1.0.0' }; + }, + }, + ); + expect(code).toBe(0); + expect(calls).toEqual([{ from: '/pkg', host: 'cursor', mode: 'marketplace', replace: true, scope: 'project' }]); + expect(JSON.parse(stdout())).toMatchObject({ bundleRoot: '/pkg', state: 'installed' }); + }); + + it('forwards uninstall --plan and the data policy flags to uninstallBundle', async () => { + const calls: unknown[] = []; + const { code } = await runPinned( + ['uninstall', 'claude', '--plan', '--purge-data', '--confirm-purge', '--json'], + { + uninstallBundle: async (options) => { + calls.push(options); + return { + data: { detail: '', outcome: 'planned', paths: [], policy: 'purge' }, + host: 'claude', + mode: 'local', + plugin: 'demo', + receipt: { path: '/r', status: 'valid' }, + registrations: [], + removed: { directories: [], files: [] }, + retained: [], + state: 'planned', + version: '1.0.0', + } as never; + }, + }, + ); + expect(code).toBe(0); + expect(calls).toEqual([{ + confirmPurge: true, from: '/pkg', host: 'claude', plan: true, purgeData: true, scope: 'user', + }]); + }); + + it('runs doctor against the pinned root and exits 1 on an error diagnostic', async () => { + const calls: unknown[] = []; + const { code, stdout } = await runPinned(['doctor', '--host', 'cursor', '--json'], { + runDoctor: async (options) => { + calls.push(options); + return { + diagnostics: [{ code: 'AB7300', message: 'boom', recovery: 'fix', severity: 'error' }], + endpoints: { status: 'clean', summary: { live: 0, staleLocks: 0, staleSockets: 0 } }, + hosts: [], + summary: { errors: 1, infos: 0, warnings: 0 }, + } as never; + }, + }); + expect(code).toBe(1); + expect(calls).toEqual([{ from: '/pkg', hosts: ['cursor'] }]); + expect(JSON.parse(stdout())).toMatchObject({ summary: { errors: 1 } }); + }); + + it('exposes no --from once the root is pinned', async () => { + const { code, stderr } = await runPinned(['install', 'cursor', '--from', '/elsewhere'], {}); + expect(code).toMatchObject({ code: 'commander.unknownOption' }); + expect(stderr()).toContain("unknown option '--from'"); + }); +}); + +describe('runInstallCli', () => { + it('prints help with the bin name and exits 0', async () => { + const out = capture(); + const code = await runInstallCli(['--help'], { from: '/pkg', name: 'demo-install', ...out.sinks }); + expect(code).toBe(0); + expect(out.stdout()).toContain('Usage: demo-install'); + expect(out.stdout()).toContain('install [options] '); + expect(out.stdout()).not.toContain('--from'); + }); + + it('exits 2 on a usage error without touching the lifecycle', async () => { + const out = capture(); + expect(await runInstallCli(['install', 'windsurf'], { from: '/pkg', ...out.sinks })).toBe(2); + expect(out.stderr()).toContain('Install host must be claude, codex, or cursor.'); + expect(await runInstallCli([], { from: '/pkg', ...out.sinks })).toBe(2); + }); + + it('reports a failed install as one diagnostics line on stderr and exits 1', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-install-cli-')); + try { + const out = capture(); + const code = await runInstallCli(['install', 'cursor', '--json'], { from: join(root, 'missing'), ...out.sinks }); + expect(code).toBe(1); + expect(out.stdout()).toBe(''); + const [diagnostic] = JSON.parse(out.stderr()) as { readonly code: string; readonly severity: string }[]; + expect(diagnostic).toMatchObject({ severity: 'error' }); + expect(diagnostic.code).toMatch(/^AB\d{4}$/u); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/packages/agent-bundle/tests/packed-install-bin.test.ts b/packages/agent-bundle/tests/packed-install-bin.test.ts new file mode 100644 index 000000000..d788eb8fa --- /dev/null +++ b/packages/agent-bundle/tests/packed-install-bin.test.ts @@ -0,0 +1,180 @@ +import { execFile as executeFile } from 'node:child_process'; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterAll, beforeAll, expect, it } from '@rstest/core'; + +import { removeProjectSource } from '../src/test/packed.ts'; +import { runBin } from './support/bin-process.ts'; +import { within } from './support/eventually.ts'; +import { + cachedNpmInstallArguments, + installedEnvironment, + packOutputFromJson, + sharedPackedTarball, +} from './support/shared-pack.ts'; +import { timeScale } from './support/time-scale.ts'; + +const execFile = promisify(executeFile); +const packageName = 'install-bin-fixture'; +const binName = 'demo-install'; +const agentBundleImport = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]agent-bundle(?:\/[^'"]*)?['"]/u; + +let consumer = ''; +let home = ''; +let bin = ''; + +interface Run { + readonly code: number | null; + readonly json: () => T; + readonly stderr: string; + readonly stdout: string; +} + +/** Runs the installed bin from a directory that is neither the package nor the artifact, with an isolated home. */ +const run = async (args: readonly string[]): Promise => { + const child = runBin(bin, args, { cwd: consumer, env: { ...installedEnvironment(), HOME: home } }); + const { code } = await within(child.exit, 60_000 * timeScale); + const stdout = child.stdout(); + return { code, json: () => JSON.parse(stdout) as T, stderr: child.stderr(), stdout }; +}; + +beforeAll(async () => { + const { tarball: agentBundle } = await sharedPackedTarball('agent-bundle'); + consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-install-bin-')); + home = join(consumer, 'home'); + const project = join(consumer, 'project'); + await mkdir(join(project, 'src', 'skills', 'demo'), { recursive: true }); + await Promise.all([ + // Installers never create a Cursor home (AB7002); the fixture host has one. + mkdir(join(home, '.cursor'), { recursive: true }), + writeFile(join(project, 'package.json'), `${JSON.stringify({ + bin: { [binName]: `./dist/bin/${binName}.js` }, + name: packageName, + private: true, + type: 'module', + version: '1.0.0', + }, null, 2)}\n`), + writeFile(join(project, 'agent-bundle.config.ts'), [ + 'export default {', + ` bin: { '${binName}': './src/install-bin.ts' },`, + " output: { distPath: 'artifact' },", + ` plugin: { description: 'Installs itself through agent-bundle/install.', name: '${packageName}' },`, + " targets: ['cursor'],", + '};', + '', + ].join('\n')), + // The whole installer a consumer writes: the npm root is the artifact, so + // the bin under `bin/` binds its parent directory and nothing is probed. + writeFile(join(project, 'src', 'install-bin.ts'), [ + "import { fileURLToPath } from 'node:url';", + '', + "import { runInstallCli } from 'agent-bundle/install';", + '', + 'export const main = (argv: readonly string[]): Promise =>', + ` runInstallCli(argv, { from: fileURLToPath(new URL('..', import.meta.url)), name: '${binName}' });`, + '', + ].join('\n')), + writeFile(join(project, 'src', 'skills', 'demo', 'SKILL.md'), '---\nname: demo\ndescription: A demo skill.\n---\n\nDemo.\n'), + writeFile(join(project, 'README.md'), '# install-bin fixture\n'), + ]); + await execFile('npm', ['install', '--save-dev', ...cachedNpmInstallArguments, agentBundle], { cwd: project, env: installedEnvironment() }); + const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); + await execFile(cli, ['prepack', '--root', project, '--output', 'artifact'], { cwd: project, env: installedEnvironment() }); + + const tarballs = join(consumer, 'tarballs'); + const installed = join(consumer, 'installed'); + await Promise.all([mkdir(tarballs), mkdir(installed)]); + const { stdout: packJson } = await execFile( + 'npm', + ['pack', '--json', '--ignore-scripts', '--pack-destination', tarballs], + { cwd: join(project, 'dist'), env: installedEnvironment() }, + ); + const packed = packOutputFromJson(packJson, packageName); + expect(packed.files.map((file) => file.path)).toEqual(expect.arrayContaining([ + 'agent-bundle.manifest.json', + `bin/${binName}.js`, + ])); + await writeFile(join(installed, 'package.json'), '{"private":true}\n'); + await execFile('npm', ['install', ...cachedNpmInstallArguments, join(tarballs, packed.filename)], { + cwd: installed, + env: installedEnvironment(), + }); + bin = resolve(installed, 'node_modules', packageName, `bin/${binName}.js`); + // `packed-deleted-source`: the source project, its build, and its node_modules + // (the only `agent-bundle` on disk) are gone before the bin runs. + await removeProjectSource({ projectRoot: project }); + await rm(project, { force: true, recursive: true }); +}, 300_000); + +afterAll(async () => { + if (consumer.length > 0) await rm(consumer, { force: true, recursive: true }); +}); + +it('ships a self-contained installer bin that binds its own npm root', async () => { + const source = await readFile(bin, 'utf8'); + expect(source).not.toMatch(agentBundleImport); + expect(source).not.toContain('agent-bundle-src'); + await expect(access(join(consumer, 'installed', 'node_modules', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); + + const help = await run(['--help']); + expect(help.code).toBe(0); + expect(help.stdout).toContain(`Usage: ${binName}`); + expect(help.stdout).not.toContain('--from'); + const rejected = await run(['install', 'cursor', '--from', consumer]); + expect(rejected.code).toBe(2); + expect(rejected.stderr).toContain("unknown option '--from'"); +}); + +it('installs, reports, replaces, plans, and uninstalls through the framework lifecycle with receipts', async () => { + const destination = join(home, '.cursor', 'plugins', 'local', packageName); + const installedRoot = resolve(bin, '..', '..'); + + const installed = await run(['install', 'cursor', '--json']); + expect(installed.stderr).toBe(''); + expect(installed.code).toBe(0); + expect(installed.json()).toMatchObject({ + bundleRoot: installedRoot, + destination, + host: 'cursor', + plugin: packageName, + receipt: join(destination, '.agent-bundle-install.json'), + state: 'installed', + version: '1.0.0', + }); + await expect(readFile(join(destination, 'skills', 'demo', 'SKILL.md'), 'utf8')).resolves.toContain('Demo.'); + + const again = await run(['install', 'cursor', '--json']); + expect(again.code).toBe(0); + expect(again.json()).toMatchObject({ state: 'already-installed' }); + + // Same-version content drift in the installed copy is replaced, never adopted. + await writeFile(join(destination, 'skills', 'demo', 'SKILL.md'), 'edited\n'); + const replaced = await run(['install', 'cursor', '--json']); + expect(replaced.code).toBe(0); + expect(replaced.json()).toMatchObject({ state: 'replaced' }); + await expect(readFile(join(destination, 'skills', 'demo', 'SKILL.md'), 'utf8')).resolves.toContain('Demo.'); + + const doctor = await run(['doctor', '--host', 'cursor', '--json']); + expect(doctor.code).toBe(0); + expect(doctor.json<{ readonly hosts: readonly { readonly bundle?: { readonly comparison?: { readonly status: string } }; readonly host: string }[] }>().hosts) + .toEqual([expect.objectContaining({ bundle: expect.objectContaining({ comparison: expect.objectContaining({ status: 'current' }) }), host: 'cursor' })]); + + const plan = await run(['uninstall', 'cursor', '--plan', '--json']); + expect(plan.code).toBe(0); + const planned = plan.json<{ readonly removed: { readonly files: readonly string[] }; readonly state: string }>(); + expect(planned.state).toBe('planned'); + expect(planned.removed.files).toContain(join(destination, 'skills', 'demo', 'SKILL.md')); + await expect(access(destination)).resolves.toBeUndefined(); + + const uninstalled = await run(['uninstall', 'cursor', '--json']); + expect(uninstalled.code).toBe(0); + expect(uninstalled.json()).toMatchObject({ state: 'uninstalled' }); + await expect(access(destination)).rejects.toMatchObject({ code: 'ENOENT' }); + + const human = await run(['uninstall', 'cursor']); + expect(human.code).toBe(0); + expect(human.stdout).toContain(`Not installed ${packageName}@1.0.0 for cursor`); +}, 120_000); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 2b22d2756..937435136 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -163,6 +163,7 @@ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/packed-consumer.test.ts', 'packages/agent-bundle/tests/packed-consumer-typescript.test.ts', 'packages/agent-bundle/tests/packed-host-install-proof.test.ts', + 'packages/agent-bundle/tests/packed-install-bin.test.ts', 'packages/agent-bundle/tests/packed-native-smoke.test.ts', 'packages/agent-bundle/tests/packed-readonly-state-root.test.ts', 'packages/agent-bundle/tests/packed-small-plugin.test.ts', diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index a0bf7aea0..07fb75dc9 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -96,6 +96,36 @@ npm-only metadata and authored package entries from host ownership comparisons. proof reconstructs the manifest-owned artifact from the installed package and exercises `agent-bundle install --from` after deleting the source checkout. +### A package-bound installer bin + +A published plugin that wants ` install ` without asking its users to install the +`agent-bundle` CLI declares one [`bin`](../authoring/package-entries.mdx#bin-and-lib) entry and +delegates to `agent-bundle/install`: + +```ts +// src/install-bin.ts — the whole installer a package ships +import { fileURLToPath } from 'node:url'; + +import { runInstallCli } from 'agent-bundle/install'; + +export const main = (argv: readonly string[]): Promise => + runInstallCli(argv, { + // The npm root is the artifact, so a bin under bin/ binds its parent directory. + from: fileURLToPath(new URL('..', import.meta.url)), + name: 'my-plugin-install', + }); +``` + +`runInstallCli` is the `agent-bundle` CLI's own `install `, `uninstall `, and +`doctor` commands — the same flags, receipts, replacement rules, `--plan`, data policy, `--json` +output, and exit codes (0, 1 with one diagnostics line on stderr, 2 for usage) — with the bundle +root pinned to `from`, so the bin exposes no `--from` and probes nothing. The package build inlines +the lifecycle into the executable; the installed package needs no `agent-bundle` at run time. The +entry also exports `installBundle`, `uninstallBundle`, `runDoctor`, and the `format*` helpers for a +bin that composes its own command surface. A read-only preview of what `install` would do is +`doctor --host `, which reports the installed copy as current, stale, version-mismatched, +foreign, or absent. + ## Development installs are a different thing `agent-bundle dev --install-host ` maintains a *marked development* install that follows diff --git a/website/docs/en/reference/api.mdx b/website/docs/en/reference/api.mdx index 64f96dbd6..d77c783a0 100644 --- a/website/docs/en/reference/api.mdx +++ b/website/docs/en/reference/api.mdx @@ -25,6 +25,7 @@ Every public entry point is documented from its declarations, one page per entry | `agent-bundle/mcp-apps` | [mcp-apps](/api/mcp-apps) | The MCP App resource registry contract, replaced by the compiler in local MCP servers. | | `agent-bundle/cli-entry` | [cli-entry](/api/cli-entry) | The routed-CLI shell every generated CLI executable is built on. | | `agent-bundle/mcp-entry` | [mcp-entry](/api/mcp-entry) | The stdio MCP entry shell every generated MCP server is wrapped in. | +| `agent-bundle/install` | [install](/api/install) | The package-bound lifecycle CLI `runInstallCli` (the `agent-bundle` CLI's `install`, `uninstall`, and `doctor` commands with the bundle root pinned to the package that ships the bin), plus `installBundle`, `uninstallBundle`, `runDoctor`, the `formatInstallResult` / `formatUninstallResult` / `formatDoctorReport` renderers, and their option and result types. See [A package-bound installer bin](../guide/distribution/installation.mdx#a-package-bound-installer-bin). | | `agent-bundle/launch-env` | [launch-env](/api/launch-env) | The operator `.env` layer every emitted shell applies at launch — `applyOperatorEnv`, `parseOperatorEnv`, and the `OPERATOR_ENV_FILE_*` constants behind the `AGENT_BUNDLE_ENV_FILE` variable — for hand-rolled entries that want the same behavior. | | `agent-bundle/web-host` | [web-host](/api/web-host) | The artifact-resident browser host: `runWebCommand`, `WEB_HOST_TOKEN_HEADER`, `readWebManifest`, and their types. The generated `bin/.mjs` imports this entry when [`web`](./configuration.mdx#web) is configured; authors run ` web` rather than importing it. | | `agent-bundle/routes` | [routes/public](/api/routes/public) | The route-module authoring types (`AgentEventRouteProps`, `ToolRouteProps`, `CliRouteProps`, the `config` shapes) and `appResourceUri`, the static reference to a sibling MCP App the compiler resolves to its `resourceUri`. | diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 104f6b12e..aea73da95 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -79,6 +79,32 @@ npm 包根目录包含组合插件清单拥有的全部字节,并额外带有 打包宿主证明会从已安装包重建清单拥有的产物,并在删除源 checkout 后执行 `agent-bundle install --from`。 +### 与包绑定的安装器 bin + +已发布的插件若希望提供 ` install `,又不想要求用户安装 `agent-bundle` CLI,只需声明一个 +[`bin`](../authoring/package-entries.mdx#bin-与-lib) 入口并委托给 `agent-bundle/install`: + +```ts +// src/install-bin.ts —— 包所需的全部安装器代码 +import { fileURLToPath } from 'node:url'; + +import { runInstallCli } from 'agent-bundle/install'; + +export const main = (argv: readonly string[]): Promise => + runInstallCli(argv, { + // npm 包根目录就是产物,因此 bin/ 下的 bin 绑定其父目录。 + from: fileURLToPath(new URL('..', import.meta.url)), + name: 'my-plugin-install', + }); +``` + +`runInstallCli` 就是 `agent-bundle` CLI 自己的 `install `、`uninstall ` 与 `doctor` +命令——同样的标志、回执、替换规则、`--plan`、数据策略、`--json` 输出与退出码(0;1 并在 stderr +输出一行诊断;2 表示用法错误)——只是把 bundle 根目录固定为 `from`,因此该 bin 不暴露 `--from`,也不做任何探测。 +包构建会把生命周期实现内联进可执行文件;已安装的包在运行时不需要 `agent-bundle`。 +该入口还导出 `installBundle`、`uninstallBundle`、`runDoctor` 与 `format*` 助手,供自行组合命令面的 bin 使用。 +要只读地预览 `install` 会做什么,请运行 `doctor --host `,它会报告已安装副本是最新、过期、版本不匹配、外来还是缺失。 + ## 开发期安装是另一回事 `agent-bundle dev --install-host ` 维护的是一个*标记为开发用*的安装,它跟随成功的重建 epoch, diff --git a/website/docs/zh/reference/api.mdx b/website/docs/zh/reference/api.mdx index 5e78366cc..d3eb588dd 100644 --- a/website/docs/zh/reference/api.mdx +++ b/website/docs/zh/reference/api.mdx @@ -24,6 +24,7 @@ description: '生成的 agent-bundle 类型 API:每个公开入口点一页、 | `agent-bundle/mcp-apps` | [mcp-apps](/zh/api/mcp-apps) | MCP App 资源注册表契约,编译器会在本地 MCP 服务器中替换它。 | | `agent-bundle/cli-entry` | [cli-entry](/zh/api/cli-entry) | 每个生成的 CLI 可执行文件所基于的路由式 CLI 外壳。 | | `agent-bundle/mcp-entry` | [mcp-entry](/zh/api/mcp-entry) | 每个生成的 MCP 服务器所包裹的 stdio MCP 入口外壳。 | +| `agent-bundle/install` | [install](/zh/api/install) | 与包绑定的生命周期 CLI `runInstallCli`(即 `agent-bundle` CLI 的 `install`、`uninstall` 与 `doctor` 命令,bundle 根目录固定为携带该 bin 的包),以及 `installBundle`、`uninstallBundle`、`runDoctor`、`formatInstallResult` / `formatUninstallResult` / `formatDoctorReport` 渲染器及其选项与结果类型。参见[与包绑定的安装器 bin](../guide/distribution/installation.mdx#与包绑定的安装器-bin)。 | | `agent-bundle/launch-env` | [launch-env](/zh/api/launch-env) | 每个输出外壳在启动时应用的操作者 `.env` 层——`applyOperatorEnv`、`parseOperatorEnv`,以及 `AGENT_BUNDLE_ENV_FILE` 变量背后的 `OPERATOR_ENV_FILE_*` 常量——供希望获得同样行为的手写入口使用。 | | `agent-bundle/web-host` | [web-host](/zh/api/web-host) | 产物内浏览器宿主:`runWebCommand`、`WEB_HOST_TOKEN_HEADER`、`readWebManifest` 及其类型。配置了 [`web`](./configuration.mdx#web) 时,生成的 `bin/.mjs` 会导入该入口;作者运行 ` web`,而不是自己导入它。 | | `agent-bundle/routes` | [routes/public](/zh/api/routes/public) | 路由模块的编写类型(`AgentEventRouteProps`、`ToolRouteProps`、`CliRouteProps` 与各类 `config` 形状),以及 `appResourceUri`——对同级 MCP App 的静态引用,编译器会把它解析为该 App 的 `resourceUri`。 | diff --git a/website/rspress.config.ts b/website/rspress.config.ts index 97f28cab6..236038f00 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -69,6 +69,7 @@ const publicApiEntryPoints = [ 'cli-entry.d.ts', 'config/index.d.ts', 'eval/index.d.ts', + 'install/index.d.ts', 'launch-env.d.ts', 'mcp-apps.d.ts', 'meta.d.ts', From 27d96ba3b050bd75205fb9f12f715acba2957437 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 03:15:46 +0000 Subject: [PATCH 2/4] chore: changeset PR number --- .changeset/724-package-bound-install-entry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/724-package-bound-install-entry.md b/.changeset/724-package-bound-install-entry.md index 990e31f06..002f33862 100644 --- a/.changeset/724-package-bound-install-entry.md +++ b/.changeset/724-package-bound-install-entry.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Add the `agent-bundle/install` entry so a published plugin can ship a package-bound installer bin without bundling the framework's installer source or parsing argv itself: `runInstallCli(argv, { from, name })` runs the `agent-bundle` CLI's own `install `, `uninstall `, and `doctor` commands — the same flags, receipts, replacement rules, `--plan`, data policy, `--json` output, and exit codes — with the bundle root pinned to the package that ships the bin (no `--from`, no artifact-root probing). The entry also exports `installBundle`, `uninstallBundle`, `runDoctor`, `formatInstallResult`, `formatUninstallResult`, the new `formatDoctorReport`, and their option and result types. The three commands are declared once and shared with the `agent-bundle` CLI. Proven from a packed consumer whose source and `node_modules` are deleted before its bin installs, replaces, plans, and uninstalls a Cursor copy (`packed-install-bin`). Fixes #724. (#728) +Add the `agent-bundle/install` entry so a published plugin can ship a package-bound installer bin without bundling the framework's installer source or parsing argv itself: `runInstallCli(argv, { from, name })` runs the `agent-bundle` CLI's own `install `, `uninstall `, and `doctor` commands — the same flags, receipts, replacement rules, `--plan`, data policy, `--json` output, and exit codes — with the bundle root pinned to the package that ships the bin (no `--from`, no artifact-root probing). The entry also exports `installBundle`, `uninstallBundle`, `runDoctor`, `formatInstallResult`, `formatUninstallResult`, the new `formatDoctorReport`, and their option and result types. The three commands are declared once and shared with the `agent-bundle` CLI. Proven from a packed consumer whose source and `node_modules` are deleted before its bin installs, replaces, plans, and uninstalls a Cursor copy (`packed-install-bin`). Fixes #724. (#730) From a0cd864b7c78f6eda12c2c6b74e7bb30d9f91834 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 03:36:46 +0000 Subject: [PATCH 3/4] review: doctor reports on stdout, shared diagnosticsFor, per-command lazy loading, full type surface --- .changeset/724-package-bound-install-entry.md | 2 +- packages/agent-bundle/src/cli.ts | 33 +++++----------- packages/agent-bundle/src/core/diagnostics.ts | 7 ++++ packages/agent-bundle/src/install/index.ts | 39 ++++--------------- .../agent-bundle/tests/install-cli.test.ts | 35 ++++++++++------- .../en/guide/distribution/installation.mdx | 18 +++++---- .../zh/guide/distribution/installation.mdx | 9 +++-- 7 files changed, 64 insertions(+), 79 deletions(-) diff --git a/.changeset/724-package-bound-install-entry.md b/.changeset/724-package-bound-install-entry.md index 002f33862..b48656bbd 100644 --- a/.changeset/724-package-bound-install-entry.md +++ b/.changeset/724-package-bound-install-entry.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Add the `agent-bundle/install` entry so a published plugin can ship a package-bound installer bin without bundling the framework's installer source or parsing argv itself: `runInstallCli(argv, { from, name })` runs the `agent-bundle` CLI's own `install `, `uninstall `, and `doctor` commands — the same flags, receipts, replacement rules, `--plan`, data policy, `--json` output, and exit codes — with the bundle root pinned to the package that ships the bin (no `--from`, no artifact-root probing). The entry also exports `installBundle`, `uninstallBundle`, `runDoctor`, `formatInstallResult`, `formatUninstallResult`, the new `formatDoctorReport`, and their option and result types. The three commands are declared once and shared with the `agent-bundle` CLI. Proven from a packed consumer whose source and `node_modules` are deleted before its bin installs, replaces, plans, and uninstalls a Cursor copy (`packed-install-bin`). Fixes #724. (#730) +Add the `agent-bundle/install` entry so a published plugin can ship a package-bound installer bin without bundling the framework's installer source or parsing argv itself: `runInstallCli(argv, { from, name })` runs the `agent-bundle` CLI's own `install `, `uninstall `, and `doctor` commands — the same flags, receipts, replacement rules, `--plan`, data policy, `--json` output, and exit codes (`doctor` writes its report to stdout and exits 1 on an error finding; a failed `install`/`uninstall` writes one diagnostics JSON line to stderr) — with the bundle root pinned to the package that ships the bin (no `--from`). The entry also exports `installBundle`, `uninstallBundle`, `runDoctor`, `formatInstallResult`, `formatUninstallResult`, the new `formatDoctorReport`, and their option and result types. Fixes #724. (#730) diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index f536214df..42d38b28a 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -35,8 +35,7 @@ import type { installBundle, InstallHost } from './install/install.ts'; import type { runDoctor } from './install/doctor.ts'; import type { uninstallBundle } from './install/uninstall.ts'; import type { runHostMcpProxy } from './dev/host-mcp-proxy.ts'; -import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; -import { errorMessage } from './core/errors.ts'; +import { DiagnosticError, diagnosticsFor, type Diagnostic } from './core/diagnostics.ts'; import { collectInstallHost, registerLifecycleCommands } from './install/commands.ts'; import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; @@ -280,15 +279,6 @@ const parseJsonObject = async (options: JsonInputOptions): Promise; }; -const diagnosticsFor = (error: unknown): readonly Diagnostic[] => { - if (error instanceof DiagnosticError) return error.diagnostics; - return [{ - code: 'AB5000', - message: errorMessage(error), - severity: 'error', - }]; -}; - /** One canonical JSON line: the `--json` document on stdout, or the diagnostics document on stderr. */ const machineLine = (result: unknown): string => `${stableJson(result === undefined ? null : result)}\n`; @@ -760,18 +750,15 @@ export const runCli = async ( }); registerLifecycleCommands(program, { - lifecycle: async () => { - const [install, uninstall, doctor] = await Promise.all([ - import('./install/install.ts'), - import('./install/uninstall.ts'), - import('./install/doctor.ts'), - ]); - return { - installBundle: dependencies.installBundle ?? install.installBundle, - runDoctor: dependencies.runDoctor ?? doctor.runDoctor, - uninstallBundle: dependencies.uninstallBundle ?? uninstall.uninstallBundle, - }; - }, + // Each implementation loads on first use, so `install` never pays for `doctor`. + lifecycle: async () => ({ + installBundle: dependencies.installBundle + ?? (async (options) => (await import('./install/install.ts')).installBundle(options)), + runDoctor: dependencies.runDoctor + ?? (async (options) => (await import('./install/doctor.ts')).runDoctor(options)), + uninstallBundle: dependencies.uninstallBundle + ?? (async (options) => (await import('./install/uninstall.ts')).uninstallBundle(options)), + }), machine, setExitCode: (code) => { exitCode = code; }, show, diff --git a/packages/agent-bundle/src/core/diagnostics.ts b/packages/agent-bundle/src/core/diagnostics.ts index af072ca9e..3ba513325 100644 --- a/packages/agent-bundle/src/core/diagnostics.ts +++ b/packages/agent-bundle/src/core/diagnostics.ts @@ -1,3 +1,4 @@ +import { errorMessage } from './errors.ts'; import { deepFreeze } from './freeze.ts'; export type DiagnosticSeverity = 'error' | 'warning' | 'info'; @@ -104,6 +105,12 @@ export class DiagnosticError extends Error { } } +/** The diagnostics a failed command reports: a `DiagnosticError`'s own, or one `AB5000` for any other throw. */ +export const diagnosticsFor = (error: unknown): readonly Diagnostic[] => + error instanceof DiagnosticError + ? error.diagnostics + : [{ code: 'AB5000', message: errorMessage(error), severity: 'error' }]; + export class DiagnosticBag { readonly diagnostics: Diagnostic[]; diff --git a/packages/agent-bundle/src/install/index.ts b/packages/agent-bundle/src/install/index.ts index 181f1c019..a282c87a3 100644 --- a/packages/agent-bundle/src/install/index.ts +++ b/packages/agent-bundle/src/install/index.ts @@ -1,38 +1,19 @@ import { Command, CommanderError } from 'commander'; -import { DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; +import { diagnosticsFor } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; -import { errorMessage } from '../core/errors.ts'; import { registerLifecycleCommands } from './commands.ts'; import { runDoctor } from './doctor.ts'; import { installBundle } from './install.ts'; import { uninstallBundle } from './uninstall.ts'; export { runDoctor } from './doctor.ts'; -export type { - DoctorHost, - DoctorHostReport, - DoctorInstallComparison, - DoctorInstallComparisonStatus, - DoctorOptions, - DoctorReport, -} from './doctor.ts'; +export type * from './doctor.ts'; export { formatDoctorReport, formatInstallResult, formatUninstallResult } from './format.ts'; export { installBundle } from './install.ts'; -export type { - InstallBundleOptions, - InstallHost, - InstallMode, - InstallResult, - InstallResultState, - InstallScope, -} from './install.ts'; +export type * from './install.ts'; export { uninstallBundle } from './uninstall.ts'; -export type { - UninstallBundleOptions, - UninstallResult, - UninstallResultState, -} from './uninstall.ts'; +export type * from './uninstall.ts'; /** * A package-bound lifecycle CLI (#724): the `agent-bundle` CLI's own @@ -52,18 +33,14 @@ export interface InstallCliOptions { readonly version?: string; } -const diagnosticsFor = (error: unknown): readonly Diagnostic[] => { - if (error instanceof DiagnosticError) return error.diagnostics; - return [{ code: 'AB5000', message: errorMessage(error), severity: 'error' }]; -}; - const lifecycle = async () => ({ installBundle, runDoctor, uninstallBundle }); /** * Runs `install `, `uninstall `, or `doctor` against the pinned - * bundle root and returns the process exit code: 0 on success, 1 when the - * command failed (diagnostics as one JSON line on stderr), 2 on a usage - * error, exactly like the `agent-bundle` CLI. + * bundle root and returns the process exit code, exactly like the + * `agent-bundle` CLI: 0 on success; 1 when the command threw (its + * diagnostics as one JSON line on stderr) or when `doctor`'s report, written + * to stdout, carries an error diagnostic; 2 on a usage error. */ export const runInstallCli = async (argv: readonly string[], options: InstallCliOptions): Promise => { const stdout = options.stdout ?? ((text: string): void => void process.stdout.write(text)); diff --git a/packages/agent-bundle/tests/install-cli.test.ts b/packages/agent-bundle/tests/install-cli.test.ts index 2e6e0d9d2..f9344d20d 100644 --- a/packages/agent-bundle/tests/install-cli.test.ts +++ b/packages/agent-bundle/tests/install-cli.test.ts @@ -82,22 +82,29 @@ describe('package-bound lifecycle commands', () => { }]); }); - it('runs doctor against the pinned root and exits 1 on an error diagnostic', async () => { + it('runs doctor against the pinned root, keeps the report on stdout, and exits 1 on an error diagnostic', async () => { const calls: unknown[] = []; - const { code, stdout } = await runPinned(['doctor', '--host', 'cursor', '--json'], { - runDoctor: async (options) => { - calls.push(options); - return { - diagnostics: [{ code: 'AB7300', message: 'boom', recovery: 'fix', severity: 'error' }], - endpoints: { status: 'clean', summary: { live: 0, staleLocks: 0, staleSockets: 0 } }, - hosts: [], - summary: { errors: 1, infos: 0, warnings: 0 }, - } as never; - }, - }); - expect(code).toBe(1); + const runDoctor = (async (options: unknown) => { + calls.push(options); + return { + diagnostics: [{ code: 'AB7300', message: 'boom', recovery: 'fix', severity: 'error' }], + endpoints: { status: 'clean', summary: { live: 0, staleLocks: 0, staleSockets: 0 } }, + hosts: [], + summary: { errors: 1, infos: 0, warnings: 0 }, + }; + }) as unknown as LifecycleApi['runDoctor']; + const json = await runPinned(['doctor', '--host', 'cursor', '--json'], { runDoctor }); + expect(json.code).toBe(1); expect(calls).toEqual([{ from: '/pkg', hosts: ['cursor'] }]); - expect(JSON.parse(stdout())).toMatchObject({ summary: { errors: 1 } }); + expect(json.stderr()).toBe(''); + expect(JSON.parse(json.stdout())).toMatchObject({ summary: { errors: 1 } }); + + // The exit code reports the finding; the report itself never moves to stderr. + const human = await runPinned(['doctor', '--host', 'cursor'], { runDoctor }); + expect(human.code).toBe(1); + expect(human.stderr()).toBe(''); + expect(human.stdout()).toContain('AB7300: boom\nRecovery: fix\n'); + expect(human.stdout()).toContain('Doctor summary: 1 error(s)'); }); it('exposes no --from once the root is pinned', async () => { diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 07fb75dc9..b1ccad390 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -118,13 +118,17 @@ export const main = (argv: readonly string[]): Promise => `runInstallCli` is the `agent-bundle` CLI's own `install `, `uninstall `, and `doctor` commands — the same flags, receipts, replacement rules, `--plan`, data policy, `--json` -output, and exit codes (0, 1 with one diagnostics line on stderr, 2 for usage) — with the bundle -root pinned to `from`, so the bin exposes no `--from` and probes nothing. The package build inlines -the lifecycle into the executable; the installed package needs no `agent-bundle` at run time. The -entry also exports `installBundle`, `uninstallBundle`, `runDoctor`, and the `format*` helpers for a -bin that composes its own command surface. A read-only preview of what `install` would do is -`doctor --host `, which reports the installed copy as current, stale, version-mismatched, -foreign, or absent. +output, and exit codes — with the bundle root pinned to `from`, so the bin exposes no `--from` and never searches for another artifact +root. The package build inlines the lifecycle into the executable; the installed package needs no +`agent-bundle` at run time. The entry also exports `installBundle`, `uninstallBundle`, `runDoctor`, +and the `format*` helpers for a bin that composes its own command surface. The read-only status +check is `doctor --host `: it reports the installed copy as current, stale, +version-mismatched, foreign, or absent. + +Exit codes follow the `agent-bundle` CLI. 0 is success. `doctor` always writes its report (human +or `--json`) to stdout and exits 1 when that report carries an error diagnostic — read the report, +not stderr. An `install` or `uninstall` that fails, or any command that throws, writes its +diagnostics as one JSON line to stderr and exits 1. A usage error exits 2. ## Development installs are a different thing diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index aea73da95..5729eaa6e 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -99,11 +99,14 @@ export const main = (argv: readonly string[]): Promise => ``` `runInstallCli` 就是 `agent-bundle` CLI 自己的 `install `、`uninstall ` 与 `doctor` -命令——同样的标志、回执、替换规则、`--plan`、数据策略、`--json` 输出与退出码(0;1 并在 stderr -输出一行诊断;2 表示用法错误)——只是把 bundle 根目录固定为 `from`,因此该 bin 不暴露 `--from`,也不做任何探测。 +命令——同样的标志、回执、替换规则、`--plan`、数据策略、`--json` 输出与退出码——只是把 bundle 根目录固定为 `from`,因此该 bin 不暴露 `--from`,也绝不会去寻找别的产物根目录。 包构建会把生命周期实现内联进可执行文件;已安装的包在运行时不需要 `agent-bundle`。 该入口还导出 `installBundle`、`uninstallBundle`、`runDoctor` 与 `format*` 助手,供自行组合命令面的 bin 使用。 -要只读地预览 `install` 会做什么,请运行 `doctor --host `,它会报告已安装副本是最新、过期、版本不匹配、外来还是缺失。 +只读的状态检查是 `doctor --host `:它报告已安装副本是最新、过期、版本不匹配、外来还是缺失。 + +退出码与 `agent-bundle` CLI 一致。0 表示成功。`doctor` 总是把报告(人类可读或 `--json`)写到 stdout, +当报告含有错误诊断时以 1 退出——请读取报告,而不是 stderr。失败的 `install` 或 `uninstall`,以及任何抛出异常的命令, +会把诊断作为一行 JSON 写到 stderr 并以 1 退出。用法错误以 2 退出。 ## 开发期安装是另一回事 From 78b966a8d1fe37134d6e2c77dd96adacd0624316 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 03:52:22 +0000 Subject: [PATCH 4/4] review: read process.cwd() only when --from is registered --- packages/agent-bundle/src/install/commands.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/agent-bundle/src/install/commands.ts b/packages/agent-bundle/src/install/commands.ts index 999f00ffb..ffc80d37f 100644 --- a/packages/agent-bundle/src/install/commands.ts +++ b/packages/agent-bundle/src/install/commands.ts @@ -85,15 +85,16 @@ const collectDoctorHost = (value: string, previous: readonly DoctorHost[]): read export const registerLifecycleCommands = (program: Command, options: LifecycleCommandOptions): void => { const { from: pinned, lifecycle, machine, setExitCode, show } = options; - const fromOption = (command: Command, help: string, defaultValue?: string): Command => - pinned === undefined ? command.option('--from ', help, defaultValue) : command; + // `process.cwd()` is read only when the option exists: a pinned bin must work from a deleted cwd. + const fromOption = (command: Command, help: string, defaultToCwd = false): Command => + pinned === undefined ? command.option('--from ', help, defaultToCwd ? process.cwd() : undefined) : command; const installCommand = fromOption( program.command('install') .description('Install a built bundle into a supported host') .argument('', 'Destination host: claude, codex, or cursor', installHost), 'Target bundle directory or artifact root', - process.cwd(), + true, ) .option('--scope ', 'Host install scope', installScope, 'user') .option( @@ -121,7 +122,7 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo .description('Remove a receipt-owned host install of a built bundle, and nothing else') .argument('', 'Host to uninstall from: claude, codex, or cursor', installHost), 'Target bundle directory or artifact root that identifies the plugin', - process.cwd(), + true, ) .option('--scope ', 'Host install scope', installScope, 'user') .option('--mode ', 'Cursor delivery mode to uninstall: local (default) or marketplace', installMode)