Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/724-package-bound-install-entry.md
Original file line number Diff line number Diff line change
@@ -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 <host>`, `uninstall <host>`, 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)
4 changes: 4 additions & 0 deletions packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
313 changes: 18 additions & 295 deletions packages/agent-bundle/src/cli.ts

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions packages/agent-bundle/src/core/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { errorMessage } from './errors.ts';
import { deepFreeze } from './freeze.ts';

export type DiagnosticSeverity = 'error' | 'warning' | 'info';
Expand Down Expand Up @@ -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[];

Expand Down
172 changes: 172 additions & 0 deletions packages/agent-bundle/src/install/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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<LifecycleApi>;
/** Writes one canonical JSON document (`--json`). */
readonly machine: (result: unknown) => Promise<void>;
/** 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<void>;
}

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;
// `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 <bundle-dir>', help, defaultToCwd ? process.cwd() : undefined) : command;

const installCommand = fromOption(
program.command('install')
.description('Install a built bundle into a supported host')
.argument('<host>', 'Destination host: claude, codex, or cursor', installHost),
'Target bundle directory or artifact root',
true,
)
.option('--scope <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 <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>', 'Host to uninstall from: claude, codex, or cursor', installHost),
'Target bundle directory or artifact root that identifies the plugin',
true,
)
.option('--scope <scope>', 'Host install scope', installScope, 'user')
.option('--mode <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>', '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);
});
};
140 changes: 140 additions & 0 deletions packages/agent-bundle/src/install/format.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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('');
};
Loading
Loading