diff --git a/.changeset/476-claude-host-validator-build-doctor.md b/.changeset/476-claude-host-validator-build-doctor.md
new file mode 100644
index 000000000..2f6c18f18
--- /dev/null
+++ b/.changeset/476-claude-host-validator-build-doctor.md
@@ -0,0 +1,5 @@
+---
+'agent-bundle': patch
+---
+
+Run the Claude Code host validator where it was missing and turn it into a load verdict. `agent-bundle build` now runs the same Claude Code checks as `validate --artifact` over every built `claude` and `plugin` target (`--host-validation` on by default, `--no-host-validation` to skip, `--strict` to promote host warnings to errors), and both commands follow the two `claude plugin validate --strict` runs with a `claude --plugin-dir
plugin list --json` load check: a row with `errors` is reported as `AB7325` (a warning when the only errors are uninstalled declared dependencies), no row as `AB7311`, an unreadable listing as `AB6022`; the report carries `load.status` (`loaded`, `refused`, `unregistered`, `failed`). Without `claude` on `PATH`, `build` spawns once and reports a single informational `AB6019`. `agent-bundle doctor --host claude --from ` runs the validator over the bundle and every installed copy (findings keep `AB6019`–`AB6022`, prefixed with the copy they came from) and reads each row's `enabled` flag: an installed-but-disabled copy is reported as `disabled` with the new `AB7327` warning naming the `claude plugin enable` command. The native Claude proofs now validate in plugin mode through the shared runner and record the documented symlink warning. (#504)
diff --git a/docs/diagnostics.md b/docs/diagnostics.md
index 8db5f5c49..981d2f095 100644
--- a/docs/diagnostics.md
+++ b/docs/diagnostics.md
@@ -36,7 +36,7 @@ even when no error diagnostic was reported.
| `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. |
| `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). |
| `AB7xxx` | Project preparation and development rebuilds. |
-| `AB7300`–`AB7326` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7326`; see below), and the Cursor Agent Plugins launch proof (`AB7326`; see below). |
+| `AB7300`–`AB7327` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), and a disabled Claude install (`AB7327`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). |
| `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. |
| `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). |
| `AB8215`–`AB8218` | Workbench read-only host discovery route. |
@@ -46,11 +46,16 @@ even when no error diagnostic was reported.
| `AB8xxx` | Development server configuration. |
| `AB9xxx` | Eval selection, harnesses, and persisted runs. |
-## Claude Code host validation (`AB6019`–`AB6022`)
+## Claude Code host validation (`AB6019`–`AB6022`, `AB7311`, `AB7325`)
-`agent-bundle validate --artifact ` runs the installed Claude Code
-validator for the `claude` and `plugin` targets when `--host-validation` is on.
-Claude Code decides what to check from the manifest it is pointed at: a run
+`agent-bundle validate --artifact ` and `agent-bundle build` run the
+installed Claude Code validator for the `claude` and `plugin` targets when
+`--host-validation` is on (the default for both commands; `--no-host-validation`
+skips it, and programmatic `build()` calls skip it unless `hostValidation: true`
+is passed). `agent-bundle doctor --host claude --from ` runs the same
+validator over the `--from` bundle and over every installed copy Claude lists
+for the plugin, prefixing each finding with `Bundle at …` or `Installed copy at
+… (scope …)`. Claude Code decides what to check from the manifest it is pointed at: a run
against the bundle directory picks `.claude-plugin/marketplace.json` when it is
present and never opens the plugin's skill, agent, command, or hook files
(Claude Code docs, "Create and distribute a plugin marketplace" →
@@ -65,12 +70,31 @@ plugin run already reported. On Claude Code 2.1.259 or later both runs add
releases fall back to the text report, attributed by its `Validating :
` headers.
+`claude plugin validate --strict` is not a load verdict: Claude Code 2.1.250
+through 2.1.260 accept manifests and component files (for example an invalid
+`monitors/monitors.json`, or a `hooks` field naming the auto-loaded
+`hooks/hooks.json`) that a session then refuses to load. So `build` and
+`validate --artifact` follow the two validation runs with a load check,
+`claude --plugin-dir plugin list --json`, and read the plugin's
+`@inline` row: no `errors` is `load.status: 'loaded'`; `errors` is
+`refused` and `AB7325`; no row at all is `unregistered` and `AB7311`; a listing
+that cannot be read is `failed` and `AB6022`. The check is read-only (the
+listing writes nothing under `~/.claude`), is skipped with the two validation
+runs when `claude` is absent (`AB6019`), and is skipped when the bundle has no
+readable `.claude-plugin/plugin.json` name (the validation runs already report
+that manifest). Doctor does not repeat it: its registration proof and the
+inventory rows' `errors` already carry the same verdicts. Without `claude` on
+`PATH`, `build` spawns once, reports one `AB6019`, and marks the remaining
+`claude`/`plugin` targets `unavailable` without spawning again.
+
| Code | Severity | Meaning | Recovery |
| --- | --- | --- | --- |
| `AB6019` | info | The `claude` CLI is not installed or not on `PATH`, so host validation was skipped. Local pinned-schema validation (`AB6011`/`AB6012`) still runs. | Install Claude Code and ensure `claude` is on `PATH`, then rerun artifact validation. |
| `AB6020` | warning (error in strict mode) / info | One Claude Code validation warning, or (info) one note, from the plugin or marketplace run. The message names the validated file and Claude Code's field path, for example `(hooks hooks/hooks.json): hooks: hooks.postToolUse: unknown hook event`. Claude Code tolerates these at load time; `agent-bundle validate --strict` promotes warnings to errors, mirroring `claude plugin validate --strict`. | Run `claude plugin validate /.claude-plugin/plugin.json --strict`, repair the reported Claude artifact, and rebuild. |
| `AB6021` | error | One Claude Code validation error from the plugin or marketplace run, such as invalid JSON in `hooks/hooks.json`, frontmatter that fails to parse, or a duplicate plugin name in `marketplace.json`. Claude Code loads the plugin without the failing component or refuses the marketplace. | Same as `AB6020`. |
-| `AB6022` | error | The bounded `claude --version` probe or a validation run could not start, exited nonzero without a report, timed out, exceeded 1 MiB of output, or (2.1.259+) returned no JSON report; the message carries the CLI's stderr when there is one. | Verify the Claude CLI starts and responds, then rerun `claude plugin validate /.claude-plugin/plugin.json --strict`. |
+| `AB6022` | error | The bounded `claude --version` probe, a validation run, or the load check could not start, exited nonzero without a report, timed out, exceeded 1 MiB of output, (2.1.259+) returned no JSON report, or (load check) returned something other than a JSON array; the message carries the CLI's stderr when there is one. | Verify the Claude CLI starts and responds, then rerun `claude plugin validate /.claude-plugin/plugin.json --strict`. |
+| `AB7311` | error | The load check's `claude --plugin-dir plugin list --json` listed no `@inline` row for the bundle (`load.status: 'unregistered'`): Claude Code did not register the directory as a plugin. Doctor emits the same code from its registration proof. | Inspect `claude --plugin-dir plugin list --json` and register the intended bundle. |
+| `AB7325` | error; warning when every `errors` entry is `Dependency "@" is not installed …` (error under `--strict`) | The load check's row for the bundle carries `errors` (`load.status: 'refused'` with the strings verbatim): `claude plugin validate --strict` accepted the artifact, but a session would refuse to load it. A missing declared dependency is a property of the validating machine rather than of the artifact, so it is a warning and the build completes. Doctor emits the same code for installed copies and its registration proof (see "Host load refusal for Claude installs"). | Fix the artifact so `claude --plugin-dir plugin list --json` reports no `errors` for it, then rebuild; for a missing dependency, install it (`claude plugin install @`) or validate where it is installed. |
## Cursor built-artifact validation (`AB6026`–`AB6029`)
@@ -872,7 +896,29 @@ pinned Claude `plugin` schema now rejects (`AB6012` at `/hooks`).
| Code | Severity | Trigger | Recovery |
| --- | --- | --- | --- |
| `AB7006` | error | `install claude` found `errors` on the plugin's row: after `claude plugin install` ran (the install itself exited 0, so the result would otherwise have been `installed`/`replaced`), or on a byte-identical existing copy that would otherwise have been reported `already-installed` (reinstalling the same bytes cannot help). The message carries the host's `errors` verbatim, the install path, and the scope. An unusable post-install listing leaves the result unverified rather than failing an install the host accepted. | Fix the artifact until `claude plugin list --json` shows no `errors` for it (the message names the cause), rebuild, and rerun `agent-bundle install claude --from --replace`. |
-| `AB7325` | error | Doctor found `errors` on the plugin's row in `claude plugin list --json` (inventory entry `state: 'failed'` with `errors`; `doctor --from` comparison `status: 'load-failed'` with `errors` instead of `current`/`stale`, since the installed bytes never reach a session) or on the `--plugin-dir` registration proof row (`bundle.state: 'failed'` with `errors`, replacing the `registered` verdict). The message carries the host's `errors` verbatim. | Same as `AB7006`: fix the artifact, rebuild, and reinstall with `--replace`. |
+| `AB7325` | error | Doctor found `errors` on the plugin's row in `claude plugin list --json` (inventory entry `state: 'failed'` with `errors`; `doctor --from` comparison `status: 'load-failed'` with `errors` instead of `current`/`stale`, since the installed bytes never reach a session) or on the `--plugin-dir` registration proof row (`bundle.state: 'failed'` with `errors`, replacing the `registered` verdict). The message carries the host's `errors` verbatim. `build` and `validate --artifact` emit the same code from their load check (see "Claude Code host validation"). | Same as `AB7006`: fix the artifact, rebuild, and reinstall with `--replace`. |
+
+## Disabled Claude install (`AB7327`)
+
+`claude plugin disable ` (or the `/plugin` menu) keeps a plugin
+installed but switched off: its row in `claude plugin list --json` reports
+`enabled: false`, and none of its hooks, MCP servers, or skills reach a session
+until `claude plugin enable` runs (Claude Code docs, "Plugins reference" →
+"plugin enable" / "plugin disable"). Reinstalling, even with `--replace`, does
+not enable it. `agent-bundle doctor --host claude` reads the flag: the
+inventory entry carries `enabled: false` with `state: 'disabled'` (instead of
+`installed`), and a `--from` comparison of that copy carries `enabled: false`
+next to its content verdict — a disabled copy can still be `current` or
+`stale`, and both facts are reported. Rows without a boolean `enabled` carry no
+flag and are `installed`. A row with `errors` is `failed` (`AB7325`) whatever
+its `enabled` value. A plugin that ships `defaultEnabled: false` in
+`plugin.json` installs disabled by design ("Plugins reference" → "Default
+enablement"); Doctor still reports `AB7327` for it, because the recovery is the
+same `claude plugin enable`.
+
+| Code | Severity | Trigger | Recovery |
+| --- | --- | --- | --- |
+| `AB7327` | warning | `doctor --host claude --from ` compared an installed copy whose row reports `enabled: false`. The message names the plugin, version, install path, and scope. | Run `claude plugin enable @ [--scope ]` (or use `/plugin` in a session), then rerun Doctor; reinstalling does not enable a disabled plugin. |
The JSON report exposes the same facts: `hosts[].inventory.findings[].errors`,
`hosts[].bundle.errors`, and `hosts[].bundle.comparison.errors`. The text
diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts
index a4e7630da..8314b5808 100644
--- a/packages/agent-bundle/src/api.ts
+++ b/packages/agent-bundle/src/api.ts
@@ -107,6 +107,7 @@ import type { ProjectContext } from './core/project-context.ts';
import type { NormalizedPlugin } from './core/types.ts';
import {
validateClaudePlugin,
+ type ClaudePluginCommandRunner,
type ClaudePluginValidationReport,
} from './host-contracts/claude-plugin-validation.ts';
import {
@@ -430,6 +431,18 @@ export interface InvalidInspectResult {
export type InspectResult = ReadyInspectResult | InvalidInspectResult;
export interface BuildOptions extends ProjectOptions {
+ /**
+ * After the artifact is written, run the installed Claude developer
+ * validator (`claude plugin validate --strict` against the emitted
+ * `plugin.json` and `marketplace.json`) for every built `claude` and
+ * `plugin` target, exactly as `validate --artifact` does. The CLI `build`
+ * command requests this by default; programmatic artifact operations
+ * (temporary artifacts, dev, evals) never do. Without `claude` on `PATH`
+ * the run costs one failed spawn and reports a single `AB6019` info.
+ */
+ readonly hostValidation?: boolean;
+ /** Injectable only to make the Claude host validator deterministic in tests; production always spawns `claude`. */
+ readonly hostValidationRunner?: ClaudePluginCommandRunner;
readonly output?: string;
/**
* Also produce the framework-owned npm package build (`dist/` bin + lib
@@ -438,11 +451,16 @@ export interface BuildOptions extends ProjectOptions {
* operations (temporary artifacts, dev, evals) never do.
*/
readonly packageOutputs?: boolean;
+ /** Promote host-tool warnings to errors (`hostValidation` only). */
+ readonly strict?: boolean;
}
export interface BuildProjectResult {
readonly build: BuildResult;
+ /** Project diagnostics followed by the host-validation findings (`AB6019`–`AB6022`) when `hostValidation` ran. */
readonly diagnostics: readonly Diagnostic[];
+ /** One report per built `claude`/`plugin` target; present only when `hostValidation` was requested. */
+ readonly hostValidation?: readonly ClaudePluginValidationReport[];
readonly model: NormalizedPlugin;
readonly packageBuild?: PackageBuildResult;
readonly projectContext: ProjectContext;
@@ -1070,15 +1088,58 @@ export const build = async (options: BuildOptions): Promise
});
if (packageBuild !== undefined) assertPackageOutputSources(packageBuild, projectContext);
}
+ const hostValidation = options.hostValidation === true
+ ? await buildHostValidation(result.manifest.targets.map((target) => target.name), output, options)
+ : undefined;
return Object.freeze({
build: result,
- diagnostics: prepared.diagnostics,
+ diagnostics: hostValidation === undefined
+ ? prepared.diagnostics
+ : freezeDiagnostics([...prepared.diagnostics, ...hostValidation.diagnostics]),
+ ...(hostValidation === undefined ? {} : { hostValidation: hostValidation.reports }),
model,
...(packageBuild === undefined ? {} : { packageBuild }),
projectContext,
});
};
+const claudeValidatedTargets: ReadonlySet = new Set(['claude', 'plugin']);
+
+/**
+ * `build --host-validation`: the Claude developer validator (`plugin validate`
+ * over both manifests, then the `--plugin-dir … plugin list --json` load check)
+ * over every built `claude`/`plugin` target (#476). Targets run one after
+ * another: once the CLI proves absent (`AB6019`), the remaining targets are
+ * marked `unavailable` without another spawn, so a build without `claude` on
+ * `PATH` costs one failed spawn and reports the skip once.
+ */
+const buildHostValidation = async (
+ targets: readonly string[],
+ output: string,
+ options: Pick,
+): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly reports: readonly ClaudePluginValidationReport[] }> => {
+ const reports: ClaudePluginValidationReport[] = [];
+ let unavailable = false;
+ for (const target of targets.filter((name) => claudeValidatedTargets.has(name))) {
+ if (unavailable) {
+ reports.push(Object.freeze({ diagnostics: freezeDiagnostics([]), host: 'claude', status: 'unavailable', target }));
+ continue;
+ }
+ const report = await validateClaudePlugin({
+ pluginDirectory: join(output, target),
+ ...(options.hostValidationRunner === undefined ? {} : { run: options.hostValidationRunner }),
+ ...(options.strict === undefined ? {} : { strict: options.strict }),
+ target,
+ });
+ reports.push(report);
+ unavailable = report.status === 'unavailable';
+ }
+ return Object.freeze({
+ diagnostics: freezeDiagnostics(reports.flatMap((report) => report.diagnostics)),
+ reports: Object.freeze(reports),
+ });
+};
+
const execFile = promisify(executeFile);
export const prepack = async (options: BuildOptions): Promise => {
diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts
index 947d70811..c2e95950f 100644
--- a/packages/agent-bundle/src/cli.ts
+++ b/packages/agent-bundle/src/cli.ts
@@ -60,6 +60,8 @@ interface CliSignalSource {
}
export interface CliDependencies {
+ /** Injectable only to verify the build host-validation CLI policy without an installed host. */
+ readonly build?: typeof build;
readonly installBundle?: typeof installBundle;
readonly prepack?: typeof prepack;
readonly runDoctor?: typeof runDoctor;
@@ -274,7 +276,19 @@ const writeMachine = (output: Output, result: unknown): void => {
};
const writeHumanBuild = (output: Output, result: Awaited>): void => {
+ // Errors abort the command before this writer runs, so any diagnostics
+ // reaching it are informational nudges or host-validation warnings.
+ for (const diagnostic of result.diagnostics) {
+ output.write(`${diagnostic.code} (${diagnostic.severity}): ${diagnostic.message}\n`);
+ }
output.write(`Built ${result.model.metadata.name} to ${result.build.outputRoot}\n`);
+ for (const report of result.hostValidation ?? []) {
+ output.write(
+ `Host validation (${report.target}): ${report.status}` +
+ `${report.version === undefined ? '' : ` (Claude Code ${report.version})`}` +
+ `${report.load === undefined ? '' : `, load check ${report.load.status}`}\n`,
+ );
+ }
if (result.packageBuild !== undefined) {
output.write(`Package build (${result.packageBuild.files.length} file(s)) at ${result.packageBuild.outputRoot}\n`);
}
@@ -293,11 +307,12 @@ const writeHumanInstall = (output: Output, result: InstallResult): void => {
};
const describeInstallComparison = (comparison: DoctorInstallComparison): string => {
- const installed = comparison.installedContentHash === undefined
+ const installed = (comparison.installedContentHash === undefined
? ''
: `; installed ${comparison.installedVersion ?? 'unknown version'} ` +
`content ${shortContentHash(comparison.installedContentHash)}, ` +
- `artifact content ${shortContentHash(comparison.artifactContentHash)}`;
+ `artifact content ${shortContentHash(comparison.artifactContentHash)}`) +
+ (comparison.enabled === false ? '; disabled by the host' : '');
switch (comparison.status) {
case 'current':
return `current${installed}`;
@@ -344,6 +359,12 @@ const writeHumanDoctor = (output: Output, result: DoctorReport): void => {
if (host.bundle.comparison !== undefined) {
output.write(` installed copy: ${describeInstallComparison(host.bundle.comparison)}\n`);
}
+ for (const validation of host.bundle.hostValidation ?? []) {
+ output.write(
+ ` host validation (${validation.copy} ${validation.pluginDirectory}` +
+ `${validation.scope === undefined ? '' : `, scope ${validation.scope}`}): ${validation.status}\n`,
+ );
+ }
}
const reports = [
...host.inventory.findings.map((finding) => finding.durableState),
@@ -605,10 +626,23 @@ export const runCli = async (
const buildCommand = configureSourceOptions(
program.command('build').description('Build a validated Agent Bundle artifact'),
- ).option('--output ', 'Artifact output path relative to --root (overrides config output.distPath; default artifact, since dist is the npm package build output)');
+ )
+ .option('--output ', 'Artifact output path relative to --root (overrides config output.distPath; default artifact, since dist is the npm package build output)')
+ .option('--host-validation', 'Run the installed Claude developer validator over built claude and plugin targets', true)
+ .option('--no-host-validation', 'Skip the installed Claude developer validator')
+ .option('--strict', 'Promote host-tool warnings to errors');
buildCommand.action(async (options: BuildCommandOptions) => {
const { build } = await import('./api.ts');
- const result = await build({ ...projectOptions(options), output: options.output, packageOutputs: true });
+ const result = await (dependencies.build ?? build)({
+ ...projectOptions(options),
+ hostValidation: options.hostValidation,
+ output: options.output,
+ packageOutputs: true,
+ strict: options.strict,
+ });
+ if (result.diagnostics.some((diagnostic) => diagnostic.severity === 'error')) {
+ throw new DiagnosticError(result.diagnostics);
+ }
if (options.json === true) writeMachine(stdout, result);
else writeHumanBuild(stdout, result);
});
diff --git a/packages/agent-bundle/src/contracts/discovery.ts b/packages/agent-bundle/src/contracts/discovery.ts
index 6696c92de..ce1766e8a 100644
--- a/packages/agent-bundle/src/contracts/discovery.ts
+++ b/packages/agent-bundle/src/contracts/discovery.ts
@@ -4,6 +4,7 @@ export type DiscoveryInventoryStatus = 'known' | 'skipped' | 'unknown';
export type DiscoveryFindingState =
| 'conflicted'
| 'corrupt'
+ | 'disabled'
| 'drifted'
| 'failed'
| 'installed'
diff --git a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts
index d28b17a29..400eb388c 100644
--- a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts
+++ b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts
@@ -19,9 +19,30 @@ type ClaudePluginTermination = 'output-limit' | 'timed-out';
export type ClaudePluginValidationStatus = 'failed' | 'passed' | 'unavailable' | 'warnings';
+/**
+ * Verdict of `claude --plugin-dir plugin list --json`: `loaded`
+ * (the plugin's `@inline` row has no `errors`), `refused` (the row
+ * carries `errors[]`, reported as `AB7325`), `unregistered` (no row for the
+ * plugin, `AB7311`), or `failed` (the listing itself could not be read, `AB6022`).
+ */
+export type ClaudePluginLoadStatus = 'failed' | 'loaded' | 'refused' | 'unregistered';
+
+export interface ClaudePluginLoadCheck {
+ /** The host's own load errors, verbatim from the row's `errors` array; present only when `refused`. */
+ readonly errors?: readonly string[];
+ readonly status: ClaudePluginLoadStatus;
+}
+
export interface ClaudePluginValidationReport {
readonly diagnostics: readonly Diagnostic[];
readonly host: 'claude';
+ /**
+ * The load check that follows the validation runs. Absent when the CLI was
+ * unavailable, a validation run itself failed (`AB6022`), the caller opted
+ * out, or the bundle has no readable `.claude-plugin/plugin.json` name to
+ * look for in the listing.
+ */
+ readonly load?: ClaudePluginLoadCheck;
readonly status: ClaudePluginValidationStatus;
readonly target: string;
readonly version?: string;
@@ -35,12 +56,26 @@ export type ClaudePluginCommandRunner = (
export interface ValidateClaudePluginOptions {
readonly executable?: string;
+ /**
+ * Also run `claude --plugin-dir plugin list --json` after the
+ * validation runs and read the plugin's row (default `true`). `plugin
+ * validate --strict` accepts manifests Claude Code then refuses to load
+ * (observed 2.1.250–2.1.259); the listing's `errors[]` is the only load
+ * verdict. Doctor passes `false` because it runs its own registration proof.
+ */
+ readonly loadCheck?: boolean;
readonly pluginDirectory: string;
/** Injectable proof seam. Production always uses the bounded process runner. */
readonly run?: ClaudePluginCommandRunner;
/** Promote host warnings to Agent Bundle errors. Claude itself always runs with `--strict`. */
readonly strict?: boolean;
readonly target: string;
+ /**
+ * The `claude --version` number the caller already probed from the same
+ * executable; skips this run's own probe (Doctor validates several
+ * directories per host and probes once).
+ */
+ readonly version?: string;
}
export interface ValidateClaudePluginFilesOptions {
@@ -58,25 +93,56 @@ const runClaudeCommand: ClaudePluginCommandRunner = (request) => runBoundedChild
const versionFrom = (output: string): string | undefined =>
/(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/u.exec(output)?.[1];
+type ClaudeHostDiagnosticCode = 'AB6019' | 'AB6020' | 'AB6021' | 'AB6022' | 'AB7311' | 'AB7325';
+
+const recoveryFor = (code: ClaudeHostDiagnosticCode): string => {
+ switch (code) {
+ case 'AB6019':
+ return 'Install Claude Code and ensure `claude` is on PATH, then rerun artifact validation.';
+ case 'AB6020':
+ case 'AB6021':
+ return 'Run `claude plugin validate /.claude-plugin/plugin.json --strict`, ' +
+ 'repair the reported Claude artifact, and rebuild.';
+ case 'AB6022':
+ return 'Verify the Claude CLI starts and responds, then rerun ' +
+ '`claude plugin validate /.claude-plugin/plugin.json --strict`.';
+ case 'AB7311':
+ return 'Inspect `claude --plugin-dir plugin list --json` and register the intended bundle.';
+ case 'AB7325':
+ return 'Fix the artifact so `claude --plugin-dir plugin list --json` reports no `errors` for it, ' +
+ 'then rebuild.';
+ default: {
+ const exhaustive: never = code;
+ throw new TypeError(`Unknown Claude host diagnostic ${String(exhaustive)}.`);
+ }
+ }
+};
+
const diagnostic = (
- code: 'AB6019' | 'AB6020' | 'AB6021' | 'AB6022',
+ code: ClaudeHostDiagnosticCode,
message: string,
severity: DiagnosticSeverity,
target: string,
): Diagnostic => Object.freeze({
code,
message,
- recovery: code === 'AB6019'
- ? 'Install Claude Code and ensure `claude` is on PATH, then rerun artifact validation.'
- : code === 'AB6022'
- ? 'Verify the Claude CLI starts and responds, then rerun ' +
- '`claude plugin validate /.claude-plugin/plugin.json --strict`.'
- : 'Run `claude plugin validate /.claude-plugin/plugin.json --strict`, ' +
- 'repair the reported Claude artifact, and rebuild.',
+ recovery: recoveryFor(code),
severity,
target,
});
+/**
+ * Reads a Claude `plugin list --json` row's `errors` array. Healthy rows omit
+ * the key (Claude Code 2.1.259); a refused row carries nonempty strings.
+ * Anything else is treated as "no readable errors" rather than a failure, so
+ * an unexpected shape cannot mask the row as uninstalled.
+ */
+export const claudePluginRowErrors = (row: Readonly>): readonly string[] => {
+ const errors = row['errors'];
+ if (!Array.isArray(errors)) return [];
+ return Object.freeze(errors.filter((error): error is string => typeof error === 'string' && error.trim().length > 0));
+};
+
const matchingDocumentPaths = async (
root: string,
contractPath: string,
@@ -401,44 +467,71 @@ const validationTargets = async (pluginDirectory: string): Promise => {
- const pluginDirectory = resolve(options.pluginDirectory);
- const executable = options.executable ?? 'claude';
- const run = options.run ?? runClaudeCommand;
- const cwd = dirname(pluginDirectory);
- let version: string | undefined;
+interface ClaudeCommandContext {
+ readonly cwd: string;
+ readonly executable: string;
+ readonly run: ClaudePluginCommandRunner;
+}
+
+type ClaudeVersionProbe =
+ | { readonly report: ClaudePluginValidationReport }
+ | { readonly version: string | undefined };
+
+/** `claude --version`: the report to return when the CLI is missing or unresponsive, else its version number. */
+const probeClaudeVersion = async (context: ClaudeCommandContext, target: string): Promise => {
try {
- const probe = await run(Object.freeze({ args: Object.freeze(['--version']), cwd, executable }));
+ const probe = await context.run(Object.freeze({
+ args: Object.freeze(['--version']),
+ cwd: context.cwd,
+ executable: context.executable,
+ }));
if (probe.exitCode !== 0 || probe.termination !== undefined) {
- return failedReport(
- probe.termination === 'timed-out'
- ? 'Claude CLI version probe timed out.'
- : probe.termination === 'output-limit'
- ? 'Claude CLI version probe exceeded its output limit.'
- : `Claude CLI version probe exited with code ${probe.exitCode ?? 'unknown'}.`,
- options.target,
- undefined,
- );
+ return {
+ report: failedReport(
+ probe.termination === 'timed-out'
+ ? 'Claude CLI version probe timed out.'
+ : probe.termination === 'output-limit'
+ ? 'Claude CLI version probe exceeded its output limit.'
+ : `Claude CLI version probe exited with code ${probe.exitCode ?? 'unknown'}.`,
+ target,
+ undefined,
+ ),
+ };
}
- version = versionFrom(`${probe.stdout}\n${probe.stderr}`);
+ return { version: versionFrom(`${probe.stdout}\n${probe.stderr}`) };
} catch (error) {
if (!isErrno(error, 'ENOENT')) {
- return failedReport('Claude CLI version probe could not be started.', options.target, undefined);
+ return { report: failedReport('Claude CLI version probe could not be started.', target, undefined) };
}
- return Object.freeze({
- diagnostics: freezeDiagnostics([diagnostic(
- 'AB6019',
- 'The Claude CLI is not installed or is not on PATH; host artifact validation was skipped.',
- 'info',
- options.target,
- )]),
- host: 'claude',
- status: 'unavailable',
- target: options.target,
- });
+ return {
+ report: Object.freeze({
+ diagnostics: freezeDiagnostics([diagnostic(
+ 'AB6019',
+ 'The Claude CLI is not installed or is not on PATH; host artifact validation was skipped.',
+ 'info',
+ target,
+ )]),
+ host: 'claude',
+ status: 'unavailable',
+ target,
+ }),
+ };
}
+};
+
+export const validateClaudePlugin = async (
+ options: ValidateClaudePluginOptions,
+): Promise => {
+ const pluginDirectory = resolve(options.pluginDirectory);
+ const executable = options.executable ?? 'claude';
+ const run = options.run ?? runClaudeCommand;
+ const cwd = dirname(pluginDirectory);
+ const context: ClaudeCommandContext = Object.freeze({ cwd, executable, run });
+ const probed = options.version === undefined
+ ? await probeClaudeVersion(context, options.target)
+ : { version: options.version };
+ if ('report' in probed) return probed.report;
+ const version = probed.version;
const jsonReport = claudeSupportsJsonValidationReport(version);
const findingsByRun: Partial> = {};
@@ -498,17 +591,127 @@ export const validateClaudePlugin = async (
const pluginFindings = findingsByRun.plugin ?? [];
const marketplaceFindings = withoutDuplicateManifestFindings(pluginFindings, findingsByRun.marketplace ?? []);
- const diagnostics = freezeDiagnostics(
- [...pluginFindings, ...marketplaceFindings]
+ const load = options.loadCheck === false
+ ? undefined
+ : await claudeLoadCheck(pluginDirectory, context, options.target, options.strict === true);
+ const diagnostics = freezeDiagnostics([
+ ...[...pluginFindings, ...marketplaceFindings]
.map((finding) => findingDiagnostic(finding, options.strict === true, options.target)),
- );
+ ...(load?.diagnostics ?? []),
+ ]);
const failed = diagnostics.some((entry) => entry.severity === 'error');
const blocking = diagnostics.some((entry) => entry.severity !== 'info');
return Object.freeze({
diagnostics,
host: 'claude',
+ ...(load === undefined ? {} : { load: load.check }),
status: failed ? 'failed' : blocking ? 'warnings' : 'passed',
target: options.target,
...(version === undefined ? {} : { version }),
});
};
+
+interface ClaudeLoadCheckOutcome {
+ readonly check: ClaudePluginLoadCheck;
+ readonly diagnostics: readonly Diagnostic[];
+}
+
+/** The `name` of `/.claude-plugin/plugin.json`, or `undefined` when the manifest cannot name a row. */
+const claudePluginManifestName = async (pluginDirectory: string): Promise => {
+ try {
+ const manifest: unknown = JSON.parse(await readFile(join(pluginDirectory, '.claude-plugin', 'plugin.json'), 'utf8'));
+ const name = isRecord(manifest) ? manifest['name'] : undefined;
+ return typeof name === 'string' && name.trim() !== '' ? name : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+const claudeLoadFailure = (message: string, target: string): ClaudeLoadCheckOutcome => Object.freeze({
+ check: Object.freeze({ status: 'failed' }),
+ diagnostics: freezeDiagnostics([diagnostic('AB6022', message, 'error', target)]),
+});
+
+/**
+ * The load verdict `plugin validate` cannot give: `claude --plugin-dir
+ * plugin list --json` registers the directory as `@inline` and reports
+ * in that row's `errors[]` why a session would refuse it (#479: `--strict`
+ * accepts manifests Claude then refuses to load). Read-only — the listing
+ * writes nothing under `~/.claude`. Returns `undefined` when the bundle has no
+ * readable manifest name: the validation runs already reported that manifest.
+ */
+const claudeLoadCheck = async (
+ pluginDirectory: string,
+ context: ClaudeCommandContext,
+ target: string,
+ strict: boolean,
+): Promise => {
+ const name = await claudePluginManifestName(pluginDirectory);
+ if (name === undefined) return undefined;
+ const label = 'Claude plugin load check (`claude --plugin-dir plugin list --json`)';
+ let result: ClaudePluginCommandResult;
+ try {
+ result = await context.run(Object.freeze({
+ args: Object.freeze(['--plugin-dir', pluginDirectory, 'plugin', 'list', '--json']),
+ cwd: context.cwd,
+ executable: context.executable,
+ }));
+ } catch {
+ return claudeLoadFailure(`${label} could not be started.`, target);
+ }
+ if (result.termination !== undefined) {
+ return claudeLoadFailure(
+ result.termination === 'timed-out' ? `${label} timed out.` : `${label} exceeded its output limit.`,
+ target,
+ );
+ }
+ if (result.exitCode !== 0) {
+ const reason = result.stderr.trim();
+ return claudeLoadFailure(
+ `${label} exited with code ${result.exitCode ?? 'unknown'}${reason === '' ? '' : `: ${reason}`}.`,
+ target,
+ );
+ }
+ let rows: unknown;
+ try {
+ rows = JSON.parse(result.stdout);
+ } catch {
+ return claudeLoadFailure(`${label} did not return JSON.`, target);
+ }
+ if (!Array.isArray(rows)) return claudeLoadFailure(`${label} did not return a JSON array.`, target);
+ const id = `${name}@inline`;
+ const row = rows.find((entry): entry is Record => isRecord(entry) && entry['id'] === id);
+ if (row === undefined) {
+ return Object.freeze({
+ check: Object.freeze({ status: 'unregistered' }),
+ diagnostics: freezeDiagnostics([diagnostic(
+ 'AB7311',
+ `Claude Code did not register ${id} from ${JSON.stringify(pluginDirectory)}: ` +
+ '`claude --plugin-dir plugin list --json` listed no row for it.',
+ 'error',
+ target,
+ )]),
+ });
+ }
+ const errors = claudePluginRowErrors(row);
+ if (errors.length === 0) return Object.freeze({ check: Object.freeze({ status: 'loaded' }), diagnostics: freezeDiagnostics([]) });
+ // A declared `dependencies` entry the validating machine lacks is a property of this machine,
+ // not of the artifact: Claude refuses the load here, and would load it where the dependency is
+ // installed. Report it, but let the build finish unless `--strict` asked otherwise.
+ const environmental = errors.every((error) => claudeMissingDependencyError.test(error));
+ return Object.freeze({
+ check: Object.freeze({ errors, status: 'refused' }),
+ diagnostics: freezeDiagnostics([diagnostic(
+ 'AB7325',
+ `Claude Code refused to load ${id} from ${JSON.stringify(pluginDirectory)} although ` +
+ `\`plugin validate --strict\` accepted it` +
+ `${environmental ? ' (a declared dependency is not installed on this machine)' : ''}; ` +
+ `the host reported: ${errors.join(' | ')}`,
+ environmental && !strict ? 'warning' : 'error',
+ target,
+ )]),
+ });
+};
+
+/** Claude Code 2.1.260: `Dependency "audit-logger@acme-shared" is not installed — run \`claude plugin install …\``. */
+const claudeMissingDependencyError = /^Dependency "[^"]+" is not installed\b/u;
diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts
index 6c3593bec..67bb9e223 100644
--- a/packages/agent-bundle/src/install/doctor.ts
+++ b/packages/agent-bundle/src/install/doctor.ts
@@ -11,7 +11,11 @@ import {
import { mapConcurrent } from '../core/async.ts';
import { isErrno } from '../core/errors.ts';
import { exists } from '../core/paths.ts';
-import { validateClaudePluginFiles } from '../host-contracts/claude-plugin-validation.ts';
+import {
+ validateClaudePlugin,
+ validateClaudePluginFiles,
+ type ClaudePluginValidationReport,
+} from '../host-contracts/claude-plugin-validation.ts';
import { validateCodexPluginFiles } from '../host-contracts/codex-plugin-validation.ts';
import {
validateCursorPluginFiles,
@@ -59,6 +63,7 @@ export type DoctorInventoryStatus = 'known' | 'skipped' | 'unknown';
export type DoctorFindingState =
| 'conflicted'
| 'corrupt'
+ | 'disabled'
| 'drifted'
| 'failed'
| 'installed'
@@ -99,6 +104,12 @@ export interface DoctorFinding {
/** Git commit of a staged Cursor marketplace repository. */
readonly commit?: string;
readonly durableState?: DoctorDurableStateReport;
+ /**
+ * Claude only: the row's `enabled` flag from `claude plugin list --json`.
+ * `false` sets `state: 'disabled'` — the copy is installed but switched off,
+ * so none of it reaches a session until `claude plugin enable` runs.
+ */
+ readonly enabled?: boolean;
readonly entry?: string;
/**
* The host's own load errors, verbatim (`claude plugin list --json` `errors`);
@@ -173,6 +184,8 @@ export type DoctorInstallComparisonStatus =
*/
export interface DoctorInstallComparison {
readonly artifactContentHash: string;
+ /** Claude only: `false` when the host lists the compared copy as disabled (`AB7327`). */
+ readonly enabled?: boolean;
/** Host load errors for a `load-failed` copy, verbatim from `claude plugin list --json`. */
readonly errors?: readonly string[];
readonly installedContentHash?: string;
@@ -183,10 +196,25 @@ export interface DoctorInstallComparison {
readonly status: DoctorInstallComparisonStatus;
}
+/**
+ * One `claude plugin validate` pass (the same runner `validate --artifact`
+ * uses: `plugin.json` then `marketplace.json`, `--strict`, `--json` on
+ * 2.1.259+) over the built bundle or one installed copy of it.
+ */
+export interface DoctorHostValidation extends ClaudePluginValidationReport {
+ /** `bundle`: the `--from` bundle root; `installed`: a copy the host lists for this plugin. */
+ readonly copy: 'bundle' | 'installed';
+ readonly pluginDirectory: string;
+ /** Claude install scope of an `installed` copy. */
+ readonly scope?: string;
+}
+
export interface DoctorHostReport {
readonly bundle?: DoctorFinding & {
readonly bundleRoot?: string;
readonly comparison?: DoctorInstallComparison;
+ /** Claude only: host validator reports for the bundle and every installed copy, when `claude` is available. */
+ readonly hostValidation?: readonly DoctorHostValidation[];
readonly marketplace?: string;
};
readonly diagnostics: readonly Diagnostic[];
@@ -920,12 +948,16 @@ const publicHostInventory = (
// A row with `errors` is installed but refused by Claude Code (no hooks, MCP servers, or skills
// reach the session); the inventory says so instead of listing it as a healthy install.
const errors = claudePluginRowErrors(row);
+ // `enabled: false` is a copy the user switched off (`claude plugin disable`): installed, but no
+ // hooks, MCP servers, or skills reach a session until it is enabled again (#476).
+ const enabled = typeof row['enabled'] === 'boolean' ? row['enabled'] : undefined;
findings.push({
+ ...(enabled === undefined ? {} : { enabled }),
entry: `${row['id']} (${row['scope']})`,
...(errors.length === 0 ? {} : { errors }),
name: row['id'].slice(0, row['id'].indexOf('@') === -1 ? undefined : row['id'].indexOf('@')),
path: row['installPath'],
- state: errors.length === 0 ? 'installed' : 'failed',
+ state: errors.length > 0 ? 'failed' : enabled === false ? 'disabled' : 'installed',
version: row['version'],
});
}
@@ -1029,6 +1061,28 @@ const hostLoadFailureDiagnostic = (
host,
);
+/**
+ * `AB7327`: Claude lists this copy with `enabled: false`. The bytes may be
+ * current, but a disabled plugin contributes no hooks, MCP servers, or skills
+ * to a session, and no install or rebuild changes that — only
+ * `claude plugin enable` does (plugins-reference §plugin enable).
+ */
+const disabledInstallDiagnostic = (
+ identity: PluginIdentity,
+ version: string,
+ installPath: string,
+ scope: string | undefined,
+): Diagnostic => diagnostic(
+ 'AB7327',
+ `claude lists ${identity.name}@${version} at ${installPath}${scope === undefined ? '' : ` (scope ${scope})`} ` +
+ 'as disabled (`enabled: false`): the copy is installed but none of it loads in a session.',
+ `Run \`claude plugin enable ${identity.name}${identity.marketplace === undefined ? '' : `@${identity.marketplace}`}` +
+ `${scope === undefined ? '' : ` --scope ${scope}`}\` (or \`/plugin\` in a session), then rerun Doctor; ` +
+ 'reinstalling does not enable a disabled plugin.',
+ 'warning',
+ 'claude',
+);
+
/**
* Compares the copy a public host CLI caches for this plugin against the
* built artifact. Unusable inventories degrade to `unknown` (Doctor never
@@ -1065,11 +1119,18 @@ const publicHostInstallComparison = async (
for (const entry of inventory.entries) {
const scoped = entry.scope === undefined ? '' : ` (scope ${entry.scope})`;
const replaceHint = entry.scope === undefined ? '' : ` --scope ${entry.scope}`;
+ const enabledField = entry.enabled === undefined ? {} : { enabled: entry.enabled };
+ if (entry.enabled === false) {
+ // Installed and possibly current, yet switched off: the content comparison still runs (a stale
+ // disabled copy is both), but the report must not read as a healthy install (#476).
+ diagnostics.push(disabledInstallDiagnostic(identity, entry.version, entry.installPath, entry.scope));
+ }
if (entry.errors !== undefined && entry.errors.length > 0) {
// The host lists the copy but refused to load it: content comparison is moot because none of the
// plugin reaches a session. Report the host's own message rather than `current`/`stale` (#464).
comparisons.push(Object.freeze({
artifactContentHash: artifact.hash,
+ ...enabledField,
errors: entry.errors,
installedPath: entry.installPath,
installedVersion: entry.version,
@@ -1085,6 +1146,7 @@ const publicHostInstallComparison = async (
} catch (error) {
comparisons.push(Object.freeze({
artifactContentHash: artifact.hash,
+ ...enabledField,
installedPath: entry.installPath,
installedVersion: entry.version,
ownership: 'host',
@@ -1108,6 +1170,7 @@ const publicHostInstallComparison = async (
: 'stale';
comparisons.push(Object.freeze({
artifactContentHash: artifact.hash,
+ ...enabledField,
installedContentHash: installed.hash,
installedPath: entry.installPath,
installedVersion: entry.version,
@@ -1449,7 +1512,47 @@ interface PublicHostContext {
readonly run: DoctorCommandRunner;
}
-/** Claude: inline registration proof plus the installed cache copy compared against the artifact. */
+/**
+ * The Claude developer validator over one directory, through the same runner
+ * `validate --artifact` uses (`plugin.json` run, then `marketplace.json`;
+ * `--strict`; `--json` on 2.1.259+). Findings keep their `AB6019`–`AB6022`
+ * codes; the message names which copy they were found in, since Doctor
+ * validates the `--from` bundle and every installed copy the host lists (#476).
+ */
+const claudeHostValidation = async (
+ copy: DoctorHostValidation['copy'],
+ pluginDirectory: string,
+ scope: string | undefined,
+ probe: DoctorHostProbe,
+ run: DoctorCommandRunner,
+): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly validation: DoctorHostValidation }> => {
+ // Doctor already holds the load verdicts (`claudeRegistration` for the bundle, the inventory
+ // rows' `errors[]` → AB7325 for installed copies) and the probed version, so the runner
+ // neither lists nor probes again.
+ const report = await validateClaudePlugin({
+ loadCheck: false,
+ pluginDirectory,
+ run,
+ target: 'claude',
+ ...(probe.version === undefined ? {} : { version: probe.version }),
+ });
+ const location = copy === 'bundle'
+ ? `Bundle at ${JSON.stringify(pluginDirectory)}`
+ : `Installed copy at ${JSON.stringify(pluginDirectory)}${scope === undefined ? '' : ` (scope ${scope})`}`;
+ return {
+ diagnostics: freezeDiagnostics(report.diagnostics.map((entry) => Object.freeze({
+ ...entry,
+ message: `${location}: ${entry.message}`,
+ }))),
+ validation: Object.freeze({ ...report, copy, pluginDirectory, ...(scope === undefined ? {} : { scope }) }),
+ };
+};
+
+/**
+ * Claude: inline registration proof, the installed cache copy compared against
+ * the artifact, and `claude plugin validate` over the bundle and each installed
+ * copy. Doctor stays read-only: the validator only reads the named files.
+ */
const claudeBundle = async (
identity: PluginIdentity,
probe: DoctorHostProbe,
@@ -1460,9 +1563,23 @@ const claudeBundle = async (
const artifact = await treeInventory(identity.bundleRoot);
const inventory = readPublicHostInventory('claude', identity, context.listing, context.environment, context.home);
const compared = await publicHostInstallComparison('claude', identity, artifact, inventory);
+ const validated = [await claudeHostValidation('bundle', identity.bundleRoot, undefined, probe, context.run)];
+ if (inventory.status === 'available') {
+ for (const entry of inventory.entries) {
+ validated.push(await claudeHostValidation('installed', entry.installPath, entry.scope, probe, context.run));
+ }
+ }
return {
- diagnostics: freezeDiagnostics([...registration.diagnostics, ...compared.diagnostics]),
- finding: Object.freeze({ ...registration.finding, comparison: compared.comparison }),
+ diagnostics: freezeDiagnostics([
+ ...registration.diagnostics,
+ ...compared.diagnostics,
+ ...validated.flatMap((entry) => entry.diagnostics),
+ ]),
+ finding: Object.freeze({
+ ...registration.finding,
+ comparison: compared.comparison,
+ hostValidation: Object.freeze(validated.map((entry) => entry.validation)),
+ }),
};
};
diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts
index b78c602ee..4568db7e1 100644
--- a/packages/agent-bundle/src/install/install.ts
+++ b/packages/agent-bundle/src/install/install.ts
@@ -10,6 +10,7 @@ import { errorMessage, isErrno } from '../core/errors.ts';
import { exists } from '../core/paths.ts';
import { runPromise } from '../effect/boundary.ts';
import { liftPromise } from '../effect/lift.ts';
+import { claudePluginRowErrors } from '../host-contracts/claude-plugin-validation.ts';
import { stageCursorMarketplace } from './cursor-marketplace.ts';
import {
compareInstalledTree,
@@ -259,6 +260,13 @@ export const publicHostCacheRoot = (
: join(environment['CODEX_HOME'] ?? join(home, '.codex'), 'plugins', 'cache');
export interface PublicHostInstalledEntry {
+ /**
+ * Claude only: the row's `enabled` flag. `false` means the copy is installed
+ * but switched off (`claude plugin disable`), so none of it reaches a
+ * session until `claude plugin enable` runs; absent when the row carries no
+ * boolean.
+ */
+ readonly enabled?: boolean;
/**
* Claude only: the host's own load errors for this copy, verbatim from the
* row's `errors` array (present and nonempty only when Claude Code refused
@@ -273,17 +281,7 @@ export interface PublicHostInstalledEntry {
readonly version: string;
}
-/**
- * Reads a Claude `plugin list --json` row's `errors` array. Healthy rows omit
- * the key (Claude Code 2.1.259); a refused row carries nonempty strings.
- * Anything else is treated as "no readable errors" rather than a failure, so
- * an unexpected shape cannot mask the row as uninstalled.
- */
-export const claudePluginRowErrors = (row: Readonly>): readonly string[] => {
- const errors = row['errors'];
- if (!Array.isArray(errors)) return [];
- return Object.freeze(errors.filter((error): error is string => typeof error === 'string' && error.trim().length > 0));
-};
+export { claudePluginRowErrors };
/**
* The host's own answer to "is this plugin installed, and where": usable, or
@@ -345,6 +343,7 @@ export const parsePublicHostInventory = (
if (options.scope !== undefined && row['scope'] !== options.scope) continue;
const errors = claudePluginRowErrors(row);
entries.push({
+ ...(typeof row['enabled'] === 'boolean' ? { enabled: row['enabled'] } : {}),
...(errors.length === 0 ? {} : { errors }),
installPath: row['installPath'],
scope: row['scope'],
diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts
index 74e073d5a..a6ed55584 100644
--- a/packages/agent-bundle/tests/api.test.ts
+++ b/packages/agent-bundle/tests/api.test.ts
@@ -18,6 +18,7 @@ import { inspectArtifactFilesystem } from '../src/build/emit.ts';
import type { CapabilityState } from '../src/core/capabilities.ts';
import type { Diagnostic } from '../src/core/diagnostics.ts';
import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts';
+import type { ClaudePluginCommandRunner } from '../src/host-contracts/claude-plugin-validation.ts';
import { ProjectService } from '../src/dev/project-service.ts';
import {
createTargetMcpRuntime,
@@ -410,6 +411,134 @@ it('resolves artifact output with CLI, config, and default precedence', async ()
}
}, 30_000);
+const recordedClaudeReport = async (name: string, pluginDirectory: string): Promise =>
+ (await readFile(new URL(`./fixtures/claude-plugin-validate/${name}`, import.meta.url), 'utf8'))
+ .replaceAll('/bundle/claude', pluginDirectory);
+
+it('build runs the Claude developer validator and load check over built claude targets only when asked (#476)', async () => {
+ const root = await createProject();
+ try {
+ const artifact = join(root, 'artifact');
+ const claudeBundle = join(artifact, 'claude');
+ const calls: string[][] = [];
+ const runner: ClaudePluginCommandRunner = async (request) => {
+ calls.push([...request.args]);
+ if (request.args[0] === '--version') return { exitCode: 0, signal: null, stderr: '', stdout: '2.1.259 (Claude Code)\n' };
+ if (request.args[0] === '--plugin-dir') {
+ return {
+ exitCode: 0,
+ signal: null,
+ stderr: '',
+ stdout: JSON.stringify([{ enabled: true, id: 'api-fixture@inline', installPath: request.args[1], scope: 'inline', version: '1.0.0' }]),
+ };
+ }
+ const target = request.args[2] ?? '';
+ const findings = target.endsWith(join('.claude-plugin', 'plugin.json'));
+ return {
+ exitCode: findings ? 1 : 0,
+ signal: null,
+ stderr: '',
+ stdout: await recordedClaudeReport(findings ? '2.1.259-plugin-strict-findings.json' : '2.1.259-plugin-strict-passed.json', claudeBundle),
+ };
+ };
+
+ // Programmatic builds never spawn the host.
+ const silent = await build({ hostValidationRunner: runner, output: artifact, root });
+ expect(silent.hostValidation).toBeUndefined();
+ expect(calls).toEqual([]);
+
+ const validated = await build({ hostValidation: true, hostValidationRunner: runner, output: artifact, root });
+ expect(calls).toEqual([
+ ['--version'],
+ ['plugin', 'validate', join(claudeBundle, '.claude-plugin', 'plugin.json'), '--strict', '--json'],
+ ['plugin', 'validate', join(claudeBundle, '.claude-plugin', 'marketplace.json'), '--strict', '--json'],
+ ['--plugin-dir', claudeBundle, 'plugin', 'list', '--json'],
+ ]);
+ // Codex is built too, but only claude/plugin targets have a Claude validator.
+ expect(validated.build.manifest.targets.map((target) => target.name).sort()).toEqual(['claude', 'codex']);
+ expect(validated.hostValidation).toEqual([
+ expect.objectContaining({ host: 'claude', load: { status: 'loaded' }, status: 'warnings', target: 'claude', version: '2.1.259' }),
+ ]);
+ const findings = validated.diagnostics.filter((entry) => entry.code === 'AB6020');
+ expect(findings.length).toBeGreaterThan(0);
+ expect(findings.every((entry) => entry.severity === 'warning' && entry.target === 'claude')).toBe(true);
+ expect(validated.diagnostics.some((entry) => entry.severity === 'error')).toBe(false);
+
+ // `--strict` promotes the same host warnings to build errors.
+ const strict = await build({ hostValidation: true, hostValidationRunner: runner, output: artifact, root, strict: true });
+ expect(strict.hostValidation?.[0]?.status).toBe('failed');
+ expect(strict.diagnostics.filter((entry) => entry.code === 'AB6020').every((entry) => entry.severity === 'error')).toBe(true);
+ } finally {
+ await rm(join(root, '..'), { force: true, recursive: true });
+ }
+}, 30_000);
+
+it('build surfaces a Claude load refusal as AB7325 even when plugin validate --strict passed (#476)', async () => {
+ const root = await createProject();
+ try {
+ const artifact = join(root, 'artifact');
+ const claudeBundle = join(artifact, 'claude');
+ const errors = ['Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file.'];
+ const runner: ClaudePluginCommandRunner = async (request) => {
+ if (request.args[0] === '--version') return { exitCode: 0, signal: null, stderr: '', stdout: '2.1.259 (Claude Code)\n' };
+ if (request.args[0] === '--plugin-dir') {
+ return {
+ exitCode: 0,
+ signal: null,
+ stderr: '',
+ stdout: JSON.stringify([{ enabled: true, errors, id: 'api-fixture@inline', installPath: request.args[1], scope: 'inline' }]),
+ };
+ }
+ return { exitCode: 0, signal: null, stderr: '', stdout: await recordedClaudeReport('2.1.259-plugin-strict-passed.json', claudeBundle) };
+ };
+ const result = await build({ hostValidation: true, hostValidationRunner: runner, output: artifact, root, targets: ['claude'] });
+
+ expect(result.hostValidation).toEqual([
+ expect.objectContaining({ load: { errors, status: 'refused' }, status: 'failed', target: 'claude' }),
+ ]);
+ expect(result.diagnostics.filter((entry) => entry.code === 'AB7325')).toEqual([expect.objectContaining({
+ message: expect.stringContaining(`refused to load api-fixture@inline from ${JSON.stringify(claudeBundle)}`),
+ severity: 'error',
+ target: 'claude',
+ })]);
+ } finally {
+ await rm(join(root, '..'), { force: true, recursive: true });
+ }
+}, 30_000);
+
+it('build reports one informational AB6019 skip for all Claude-validated targets when claude is absent (#476)', async () => {
+ const root = await createProject();
+ try {
+ await writeFile(join(root, 'agent-bundle.config.ts'), [
+ 'export default {',
+ " plugin: { name: 'api-fixture', version: '1.0.0' },",
+ " targets: ['claude', 'plugin', 'codex'],",
+ '};',
+ '',
+ ].join('\n'));
+ const missing = Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' });
+ let spawns = 0;
+ const runner: ClaudePluginCommandRunner = async () => {
+ spawns += 1;
+ throw missing;
+ };
+ const result = await build({ hostValidation: true, hostValidationRunner: runner, output: join(root, 'artifact'), root });
+
+ // One failed spawn for the whole build, one info diagnostic — build stays fast without the host.
+ expect(spawns).toBe(1);
+ expect(result.hostValidation?.map((report) => [report.target, report.status, report.diagnostics.length])).toEqual([
+ ['claude', 'unavailable', 1],
+ ['plugin', 'unavailable', 0],
+ ]);
+ expect(result.diagnostics.filter((entry) => entry.code === 'AB6019')).toEqual([
+ expect.objectContaining({ severity: 'info', target: 'claude' }),
+ ]);
+ expect(result.diagnostics.some((entry) => entry.severity === 'error')).toBe(false);
+ } finally {
+ await rm(join(root, '..'), { force: true, recursive: true });
+ }
+}, 30_000);
+
it('deduplicates identical adapter diagnostics without collapsing distinct stable identities', async () => {
const root = await createProject();
const diagnostic = (code: string, overrides: Partial = {}): Diagnostic => ({
diff --git a/packages/agent-bundle/tests/claude-plugin-validation.test.ts b/packages/agent-bundle/tests/claude-plugin-validation.test.ts
index c9be7be68..312b8b099 100644
--- a/packages/agent-bundle/tests/claude-plugin-validation.test.ts
+++ b/packages/agent-bundle/tests/claude-plugin-validation.test.ts
@@ -30,6 +30,16 @@ const emittedClaudeBundle = async (withMarketplace = true): Promise => {
return root;
};
+/** A healthy `claude --plugin-dir plugin list --json` row for the emitted fixture bundle. */
+const loadedFixtureRow = (bundle: string, extra: Readonly> = {}): string => JSON.stringify([{
+ enabled: true,
+ id: 'fixture@inline',
+ installPath: bundle,
+ scope: 'inline',
+ version: '0.0.0',
+ ...extra,
+}]);
+
const runByTarget = (
responses: Readonly>>,
version = '2.1.259',
@@ -42,6 +52,13 @@ const runByTarget = (
if (request.args[0] === '--version') {
return { exitCode: 0, signal: null, stderr: '', stdout: `${version} (Claude Code)\n` };
}
+ if (request.args[0] === '--plugin-dir') {
+ // Load check (#476): answer with the recorded `load` response when the test provides one, else a loaded row.
+ const load = responses['load'];
+ return load === undefined
+ ? { exitCode: 0, signal: null, stderr: '', stdout: loadedFixtureRow(request.args[1] ?? '') }
+ : { exitCode: load.exitCode, signal: null, stderr: load.stderr ?? '', stdout: load.stdout };
+ }
const target = request.args[2] ?? '';
const response = responses[target.endsWith('marketplace.json') ? 'marketplace' : 'plugin'];
if (response === undefined) throw new Error(`unexpected validation target ${target}`);
@@ -452,8 +469,16 @@ it('validates the plugin manifest and the marketplace manifest separately with -
['--version'],
['plugin', 'validate', join(bundle, '.claude-plugin', 'plugin.json'), '--strict', '--json'],
['plugin', 'validate', join(bundle, '.claude-plugin', 'marketplace.json'), '--strict', '--json'],
+ ['--plugin-dir', bundle, 'plugin', 'list', '--json'],
]);
- expect(report).toEqual({ diagnostics: [], host: 'claude', status: 'passed', target: 'claude', version: '2.1.259' });
+ expect(report).toEqual({
+ diagnostics: [],
+ host: 'claude',
+ load: { status: 'loaded' },
+ status: 'passed',
+ target: 'claude',
+ version: '2.1.259',
+ });
});
it('skips the marketplace run when the bundle emits no marketplace.json', async () => {
@@ -462,7 +487,136 @@ it('skips the marketplace run when the bundle emits no marketplace.json', async
plugin: { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) },
});
await validateClaudePlugin({ pluginDirectory: bundle, run: fixture.run, target: 'claude' });
- expect(fixture.calls.map((call) => call[2])).toEqual([undefined, join(bundle, '.claude-plugin', 'plugin.json')]);
+ expect(fixture.calls.map((call) => call[2])).toEqual([undefined, join(bundle, '.claude-plugin', 'plugin.json'), 'plugin']);
+});
+
+it('skips the load check on request and when the bundle has no manifest name to look up', async () => {
+ const bundle = await emittedClaudeBundle(false);
+ const passed = { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) };
+ const optedOut = runByTarget({ plugin: passed });
+ const report = await validateClaudePlugin({ loadCheck: false, pluginDirectory: bundle, run: optedOut.run, target: 'claude' });
+ expect(report.load).toBeUndefined();
+ expect(optedOut.calls.some((call) => call[0] === '--plugin-dir')).toBe(false);
+
+ await writeFile(join(bundle, '.claude-plugin', 'plugin.json'), '{"version":"1.0.0"}\n');
+ const nameless = runByTarget({ plugin: passed });
+ expect((await validateClaudePlugin({ pluginDirectory: bundle, run: nameless.run, target: 'claude' })).load).toBeUndefined();
+ expect(nameless.calls.some((call) => call[0] === '--plugin-dir')).toBe(false);
+});
+
+it('reports AB7325 when Claude accepts the manifest under --strict but refuses to load it (#479 follow-up)', async () => {
+ const bundle = await emittedClaudeBundle();
+ const errors = [
+ 'Hook load failed: Duplicate hooks file detected: hooks/hooks.json is loaded automatically; remove it from the manifest.',
+ ];
+ const fixture = runByTarget({
+ load: { exitCode: 0, stdout: loadedFixtureRow(bundle, { errors }) },
+ marketplace: { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) },
+ plugin: { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) },
+ });
+ const report = await validateClaudePlugin({ pluginDirectory: bundle, run: fixture.run, target: 'claude' });
+
+ expect(report.status).toBe('failed');
+ expect(report.load).toEqual({ errors, status: 'refused' });
+ expect(report.diagnostics).toEqual([expect.objectContaining({
+ code: 'AB7325',
+ message: `Claude Code refused to load fixture@inline from ${JSON.stringify(bundle)} although ` +
+ `\`plugin validate --strict\` accepted it; the host reported: ${errors[0]}`,
+ recovery: expect.stringContaining('claude --plugin-dir plugin list --json'),
+ severity: 'error',
+ target: 'claude',
+ })]);
+});
+
+it('reports a load refusal caused only by an uninstalled declared dependency as an AB7325 warning', async () => {
+ const bundle = await emittedClaudeBundle(false);
+ const errors = [
+ 'Dependency "audit-logger@acme-shared" is not installed — run `claude plugin install audit-logger@acme-shared`, ' +
+ 'or check that its marketplace is added',
+ ];
+ const passed = { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) };
+ const report = await validateClaudePlugin({
+ pluginDirectory: bundle,
+ run: runByTarget({ load: { exitCode: 0, stdout: loadedFixtureRow(bundle, { errors }) }, plugin: passed }).run,
+ target: 'claude',
+ });
+ expect(report.status).toBe('warnings');
+ expect(report.load).toEqual({ errors, status: 'refused' });
+ expect(report.diagnostics).toEqual([expect.objectContaining({
+ code: 'AB7325',
+ message: expect.stringContaining('(a declared dependency is not installed on this machine)'),
+ severity: 'warning',
+ })]);
+
+ const strict = await validateClaudePlugin({
+ pluginDirectory: bundle,
+ run: runByTarget({ load: { exitCode: 0, stdout: loadedFixtureRow(bundle, { errors }) }, plugin: passed }).run,
+ strict: true,
+ target: 'claude',
+ });
+ expect(strict.status).toBe('failed');
+ expect(strict.diagnostics[0]?.severity).toBe('error');
+
+ // Mixed with any other refusal the verdict stays an error.
+ const mixed = await validateClaudePlugin({
+ pluginDirectory: bundle,
+ run: runByTarget({
+ load: { exitCode: 0, stdout: loadedFixtureRow(bundle, { errors: [...errors, 'Hook load failed: Duplicate hooks file detected'] }) },
+ plugin: passed,
+ }).run,
+ target: 'claude',
+ });
+ expect(mixed.status).toBe('failed');
+ expect(mixed.diagnostics[0]?.message).not.toContain('declared dependency');
+});
+
+it('reports AB7311 when the --plugin-dir listing has no row for the bundle', async () => {
+ const bundle = await emittedClaudeBundle(false);
+ const fixture = runByTarget({
+ load: { exitCode: 0, stdout: JSON.stringify([{ id: 'other@marketplace', installPath: '/elsewhere', scope: 'user' }]) },
+ plugin: { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) },
+ });
+ const report = await validateClaudePlugin({ pluginDirectory: bundle, run: fixture.run, target: 'claude' });
+
+ expect(report.status).toBe('failed');
+ expect(report.load).toEqual({ status: 'unregistered' });
+ expect(report.diagnostics).toEqual([expect.objectContaining({
+ code: 'AB7311',
+ message: expect.stringContaining('did not register fixture@inline'),
+ severity: 'error',
+ })]);
+});
+
+it('reports AB6022 when the load check itself cannot be read', async () => {
+ const bundle = await emittedClaudeBundle(false);
+ const passed = { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) };
+ const nonzero = await validateClaudePlugin({
+ pluginDirectory: bundle,
+ run: runByTarget({ load: { exitCode: 1, stderr: 'boom', stdout: '' }, plugin: passed }).run,
+ target: 'claude',
+ });
+ expect(nonzero).toMatchObject({
+ diagnostics: [expect.objectContaining({ code: 'AB6022', message: expect.stringContaining('exited with code 1: boom') })],
+ load: { status: 'failed' },
+ status: 'failed',
+ });
+
+ const notJson = await validateClaudePlugin({
+ pluginDirectory: bundle,
+ run: runByTarget({ load: { exitCode: 0, stdout: 'Installed plugins:\n' }, plugin: passed }).run,
+ target: 'claude',
+ });
+ expect(notJson).toMatchObject({
+ diagnostics: [expect.objectContaining({ code: 'AB6022', message: expect.stringContaining('did not return JSON') })],
+ load: { status: 'failed' },
+ });
+
+ const notArray = await validateClaudePlugin({
+ pluginDirectory: bundle,
+ run: runByTarget({ load: { exitCode: 0, stdout: '{"plugins":[]}' }, plugin: passed }).run,
+ target: 'claude',
+ });
+ expect(notArray.diagnostics).toEqual([expect.objectContaining({ message: expect.stringContaining('did not return a JSON array') })]);
});
it('attributes --json findings to their file and de-duplicates marketplace re-reports of manifest findings', async () => {
diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts
index 86ce8b5a3..a491c5b61 100644
--- a/packages/agent-bundle/tests/cli.test.ts
+++ b/packages/agent-bundle/tests/cli.test.ts
@@ -403,6 +403,46 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async (
}
}, 30_000 * timeScale);
+it('build requests the Claude host validator by default, opts out under --no-host-validation, and fails under --strict (#476)', async () => {
+ const calls: unknown[] = [];
+ const build = async (options: unknown) => {
+ calls.push(options);
+ const strict = (options as { strict?: boolean }).strict === true;
+ const skipped = (options as { hostValidation?: boolean }).hostValidation === false;
+ const diagnostics = skipped
+ ? []
+ : [{ code: 'AB6020', message: 'Claude plugin validation warning.', severity: strict ? 'error' as const : 'warning' as const }];
+ return {
+ build: { outputRoot: '/artifact' },
+ diagnostics,
+ ...(skipped ? {} : {
+ hostValidation: [{ diagnostics, host: 'claude', load: { status: 'loaded' }, status: strict ? 'failed' : 'warnings', target: 'claude', version: '2.1.259' }],
+ }),
+ model: { metadata: { name: 'fixture' } },
+ };
+ };
+
+ const human = await runSourceCliWithOutput(['build', '--root', '/project'], { build: build as never });
+ expect(human).toEqual({
+ code: 0,
+ stderr: '',
+ stdout: 'AB6020 (warning): Claude plugin validation warning.\nBuilt fixture to /artifact\nHost validation (claude): warnings (Claude Code 2.1.259), load check loaded\n',
+ });
+
+ const skipped = await runSourceCliWithOutput(['build', '--root', '/project', '--no-host-validation'], { build: build as never });
+ expect(skipped).toEqual({ code: 0, stderr: '', stdout: 'Built fixture to /artifact\n' });
+
+ const strict = await runSourceCliWithOutput(['build', '--root', '/project', '--strict', '--json'], { build: build as never });
+ expect(strict.code).toBe(1);
+ expect(strict.stderr).toContain('AB6020');
+
+ expect(calls).toEqual([
+ expect.objectContaining({ hostValidation: true, packageOutputs: true, root: '/project', strict: undefined }),
+ expect.objectContaining({ hostValidation: false, packageOutputs: true, root: '/project' }),
+ expect.objectContaining({ hostValidation: true, strict: true }),
+ ]);
+});
+
it('enables bounded host validation for built artifacts and promotes warnings only under --strict', async () => {
const calls: unknown[] = [];
const validate = async (options: unknown) => {
diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts
index 3bd2bcd29..189e4540c 100644
--- a/packages/agent-bundle/tests/doctor.test.ts
+++ b/packages/agent-bundle/tests/doctor.test.ts
@@ -881,7 +881,9 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof'
hosts: ['claude'],
});
- // Read-only proofs only: version probe, installed inventory, inline registration proof; never `plugin validate`.
+ // Read-only proofs only: one version probe, installed inventory, inline registration proof, then the
+ // developer validator over the bundle's two manifests (#476) — it reads files and writes nothing.
+ // The pinned-bytes document check (AB7319) needs no CLI proof of its own.
expect(calls).toEqual([
expect.objectContaining({ args: ['--version'], executable: 'claude' }),
expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle, executable: 'claude' }),
@@ -889,6 +891,14 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof'
args: ['--plugin-dir', bundle, 'plugin', 'list', '--json'],
executable: 'claude',
}),
+ expect.objectContaining({
+ args: ['plugin', 'validate', join(bundle, '.claude-plugin', 'plugin.json'), '--strict'],
+ executable: 'claude',
+ }),
+ expect.objectContaining({
+ args: ['plugin', 'validate', join(bundle, '.claude-plugin', 'marketplace.json'), '--strict'],
+ executable: 'claude',
+ }),
]);
expect(hostReport(report, 'claude').bundle?.state).toBe('corrupt');
expect(report.diagnostics).toEqual(expect.arrayContaining([
@@ -1009,8 +1019,22 @@ it('inventories Claude and Codex installs from their pinned plugin list --json v
expect(report.diagnostics.some((entry) => entry.code === 'AB7303')).toBe(false);
expect(hostReport(report, 'claude').inventory).toEqual({
findings: [
- { entry: 'alpha@alpha-marketplace (user)', name: 'alpha', path: '/cache/alpha/1.0.0', state: 'installed', version: '1.0.0' },
- { entry: 'alpha@alpha-marketplace (project)', name: 'alpha', path: '/cache/alpha-project/1.0.0', state: 'installed', version: '1.0.0' },
+ {
+ enabled: true,
+ entry: 'alpha@alpha-marketplace (user)',
+ name: 'alpha',
+ path: '/cache/alpha/1.0.0',
+ state: 'installed',
+ version: '1.0.0',
+ },
+ {
+ enabled: true,
+ entry: 'alpha@alpha-marketplace (project)',
+ name: 'alpha',
+ path: '/cache/alpha-project/1.0.0',
+ state: 'installed',
+ version: '1.0.0',
+ },
],
status: 'known',
});
@@ -1508,6 +1532,7 @@ it('reports a Claude copy the host refused to load as load-failed (AB7325) inste
expect(host.bundle?.comparison).not.toHaveProperty('installedContentHash');
expect(host.inventory).toEqual({
findings: [{
+ enabled: true,
entry: 'doctor-fixture@doctor-fixture-marketplace (user)',
errors: refusedRow.errors,
name: 'doctor-fixture',
@@ -1529,6 +1554,128 @@ it('reports a Claude copy the host refused to load as load-failed (AB7325) inste
}
});
+it('reports an installed-but-disabled Claude copy as disabled (AB7327) with the enable command (#476)', async () => {
+ const fixture = await temporaryDoctor();
+ try {
+ const bundle = await createBundle(fixture.root, 'claude');
+ const installed = join(fixture.root, 'claude-config', 'plugins', 'cache', 'doctor-fixture-marketplace', 'doctor-fixture', '1.2.3');
+ await cp(bundle, installed, { recursive: true });
+ const artifactHash = (await treeInventory(bundle)).hash;
+ const disabledRow = {
+ enabled: false,
+ id: 'doctor-fixture@doctor-fixture-marketplace',
+ installPath: installed,
+ scope: 'project',
+ version: '1.2.3',
+ };
+ const runner: DoctorCommandRunner = async (request) => {
+ if (request.args[0] === '--version') return commandResult({ stdout: 'claude 2.1.259' });
+ if (isInventoryRequest(request)) return commandResult({ stdout: JSON.stringify([disabledRow]) });
+ if (request.args[0] === '--plugin-dir') return commandResult({ stdout: JSON.stringify([{ id: 'doctor-fixture@inline' }]) });
+ return commandResult({ stdout: JSON.stringify({ contents: [], manifest: { errors: [], notes: [], warnings: [] }, success: true }) });
+ };
+ const host = hostReport(await runDoctor({
+ commandRunner: runner,
+ endpointDirectory: fixture.endpointDirectory,
+ from: bundle,
+ home: fixture.home,
+ hosts: ['claude'],
+ }), 'claude');
+
+ // Byte-identical and current, yet switched off: the comparison still runs, the inventory says so.
+ expect(host.bundle).toMatchObject({
+ comparison: {
+ artifactContentHash: artifactHash,
+ enabled: false,
+ installedContentHash: artifactHash,
+ installedPath: installed,
+ status: 'current',
+ },
+ state: 'registered',
+ });
+ expect(host.inventory).toEqual({
+ findings: [{
+ enabled: false,
+ entry: 'doctor-fixture@doctor-fixture-marketplace (project)',
+ name: 'doctor-fixture',
+ path: installed,
+ state: 'disabled',
+ version: '1.2.3',
+ }],
+ status: 'known',
+ });
+ const disabled = host.diagnostics.filter((entry) => entry.code === 'AB7327');
+ expect(disabled).toEqual([expect.objectContaining({ severity: 'warning', target: 'claude' })]);
+ expect(disabled[0]?.message).toContain('as disabled (`enabled: false`)');
+ expect(disabled[0]?.message).toContain('(scope project)');
+ expect(disabled[0]?.recovery).toContain('claude plugin enable doctor-fixture@doctor-fixture-marketplace --scope project');
+ expect(disabled[0]?.recovery).toContain('reinstalling does not enable');
+ expect(host.diagnostics.some((entry) => entry.code === 'AB7308' || entry.code === 'AB7325')).toBe(false);
+ } finally {
+ await fixture.cleanup();
+ }
+});
+
+it('runs the Claude developer validator over the bundle and every installed copy (#476)', async () => {
+ const fixture = await temporaryDoctor();
+ try {
+ const bundle = await createBundle(fixture.root, 'claude');
+ const installed = join(fixture.root, 'claude-config', 'plugins', 'cache', 'doctor-fixture-marketplace', 'doctor-fixture', '1.2.3');
+ await cp(bundle, installed, { recursive: true });
+ const recorded = (name: string, directory: string): Promise =>
+ readFile(new URL(`./fixtures/claude-plugin-validate/${name}`, import.meta.url), 'utf8')
+ .then((text) => text.replaceAll('/bundle/claude', directory));
+ const calls: string[][] = [];
+ const runner: DoctorCommandRunner = async (request) => {
+ calls.push([...request.args]);
+ if (request.args[0] === '--version') return commandResult({ stdout: 'claude 2.1.259' });
+ if (isInventoryRequest(request)) {
+ return commandResult({ stdout: JSON.stringify([
+ { enabled: true, id: 'doctor-fixture@doctor-fixture-marketplace', installPath: installed, scope: 'user', version: '1.2.3' },
+ ]) });
+ }
+ if (request.args[0] === '--plugin-dir') return commandResult({ stdout: JSON.stringify([{ id: 'doctor-fixture@inline' }]) });
+ const target = request.args[2] ?? '';
+ // The bundle validates clean; the installed copy's plugin.json run carries the recorded warnings.
+ const findings = target.startsWith(installed) && target.endsWith('plugin.json') && !target.endsWith('marketplace.json');
+ return commandResult({
+ exitCode: findings ? 1 : 0,
+ stdout: await recorded(findings ? '2.1.259-plugin-strict-findings.json' : '2.1.259-plugin-strict-passed.json', dirname(dirname(target))),
+ });
+ };
+ const host = hostReport(await runDoctor({
+ commandRunner: runner,
+ endpointDirectory: fixture.endpointDirectory,
+ from: bundle,
+ home: fixture.home,
+ hosts: ['claude'],
+ }), 'claude');
+
+ // One probe for the whole host; the validator never lists plugins again (Doctor holds the verdict).
+ expect(calls.filter((call) => call[0] === '--version')).toHaveLength(1);
+ expect(calls.filter((call) => call[0] === '--plugin-dir')).toHaveLength(1);
+ expect(calls.filter((call) => call[0] === 'plugin' && call[1] === 'validate').map((call) => call[2])).toEqual([
+ join(bundle, '.claude-plugin', 'plugin.json'),
+ join(bundle, '.claude-plugin', 'marketplace.json'),
+ join(installed, '.claude-plugin', 'plugin.json'),
+ join(installed, '.claude-plugin', 'marketplace.json'),
+ ]);
+ expect(host.bundle?.hostValidation).toEqual([
+ expect.objectContaining({ copy: 'bundle', diagnostics: [], pluginDirectory: bundle, status: 'passed', version: '2.1.259' }),
+ expect.objectContaining({ copy: 'installed', pluginDirectory: installed, scope: 'user', status: 'warnings' }),
+ ]);
+ expect(host.bundle?.hostValidation?.[1]).not.toHaveProperty('load');
+ const findings = host.diagnostics.filter((entry) => entry.code === 'AB6020');
+ expect(findings.length).toBeGreaterThan(0);
+ expect(findings.every((entry) => entry.severity === 'warning' && entry.target === 'claude')).toBe(true);
+ expect(findings[0]?.message).toMatch(new RegExp(`^Installed copy at ${JSON.stringify(installed).replaceAll('\\', '\\\\')} \\(scope user\\): `, 'u'));
+ expect(host.diagnostics.some((entry) => entry.message.startsWith('Bundle at'))).toBe(false);
+ expect(host.bundle?.comparison?.status).toBe('current');
+ } finally {
+ await fixture.cleanup();
+ }
+});
+
it('fails the Claude registration proof when --plugin-dir plugin list --json carries errors for the bundle', async () => {
const fixture = await temporaryDoctor();
try {
diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts
index 309a3a5bc..d4c03ffdb 100644
--- a/packages/agent-bundle/tests/host-adapters.native.test.ts
+++ b/packages/agent-bundle/tests/host-adapters.native.test.ts
@@ -9,6 +9,10 @@ import { claudeAdapter } from '../src/adapters/claude.ts';
import { codexAdapter } from '../src/adapters/codex.ts';
import { emitPlanEntries } from '../src/build/emit.ts';
import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts';
+import {
+ validateClaudePlugin,
+ type ClaudePluginValidationReport,
+} from '../src/host-contracts/claude-plugin-validation.ts';
const nativeIt = process.env.AGENT_BUNDLE_NATIVE_HOST_CONTRACTS === '1' ? it : it.skip;
@@ -17,6 +21,12 @@ interface ClaudeValidation {
readonly output: string;
}
+interface ClaudePluginValidation {
+ /** Every diagnostic as `CODE: message`, so `toContain` reads like the raw CLI output did. */
+ readonly output: string;
+ readonly report: ClaudePluginValidationReport;
+}
+
interface CodexValidation {
readonly code: number | null;
readonly stderr: string;
@@ -49,9 +59,34 @@ const runClaude = async (
child.once('close', (code) => resolvePromise({ code, output }));
});
+/**
+ * A raw `claude plugin validate --strict ` run. Name a manifest, never the emitted
+ * plugin directory: with `.claude-plugin/marketplace.json` beside `plugin.json`, a directory run
+ * is a marketplace run, and "From a marketplace directory, Claude Code doesn't open the plugins'
+ * skill, agent, command, or hook files" (plugin-marketplaces → Troubleshooting › Marketplace
+ * validation errors). Tests about the marketplace manifest use this with `marketplace.json`.
+ */
const runClaudeValidation = async (cwd: string, target: string): Promise =>
runClaude(cwd, ['plugin', 'validate', '--strict', target]);
+/**
+ * The plugin-mode proof through the production runner `validate --artifact`, `build`, and
+ * `doctor` share: `plugin.json` first (covers `plugin.json`, `hooks/hooks.json`, `skills/`,
+ * `agents/`, `commands/` — plugin-marketplaces → Validate a plugin or a directory without a
+ * manifest › Pick the directory to name), then `marketplace.json`; `--strict` on both, `--json`
+ * on 2.1.259+. `status: 'passed'` means the strict runs reported nothing (#475).
+ */
+const validateClaudePluginRoot = async (
+ root: string,
+ options: Readonly<{ loadCheck?: boolean; strict?: boolean }> = {},
+): Promise => {
+ const report = await validateClaudePlugin({ pluginDirectory: root, target: 'claude', ...options });
+ return {
+ output: report.diagnostics.map((entry) => `${entry.code}: ${entry.message}`).join('\n'),
+ report,
+ };
+};
+
const runCodex = async (
cwd: string,
args: readonly string[],
@@ -614,10 +649,9 @@ nativeIt('records that strict validation accepts package metadata without runnin
version: '1.0.0',
})}\n`),
]);
- const validation = await runClaudeValidation(root, root);
+ const validation = await validateClaudePluginRoot(root);
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ expect(validation.report.status, validation.output).toBe('passed');
expect(validation.output).not.toContain('package.json');
expect(validation.output).not.toContain('package-lock.json');
await expect(access(join(root, 'node_modules'))).rejects.toMatchObject({ code: 'ENOENT' });
@@ -626,7 +660,7 @@ nativeIt('records that strict validation accepts package metadata without runnin
}
});
-nativeIt('records that strict validation does not catch a path-escaping plugin symlink', async () => {
+nativeIt('records that plugin-mode validation warns about a symlinked skill entry a session would follow', async () => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-symlink-validation-'));
const externalSkill = `${root}-outside-skill`;
@@ -639,11 +673,31 @@ nativeIt('records that strict validation does not catch a path-escaping plugin s
);
await mkdir(join(root, 'skills'), { recursive: true });
await symlink(externalSkill, join(root, 'skills', 'outside-skill'), 'dir');
- const validation = await runClaudeValidation(root, root);
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ // plugin-marketplaces → Validate a plugin or a directory without a manifest › Check files behind
+ // symlinks: "A linked entry inside a skills, agents, or commands directory: Claude Code skips it
+ // and warns, per directory, how many entries it skipped that a session would load." Observed on
+ // Claude Code 2.1.250 (CI pin), 2.1.251, and 2.1.260 as a `skills` directory warning that
+ // --strict promotes to exit 1. The validator never names the link target, so containment stays
+ // an install-copy check; this proof is that the plugin run reports the skipped entry at all.
+ const validation = await validateClaudePluginRoot(root);
+ expect(validation.report.status, validation.output).toBe('warnings');
+ expect(validation.report.diagnostics).toEqual([expect.objectContaining({
+ code: 'AB6020',
+ generatedPath: 'skills',
+ message: expect.stringMatching(/\(skill skills\): directory: 1 entry here is a symlink and was not read/u),
+ severity: 'warning',
+ })]);
expect(validation.output).not.toContain('outside-skill');
+ expect((await validateClaudePluginRoot(root, { strict: true })).report.status).toBe('failed');
+
+ // The former proof named the directory, which is a marketplace run: the same bundle passes and
+ // the skills directory is never opened (Troubleshooting › Marketplace validation errors).
+ const marketplaceRun = await runClaudeValidation(root, root);
+ expect(marketplaceRun.code, marketplaceRun.output).toBe(0);
+ expect(marketplaceRun.output).toContain('Validating marketplace manifest');
+ expect(marketplaceRun.output).toContain('Validation passed');
+ expect(marketplaceRun.output).not.toMatch(/is a symlink and was not read/u);
} finally {
await Promise.all([
rm(root, { force: true, recursive: true }),
@@ -908,13 +962,13 @@ nativeIt('accepts an emitted Claude artifact whose plugin root carries settings.
}));
expect(written).toContain('settings.json');
- // Both documented validation entry points: the marketplace manifest and
- // the plugin directory itself.
- for (const target of [join(root, '.claude-plugin', 'marketplace.json'), root]) {
- const validation = await runClaudeValidation(root, target);
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
- }
+ // Both documented validation entry points: the marketplace manifest alone,
+ // and the plugin-mode run (plugin.json, then marketplace.json).
+ const marketplace = await runClaudeValidation(root, join(root, '.claude-plugin', 'marketplace.json'));
+ expect(marketplace.code, marketplace.output).toBe(0);
+ expect(marketplace.output).toContain('Validation passed');
+ const plugin = await validateClaudePluginRoot(root);
+ expect(plugin.report.status, plugin.output).toBe('passed');
} finally {
await rm(root, { force: true, recursive: true });
}
@@ -932,12 +986,12 @@ nativeIt('records that strict native validation never inspects plugin settings.j
join(root, 'settings.json'),
'{"agent":"","statusLine":{"type":"command","command":"rows.sh"},"padding":3}\n',
);
- const validation = await runClaudeValidation(root, root);
+ const validation = await validateClaudePluginRoot(root);
- // The host validator ignores settings.json even under --strict, so the
- // compiler's own claude.settings.* diagnostics are the only guard an
+ // The host validator ignores settings.json even in a strict plugin run, so
+ // the compiler's own claude.settings.* diagnostics are the only guard an
// author gets before the plugin is enabled in a session.
- expect(validation.code, validation.output).toBe(0);
+ expect(validation.report.status, validation.output).toBe('passed');
expect(validation.output).not.toContain('settings.json');
} finally {
await rm(root, { force: true, recursive: true });
@@ -983,26 +1037,18 @@ nativeIt('records strict native validation behavior for documented and security-
try {
for (const { fileName, frontmatter, label } of cases) {
const caseRoot = join(root, fileName.replace('.md', ''));
- const configRoot = join(caseRoot, 'config');
const pluginRoot = join(caseRoot, 'plugin');
await writeClaudeArtifact(pluginRoot, model);
- await Promise.all([
- mkdir(join(pluginRoot, 'agents'), { recursive: true }),
- mkdir(configRoot, { recursive: true }),
- ]);
+ await mkdir(join(pluginRoot, 'agents'), { recursive: true });
await writeFile(
join(pluginRoot, 'agents', fileName),
`---\n${frontmatter.join('\n')}\n---\n\nInspect the repository and report findings.\n`,
);
- const validation = await runClaude(
- pluginRoot,
- ['plugin', 'validate', '--strict', pluginRoot],
- configRoot,
- );
+ // A plugin run is the only mode that opens agents/ at all.
+ const validation = await validateClaudePluginRoot(pluginRoot);
- expect(validation.code, `${label}: ${validation.output}`).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ expect(validation.report.status, `${label}: ${validation.output}`).toBe('passed');
if (label === 'security-sensitive fields') {
expect(validation.output).not.toContain('hooks');
expect(validation.output).not.toContain('mcpServers');
@@ -1039,10 +1085,9 @@ nativeIt('accepts emitted Claude experimental themes and monitors under strict n
expect(JSON.parse(
await readFile(join(root, 'monitors', 'monitors.json'), 'utf8'),
)[0].command).toBe('node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs');
- const validation = await runClaudeValidation(root, root);
+ const validation = await validateClaudePluginRoot(root);
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ expect(validation.report.status, validation.output).toBe('passed');
} finally {
await rm(root, { force: true, recursive: true });
}
@@ -1058,11 +1103,24 @@ nativeIt('records whether strict native validation inspects monitors/monitors.js
join(root, 'monitors', 'monitors.json'),
'[{"name":"missing-command","description":"Missing its required command."}]\n',
);
- const validation = await runClaudeValidation(root, root);
-
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ // Plugin mode, so a monitors finding would be visible if the validator produced one. It is not:
+ // both `plugin validate --strict` runs accept the bundle. Only the load check that follows
+ // (`claude --plugin-dir plugin list --json`) exposes the refusal, as AB7325 (#479 follow-up).
+ const validation = await validateClaudePluginRoot(root, { loadCheck: false });
+ expect(validation.report.status, validation.output).toBe('passed');
expect(validation.output).not.toContain('monitors.json');
+
+ const loaded = await validateClaudePluginRoot(root);
+ expect(loaded.report.status, loaded.output).toBe('failed');
+ expect(loaded.report.load).toEqual({
+ errors: [expect.stringMatching(/^monitors load failed from .*monitors\/monitors\.json: /u)],
+ status: 'refused',
+ });
+ expect(loaded.report.diagnostics).toEqual([expect.objectContaining({
+ code: 'AB7325',
+ message: expect.stringContaining('although `plugin validate --strict` accepted it; the host reported: monitors load failed'),
+ severity: 'error',
+ })]);
} finally {
await rm(root, { force: true, recursive: true });
}
@@ -1078,10 +1136,10 @@ nativeIt('records whether strict native validation inspects plugin theme content
join(root, 'themes', 'invalid.json'),
'{"name":"Missing base","overrides":{"error":7},"typo":true}\n',
);
- const validation = await runClaudeValidation(root, root);
+ // Plugin mode, so a theme finding would be visible if the validator produced one.
+ const validation = await validateClaudePluginRoot(root);
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ expect(validation.report.status, validation.output).toBe('passed');
expect(validation.output).not.toContain('invalid.json');
} finally {
await rm(root, { force: true, recursive: true });
@@ -1097,9 +1155,9 @@ nativeIt('records that strict native validation rejects the deprecated top-level
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record;
manifest['monitors'] = './monitors/monitors.json';
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
- const validation = await runClaudeValidation(root, root);
+ const validation = await validateClaudePluginRoot(root);
- expect(validation.code).not.toBe(0);
+ expect(validation.report.status, validation.output).not.toBe('passed');
expect(validation.output).toContain('monitors');
} finally {
await rm(root, { force: true, recursive: true });
@@ -1190,10 +1248,9 @@ nativeIt('accepts emitted Claude workflows and output styles under strict native
await emitPlanEntries({ entries: claudeAdapter.plan(payloadModel).entries, root: outputRoot });
expect(await readFile(join(outputRoot, 'workflows', 'release-audit.js'), 'utf8')).toBe(workflow);
expect(await readFile(join(outputRoot, 'output-styles', 'terse.md'), 'utf8')).toBe(outputStyle);
- const validation = await runClaudeValidation(outputRoot, outputRoot);
+ const validation = await validateClaudePluginRoot(outputRoot);
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ expect(validation.report.status, validation.output).toBe('passed');
} finally {
await rm(root, { force: true, recursive: true });
}
@@ -1207,9 +1264,9 @@ nativeIt('records whether strict native validation inspects output-style frontma
await writeClaudeArtifact(outputRoot, model);
await mkdir(join(outputRoot, 'output-styles'), { recursive: true });
await writeFile(join(outputRoot, 'output-styles', 'missing-frontmatter.md'), 'Be concise.\n');
- const validation = await runClaudeValidation(outputRoot, outputRoot);
+ const validation = await validateClaudePluginRoot(outputRoot);
- expect(validation.code, validation.output).toBe(0);
+ expect(validation.report.status, validation.output).toBe('passed');
expect(validation.output).not.toContain('missing-frontmatter.md');
} finally {
await rm(root, { force: true, recursive: true });
@@ -1287,10 +1344,9 @@ nativeIt('accepts emitted Claude channels bound to a plugin MCP server under str
try {
const written = await writeClaudeArtifact(root, channelModel);
expect(written).toContain('.mcp.json');
- const validation = await runClaudeValidation(root, root);
+ const validation = await validateClaudePluginRoot(root);
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ expect(validation.report.status, validation.output).toBe('passed');
} finally {
await rm(root, { force: true, recursive: true });
}
@@ -1305,12 +1361,11 @@ nativeIt('records whether strict native validation catches a dangling Claude cha
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record;
manifest['channels'] = [{ server: 'missing' }];
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
- const validation = await runClaudeValidation(root, root);
+ const validation = await validateClaudePluginRoot(root);
// Claude Code 2.1.257 validates the channel declaration shape but does
// not cross-check `server` against the sibling .mcp.json keys.
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ expect(validation.report.status, validation.output).toBe('passed');
expect(validation.output).not.toContain('missing');
} finally {
await rm(root, { force: true, recursive: true });
@@ -1325,10 +1380,22 @@ nativeIt('accepts emitted Claude plugin dependencies under strict native validat
{ marketplace: 'acme-shared', name: 'audit-logger' },
{ marketplace: 'acme-shared', name: 'secrets-vault', version: '~2.1.0' },
]));
- const validation = await runClaudeValidation(root, root);
-
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ const validation = await validateClaudePluginRoot(root, { loadCheck: false });
+ expect(validation.report.status, validation.output).toBe('passed');
+
+ // The load check is machine-bound: this machine has no `acme-shared` marketplace, so Claude
+ // refuses the load for the missing dependency. That is reported as an AB7325 *warning* (the
+ // artifact is not at fault); `--strict` promotes it like any other host warning.
+ const loaded = await validateClaudePluginRoot(root);
+ expect(loaded.report.status, loaded.output).toBe('warnings');
+ expect(loaded.report.load?.status).toBe('refused');
+ expect(loaded.report.load?.errors).toEqual([expect.stringMatching(/^Dependency "audit-logger@acme-shared" is not installed/u)]);
+ expect(loaded.report.diagnostics).toEqual([expect.objectContaining({
+ code: 'AB7325',
+ message: expect.stringContaining('(a declared dependency is not installed on this machine)'),
+ severity: 'warning',
+ })]);
+ expect((await validateClaudePluginRoot(root, { strict: true })).report.status).toBe('failed');
} finally {
await rm(root, { force: true, recursive: true });
}
@@ -1352,9 +1419,8 @@ nativeIt('accepts emitted Claude manifest metadata fields under strict native va
metadata: { catalog: 'security', entitlement: { tier: 'team' } },
});
- const validation = await runClaudeValidation(root, root);
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ const validation = await validateClaudePluginRoot(root);
+ expect(validation.report.status, validation.output).toBe('passed');
} finally {
await rm(root, { force: true, recursive: true });
}
@@ -1375,9 +1441,9 @@ nativeIt('accepts a custom flat command path without a default commands director
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record;
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, commands: './custom/deploy.md' })}\n`);
- const validation = await runClaudeValidation(root, root);
- expect(validation.code, validation.output).toBe(0);
- expect(validation.output).toContain('Validation passed');
+ // Plugin mode: the run checks that the custom `commands` path exists (Read the validation results).
+ const validation = await validateClaudePluginRoot(root);
+ expect(validation.report.status, validation.output).toBe('passed');
} finally {
await rm(root, { force: true, recursive: true });
}
diff --git a/packages/agent-bundle/tests/support/packed-native-smoke.ts b/packages/agent-bundle/tests/support/packed-native-smoke.ts
index 33ff94622..1fa576e37 100644
--- a/packages/agent-bundle/tests/support/packed-native-smoke.ts
+++ b/packages/agent-bundle/tests/support/packed-native-smoke.ts
@@ -255,6 +255,39 @@ const summarizeEval = (host: PackedNativeHost, command: CommandResult) => {
return Object.freeze({ host, status: passed ? 'passed' as const : 'failed' as const, trials: summary?.trials ?? 0 });
};
+interface PackedClaudeValidationDocument {
+ readonly hostValidation?: readonly {
+ readonly diagnostics?: readonly unknown[];
+ readonly host?: unknown;
+ readonly load?: { readonly status?: unknown };
+ readonly status?: unknown;
+ readonly target?: unknown;
+ readonly version?: unknown;
+ }[];
+}
+
+/**
+ * The packed `validate --artifact --strict --json` document must carry one Claude
+ * report that passed both the plugin-mode validation runs and the
+ * `--plugin-dir … plugin list --json` load check against the installed `claude`.
+ */
+const packedClaudeValidationPassed = (stdout: string, versionNumber: string): boolean => {
+ let document: PackedClaudeValidationDocument;
+ try {
+ document = JSON.parse(stdout) as PackedClaudeValidationDocument;
+ } catch {
+ return false;
+ }
+ // Other hosts' informational reports (Codex `AB6030`/`AB6031`) share the document; the exit
+ // code already proves none of them is an error.
+ const report = document.hostValidation?.find((entry) => entry.host === 'claude' && entry.target === 'claude');
+ return report !== undefined
+ && report.status === 'passed'
+ && report.version === versionNumber
+ && report.load?.status === 'loaded'
+ && (report.diagnostics?.length ?? 0) === 0;
+};
+
/**
* Packed-artifact proof for Claude's developer tools. It requires only the
* installed binary, never authentication, and retains no plugin-list output.
@@ -300,11 +333,24 @@ export const runPackedClaudePluginProof = async (options: {
if (version.exitCode !== 0 || versionNumber === undefined) {
throw new Error('packed-claude-proof:version');
}
- const validation = await run('claude', ['plugin', 'validate', pluginDirectory, '--strict'], {
- cwd: project,
- environment,
- });
- if (validation.exitCode !== 0) throw new Error('packed-claude-proof:validate');
+ // The packed CLI's own host validation: `claude plugin validate --strict` against
+ // `.claude-plugin/plugin.json` (plugin.json, hooks/hooks.json, skills/, agents/, commands/),
+ // then `marketplace.json`, then the `--plugin-dir … plugin list --json` load check. A raw
+ // run against the directory would be a marketplace run that never opens the component
+ // files (#475), and the shared runner is what `build`, `validate --artifact`, and `doctor`
+ // execute in production.
+ const validation = await runNodeEntrypoint(cli, [
+ 'validate',
+ '--root',
+ project,
+ '--artifact',
+ artifact,
+ '--strict',
+ '--json',
+ ], { cwd: project, environment });
+ if (validation.exitCode !== 0 || !packedClaudeValidationPassed(validation.stdout, versionNumber)) {
+ throw new Error('packed-claude-proof:validate');
+ }
const plugins = await run('claude', ['--plugin-dir', pluginDirectory, 'plugin', 'list', '--json'], {
cwd: project,
environment,
diff --git a/packages/workbench/src/discovery/discovery-client.ts b/packages/workbench/src/discovery/discovery-client.ts
index 0c067ca7a..63d668b66 100644
--- a/packages/workbench/src/discovery/discovery-client.ts
+++ b/packages/workbench/src/discovery/discovery-client.ts
@@ -107,6 +107,7 @@ const durableStateSchema = z.strictObject({
const findingStateSchema = z.enum([
'conflicted',
'corrupt',
+ 'disabled',
'drifted',
'failed',
'installed',
diff --git a/packages/workbench/src/discovery/discovery-model.ts b/packages/workbench/src/discovery/discovery-model.ts
index ee3ded01f..1aa8a96e8 100644
--- a/packages/workbench/src/discovery/discovery-model.ts
+++ b/packages/workbench/src/discovery/discovery-model.ts
@@ -133,6 +133,8 @@ export const findingPresentationFor = (state: DiscoveryFindingState): DiscoveryP
return presentation('Conflicted', 'warning');
case 'corrupt':
return presentation('Corrupt', 'warning');
+ case 'disabled':
+ return presentation('Disabled', 'warning');
case 'drifted':
return presentation('Drifted', 'warning');
case 'failed':
diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx
index d181ef23c..d4b2a896e 100644
--- a/website/docs/en/guide/distribution/installation.mdx
+++ b/website/docs/en/guide/distribution/installation.mdx
@@ -116,6 +116,17 @@ place of `current`, because a refused copy contributes no hooks, MCP servers, or
installer reads the same array: `agent-bundle install claude` fails with `AB7006` when the freshly
installed (or byte-identical existing) copy carries `errors`, instead of reporting `installed`.
+For Claude, Doctor also reads each row's `enabled` flag and runs the Claude Code developer
+validator. A copy listed with `enabled: false` (switched off with `claude plugin disable` or the
+`/plugin` menu) is reported as `disabled` in the inventory, keeps its content verdict in the
+`--from` comparison, and adds an `AB7327` warning with the `claude plugin enable` command — a
+disabled plugin loads nothing, and reinstalling does not enable it. The validator
+(`claude plugin validate --strict` against `plugin.json`, then `marketplace.json`, the same runs
+`build` and `validate --artifact` perform) covers the `--from` bundle and every installed copy, and
+its findings keep their `AB6019`–`AB6022` codes prefixed with `Bundle at …` or
+`Installed copy at … (scope …)`. Doctor never runs the `--plugin-dir` load check twice: its
+registration proof and the rows' `errors` already hold that verdict.
+
| Code | Severity | Meaning |
| --- | --- | --- |
| `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Doctor inventories state by directory entry and metadata only; it never opens a database. |
@@ -126,6 +137,7 @@ installed (or byte-identical existing) copy carries `errors`, instead of reporti
| `AB7323` | warning | `~/.cursor/hooks.json` registers a command that points into an installed plugin directory, so Cursor would deliver that hook twice; manifest registration alone is sufficient. |
| `AB7324` | info / warning / error | A marketplace staged by `install cursor --mode marketplace` is imported (`registered`), still awaiting the Customize import step (`unregistered`), or incomplete (`corrupt`). |
| `AB7326` | info / warning / error | An Agent Plugins install under `~/.cursor/plugins/local` had its `mcp.json` placeholders expanded by the emitted `install.mjs` and every expanded path still resolves (`expanded`); still carries the spec forms Cursor 3.18.25 does not resolve, so its stdio servers cannot spawn (`unexpanded`); or was moved, duplicated, or edited after the expansion was recorded (`drifted`, entry `corrupt`). Reinstall with the bundle's `install.mjs` at the current location. |
+| `AB7327` | warning | `doctor --from` compared an installed Claude copy whose `claude plugin list --json` row reports `enabled: false`; the recovery is `claude plugin enable @ [--scope ]`. |
| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names, so the unified `plugin` bundle's Claude-format `hooks/hooks.json` beside `hooks/hooks-cursor.json` is not a finding), or when a local plugin contains a symlink escaping `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract, and the loader-recognized entry stays `installed`. |
## Next
diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx
index bba78a784..3307cdda8 100644
--- a/website/docs/en/guide/distribution/validation.mdx
+++ b/website/docs/en/guide/distribution/validation.mdx
@@ -36,7 +36,10 @@ diagnostic involved — see the [CLI exit codes](../../reference/cli.mdx#exit-co
## Claude Code strict validation
When Claude Code is on `PATH`, artifact validation runs the installed `claude plugin validate`
-for emitted `claude` and unified `plugin` targets. Claude Code decides what it checks from the
+for emitted `claude` and unified `plugin` targets. `agent-bundle build` runs the same checks over
+the artifact it just wrote (`--no-host-validation` skips them; `--strict` promotes host warnings to
+build errors), and `agent-bundle doctor --host claude --from ` runs them over the
+bundle and over every installed copy Claude lists. Claude Code decides what it checks from the
manifest it is pointed at, and a bundle directory that holds both `.claude-plugin/plugin.json` and
`.claude-plugin/marketplace.json` is treated as a marketplace, whose validation never opens the
plugin's hook, skill, agent, or command files. Agent Bundle therefore runs two commands:
@@ -56,7 +59,28 @@ claude plugin validate /.claude-plugin/marketplace.json --strict
report. The marketplace run's `plugins[0] plugin.json →` copies of manifest findings the plugin
run already reported are dropped.
- A missing binary is reported as an explicit informational **skip** (`AB6019`), never as
- fabricated success; a probe or run that cannot complete is `AB6022`.
+ fabricated success; a probe or run that cannot complete is `AB6022`. A `build` without `claude`
+ on `PATH` spawns once and reports the skip once, however many `claude`/`plugin` targets it built.
+
+### The load check
+
+Validation is not a load verdict. Claude Code 2.1.250 through 2.1.260 accept, under `--strict`,
+manifests and component files that a session then refuses to load — an invalid
+`monitors/monitors.json`, or a `hooks` field naming the auto-loaded `hooks/hooks.json`. The only
+place the refusal shows is the plugin's row in `claude --plugin-dir plugin list --json`
+("Plugins reference" → "plugin list"): a refused row carries an `errors` array with the host's
+message, a healthy row has no `errors` key. So `build` and `validate --artifact` follow the two
+validation runs with exactly that listing and read the bundle's `@inline` row:
+
+| `load.status` | Meaning | Code |
+| --- | --- | --- |
+| `loaded` | The row has no `errors`. | — |
+| `refused` | The row carries `errors`; they are quoted verbatim. | `AB7325` (error; a warning when every entry is `Dependency "…" is not installed`, since that describes the validating machine rather than the artifact — `--strict` promotes it) |
+| `unregistered` | No row for the bundle: Claude Code did not register the directory. | `AB7311` |
+| `failed` | The listing could not be read (nonzero exit, timeout, not a JSON array). | `AB6022` |
+
+The listing is read-only and needs no login. Doctor does not repeat it: its `--plugin-dir`
+registration proof and the inventory rows' `errors` already carry the same verdicts.
CI should use strict validation:
@@ -81,14 +105,18 @@ visible to a bare `claude plugin list`; put the flag before the subcommand to in
claude --plugin-dir artifact/claude plugin list --json
```
-That listing is the load verdict, and it is stricter than validation: `claude plugin validate --strict`
-accepts a manifest that Claude Code then refuses at load time (for example a `hooks` field naming the
-auto-loaded `hooks/hooks.json`, "Duplicate hooks file detected"). A refused plugin's row carries an
-`errors` array with the host's message; a healthy row has no `errors` key. `agent-bundle doctor --host
-claude --from ` reads that array on both the `--plugin-dir` registration proof and the installed
-copy and reports it as `AB7325` (`load-failed`), and `agent-bundle install claude` fails with `AB7006` when
-the installed copy carries it. The pinned Claude `plugin` schema also rejects `hooks: "./hooks/hooks.json"`
-up front (`AB6012`), since the manifest field is for *additional* hook files only.
+That listing is the load verdict described above, and `build` and `validate --artifact` run it for
+you. `agent-bundle doctor --host claude --from ` reads the same `errors` array on both
+the `--plugin-dir` registration proof and the installed copy and reports it as `AB7325`
+(`load-failed`), and `agent-bundle install claude` fails with `AB7006` when the installed copy
+carries it. The pinned Claude `plugin` schema also rejects `hooks: "./hooks/hooks.json"` up front
+(`AB6012`), since the manifest field is for *additional* hook files only.
+
+Doctor also reads each row's `enabled` flag. A copy that `claude plugin disable` switched off
+("Plugins reference" → "plugin disable") is still installed and may still be byte-identical to the
+bundle, but none of it reaches a session; Doctor lists it as `disabled`, keeps its content verdict,
+and adds an `AB7327` warning whose recovery is the exact `claude plugin enable @
+[--scope ]` command — reinstalling does not enable a disabled plugin.
## Codex and Cursor: pinned schemas
diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx
index ec4d01a6b..066d4a8a1 100644
--- a/website/docs/en/reference/cli.mdx
+++ b/website/docs/en/reference/cli.mdx
@@ -76,6 +76,17 @@ Both take the source option group plus:
Both build package outputs when the project declares `bin` or `lib`.
+`build` alone also takes:
+
+| Option | Default | Meaning |
+| --- | --- | --- |
+| `--host-validation` / `--no-host-validation` | on | After the artifact is written, run the installed Claude Code developer validator and load check over every built `claude` and `plugin` target — the same pair `validate --artifact` runs (`AB6019`–`AB6022`, `AB7311`, `AB7325`). Without `claude` on `PATH` the build spawns once and reports one informational `AB6019` skip. |
+| `--strict` | off | Promote host-tool warnings to errors. |
+
+The JSON document carries the reports under `hostValidation`, one per validated target, each
+with a `load` verdict (`loaded`, `refused`, `unregistered`, or `failed`) when the load check ran.
+Programmatic `build()` calls never run host validation unless `hostValidation: true` is passed.
+
## install
```sh
@@ -108,7 +119,12 @@ Doctor exits `1` when it reports any error diagnostic, and never modifies anythi
it reports the installed copy per host as `current`, `stale` (same version, different content —
`AB7308`), `version-mismatch` (`AB7309`), `foreign` (`AB7321`), `load-failed` (`AB7325` — Claude Code lists
the copy with `errors` in `claude plugin list --json` and refused to load it; the message carries the host's
-text verbatim), or `not-installed` (`AB7307`). For
+text verbatim), or `not-installed` (`AB7307`). For Claude it also reads each row's `enabled` flag: a
+copy listed with `enabled: false` is `disabled` in the inventory and carries `enabled: false` next to its
+content verdict, with an `AB7327` warning naming the `claude plugin enable` command; and it runs the
+Claude Code developer validator (`claude plugin validate --strict` against `plugin.json`, then
+`marketplace.json`) over the `--from` bundle and every installed copy, reporting findings as
+`AB6020`–`AB6022` prefixed with `Bundle at …` or `Installed copy at …`. For
Cursor it also proves plugin-scoped hook registration from the installed manifest (`AB7322`), warns
when `~/.cursor/hooks.json` would deliver a plugin hook a second time (`AB7323`), and tracks a
staged marketplace from `unregistered` to `registered` once Cursor imports it (`AB7324`). For an
@@ -120,7 +136,7 @@ Agent Plugins install it proves the emitted installer's placeholder expansion
| Option | Default | Meaning |
| --- | --- | --- |
| `--artifact ` | — | Validate exactly this built artifact instead of project source. |
-| `--host-validation` / `--no-host-validation` | on | Run installed host developer tools for compatible built targets. |
+| `--host-validation` / `--no-host-validation` | on | Run installed host developer tools for compatible built targets. For `claude` and `plugin` targets this is the two `claude plugin validate --strict` runs followed by the `claude --plugin-dir plugin list --json` load check (`AB7325` when the row carries `errors`, `AB7311` when there is no row). |
| `--strict` | off | Promote host-tool warnings to errors. |
## eval
diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx
index 570dade16..8085acf3e 100644
--- a/website/docs/zh/guide/distribution/installation.mdx
+++ b/website/docs/zh/guide/distribution/installation.mdx
@@ -98,6 +98,14 @@ Claude Code 列出了该副本却拒绝加载它——它在 `claude plugin list
该数组而不是 `current`,因为被拒绝的副本不会为会话提供任何 hook、MCP 服务器或技能。安装器读取同一数组:当刚安装的
(或字节完全相同的既有)副本带有 `errors` 时,`agent-bundle install claude` 以 `AB7006` 失败,而不是报告 `installed`。
+对 Claude,Doctor 还会读取每一行的 `enabled` 标志并运行 Claude Code 开发者校验器。被列为 `enabled: false`
+的副本(用 `claude plugin disable` 或 `/plugin` 菜单关闭)在清点中报告为 `disabled`,在 `--from` 比对中保留其内容
+结论,并追加一条带 `claude plugin enable` 命令的 `AB7327` warning——被禁用的插件不会加载任何内容,重新安装也不会启用它。
+校验器(对 `plugin.json`、再对 `marketplace.json` 执行 `claude plugin validate --strict`,与 `build` 和
+`validate --artifact` 执行的是同样的运行)覆盖 `--from` 捆绑包以及每一份已安装副本,其发现保留 `AB6019`–`AB6022`
+代码,并带有 `Bundle at …` 或 `Installed copy at … (scope …)` 前缀。Doctor 绝不会重复运行 `--plugin-dir` 加载检查:
+它的注册证明与各行的 `errors` 已经承载了该结论。
+
| 代码 | 严重级别 | 含义 |
| --- | --- | --- |
| `AB7316` | warning | 某个已安装捆绑包的 `state/` 目录,或它的某个 `*.sqlite`、`-wal`、`-shm` 文件无法通过文件系统元数据操作读取。Doctor 只按目录条目与元数据清点状态;它绝不打开数据库。 |
@@ -108,6 +116,7 @@ Claude Code 列出了该副本却拒绝加载它——它在 `claude plugin list
| `AB7323` | warning | `~/.cursor/hooks.json` 注册了指向某个已安装插件目录的命令,Cursor 会把该 hook 投递两次;仅靠清单注册就已足够。 |
| `AB7324` | info / warning / error | 由 `install cursor --mode marketplace` 暂存的市场已被导入(`registered`)、仍在等待 Customize 导入步骤(`unregistered`),或不完整(`corrupt`)。 |
| `AB7326` | info / warning / error | `~/.cursor/plugins/local` 下的某个 Agent Plugins 安装:其 `mcp.json` 占位符已由输出的 `install.mjs` 展开且每个展开后的路径仍可解析(`expanded`);仍带有 Cursor 3.18.25 不会解析的规范形式,因此其 stdio 服务器无法启动(`unexpanded`);或在记录展开之后被移动、复制或编辑(`drifted`,条目为 `corrupt`)。请在当前位置用捆绑包的 `install.mjs` 重新安装。 |
+| `AB7327` | warning | `doctor --from` 比对了一份其 `claude plugin list --json` 行报告 `enabled: false` 的已安装 Claude 副本;修复方式是 `claude plugin enable @ [--scope ]`。 |
| `AB7320` | error / info | 当某个 `.cursor-plugin/plugin.json` 安装违反 Cursor 被固定的文档 schema 或 token 位置规则(检查的 hooks 文档是清单 `hooks` 字段所指定的那个,因此统一 `plugin` 捆绑包中与 `hooks/hooks-cursor.json` 并存的 Claude 格式 `hooks/hooks.json` 不构成发现),或某个本地插件包含逃逸出 `~/.cursor/plugins/local` 的符号链接时为 error,该清点条目被报告为 `corrupt`。当某个 `.claude-plugin/plugin.json` 或根 `plugin.json` 安装没有 Cursor 侧被固定的静态文档契约时为 info,被加载器识别的条目仍为 `installed`。 |
## 下一步
diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx
index 49e7c6268..95059e63c 100644
--- a/website/docs/zh/guide/distribution/validation.mdx
+++ b/website/docs/zh/guide/distribution/validation.mdx
@@ -31,7 +31,10 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节,
## Claude Code 严格校验
当 Claude Code 位于 `PATH` 上时,产物校验会为输出的 `claude` target 以及统一的 `plugin` target 运行
-已安装的 `claude plugin validate`。Claude Code 根据被指向的清单决定检查什么:一个同时包含
+已安装的 `claude plugin validate`。`agent-bundle build` 会对刚写出的产物运行同样的检查
+(`--no-host-validation` 跳过;`--strict` 把宿主 warning 提升为构建 error),而
+`agent-bundle doctor --host claude --from ` 会对该 bundle 以及 Claude 列出的每一份已安装副本运行
+这些检查。Claude Code 根据被指向的清单决定检查什么:一个同时包含
`.claude-plugin/plugin.json` 与 `.claude-plugin/marketplace.json` 的 bundle 目录会被当作 marketplace,
而 marketplace 校验从不打开插件的 hook、skill、agent 或 command 文件。因此 Agent Bundle 运行两条命令:
@@ -49,7 +52,26 @@ claude plugin validate /.claude-plugin/marketplace.json --strict
及更高版本上,Agent Bundle 读取 CLI 的 `--json` 报告;更早的版本则解析文本报告。marketplace 那次
运行中对插件运行已报告过的清单发现的 `plugins[0] plugin.json →` 副本会被丢弃。
- 二进制文件缺失会被报告为一次显式的信息性**跳过**(`AB6019`),绝不会伪造成功;探测或运行无法完成
- 时为 `AB6022`。
+ 时为 `AB6022`。`PATH` 上没有 `claude` 时,`build` 只会启动一次进程并只报告一次跳过,无论它构建了多少个
+ `claude`/`plugin` target。
+
+### 加载检查
+
+校验不是加载结论。Claude Code 2.1.250 到 2.1.260 在 `--strict` 下会接受一些随后会被会话拒绝加载的清单与
+组件文件——例如无效的 `monitors/monitors.json`,或指向自动加载的 `hooks/hooks.json` 的 `hooks` 字段。拒绝
+唯一显现的地方是 `claude --plugin-dir plugin list --json`("Plugins reference" → "plugin list")
+中该插件所在的行:被拒绝的行带有 `errors` 数组,内含宿主的消息;健康的行没有 `errors` 键。因此 `build` 与
+`validate --artifact` 在两次校验运行之后正好执行这条列表命令,并读取 bundle 的 `@inline` 行:
+
+| `load.status` | 含义 | 代码 |
+| --- | --- | --- |
+| `loaded` | 该行没有 `errors`。 | — |
+| `refused` | 该行带有 `errors`;逐字引用。 | `AB7325`(error;当每一项都是 `Dependency "…" is not installed` 时为 warning,因为它描述的是执行校验的机器而非产物——`--strict` 会提升它) |
+| `unregistered` | 没有该 bundle 的行:Claude Code 没有把该目录注册为插件。 | `AB7311` |
+| `failed` | 列表无法读取(非零退出、超时、不是 JSON 数组)。 | `AB6022` |
+
+这条列表命令是只读的,也不需要登录。Doctor 不会重复它:其 `--plugin-dir` 注册证明与清单行中的 `errors`
+已经承载了同样的结论。
CI 应当使用严格校验:
@@ -74,12 +96,16 @@ claude --plugin-dir artifact/claude
claude --plugin-dir artifact/claude plugin list --json
```
-这份列表才是加载结论,而且比校验更严格:`claude plugin validate --strict` 会接受一份 Claude Code 随后在加载时
-拒绝的清单(例如 `hooks` 字段指向自动加载的 `hooks/hooks.json`——"Duplicate hooks file detected")。被拒绝插件
-所在的行带有 `errors` 数组,内含宿主的消息;健康的行没有 `errors` 键。`agent-bundle doctor --host claude --from
-` 会在 `--plugin-dir` 注册证明与已安装副本两处读取该数组,并报告为 `AB7325`(`load-failed`);当已安装
-副本带有该数组时,`agent-bundle install claude` 以 `AB7006` 失败。被固定的 Claude `plugin` schema 也会预先拒绝
-`hooks: "./hooks/hooks.json"`(`AB6012`),因为清单中的该字段只用于*额外的* hook 文件。
+这份列表就是上文描述的加载结论,`build` 与 `validate --artifact` 会替你运行它。`agent-bundle doctor --host
+claude --from ` 会在 `--plugin-dir` 注册证明与已安装副本两处读取同一个 `errors` 数组,并报告为
+`AB7325`(`load-failed`);当已安装副本带有该数组时,`agent-bundle install claude` 以 `AB7006` 失败。被固定的
+Claude `plugin` schema 也会预先拒绝 `hooks: "./hooks/hooks.json"`(`AB6012`),因为清单中的该字段只用于*额外的*
+hook 文件。
+
+Doctor 还会读取每一行的 `enabled` 标志。被 `claude plugin disable` 关闭的副本("Plugins reference" →
+"plugin disable")仍然处于已安装状态,甚至可能与 bundle 字节一致,但它的任何内容都不会进入会话;Doctor 把它列为
+`disabled`,保留其内容结论,并追加一条 `AB7327` warning,其修复建议就是精确的
+`claude plugin enable @ [--scope ]` 命令——重新安装不会启用一个被禁用的插件。
## Codex 与 Cursor:被固定的 schema
diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx
index 7db6314f8..71b2d2b97 100644
--- a/website/docs/zh/reference/cli.mdx
+++ b/website/docs/zh/reference/cli.mdx
@@ -76,6 +76,17 @@ proxy 从父命令 `dev` 继承 `--root`,并把诊断写到 stderr。
当项目声明了 `bin` 或 `lib` 时,两者都会构建包输出。
+仅 `build` 还接受:
+
+| 选项 | 默认值 | 含义 |
+| --- | --- | --- |
+| `--host-validation` / `--no-host-validation` | 开启 | 产物写出后,对每个已构建的 `claude` 与 `plugin` target 运行已安装的 Claude Code 开发者校验器与加载检查——与 `validate --artifact` 运行的是同一对检查(`AB6019`–`AB6022`、`AB7311`、`AB7325`)。`PATH` 上没有 `claude` 时,构建只启动一次进程并报告一条信息性的 `AB6019` 跳过。 |
+| `--strict` | 关闭 | 把宿主工具的 warning 提升为 error。 |
+
+JSON 文档在 `hostValidation` 下携带这些报告,每个被校验的 target 一份;加载检查运行过时,每份报告带有
+`load` 结论(`loaded`、`refused`、`unregistered` 或 `failed`)。以编程方式调用 `build()` 时,除非传入
+`hostValidation: true`,否则绝不运行宿主校验。
+
## install
```sh
@@ -105,7 +116,11 @@ agent-bundle install [--from ] [--scope ] [--mode [--from ] [--scope ] [--mode ` | —— | 精确校验这份已构建产物,而不是项目源码。 |
-| `--host-validation` / `--no-host-validation` | 开启 | 为兼容的已构建 target 运行已安装的宿主开发者工具。 |
+| `--host-validation` / `--no-host-validation` | 开启 | 为兼容的已构建 target 运行已安装的宿主开发者工具。对 `claude` 与 `plugin` target,这是两次 `claude plugin validate --strict` 运行,随后是 `claude --plugin-dir plugin list --json` 加载检查(该行带有 `errors` 时为 `AB7325`,没有该行时为 `AB7311`)。 |
| `--strict` | 关闭 | 把宿主工具的 warning 提升为 error。 |
## eval
diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts
index 83b02fe92..7b35169d2 100644
--- a/website/plugins/generated-reference.ts
+++ b/website/plugins/generated-reference.ts
@@ -169,6 +169,7 @@ const messages = {
reason: 'Reason',
channel: 'Channel',
hosts: 'Hosts',
+ detail: 'Detail',
},
unavailable: 'unavailable',
notApplicable: '—',
@@ -249,6 +250,7 @@ const messages = {
reason: '原因',
channel: '通道',
hosts: '宿主',
+ detail: '详情',
},
unavailable: 'unavailable',
notApplicable: '—',