diff --git a/.changeset/claude-plugin-validate-manifests.md b/.changeset/claude-plugin-validate-manifests.md new file mode 100644 index 000000000..1886f03a5 --- /dev/null +++ b/.changeset/claude-plugin-validate-manifests.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Make `agent-bundle validate --artifact` run Claude Code's validator against `.claude-plugin/plugin.json` and, when the bundle emits one, `.claude-plugin/marketplace.json`, instead of the bundle directory: Claude Code treats a directory holding both manifests as a marketplace and never opens `hooks/hooks.json`, `skills/`, `agents/`, or `commands/`, so hook, skill, and agent findings were invisible to the `claude` and `plugin` targets. On Claude Code 2.1.259 or later the runs use `claude plugin validate --json`, and every `AB6020` warning and `AB6021` error now names the validated file (`generatedPath`) and Claude Code's field path; older releases fall back to the text report with the same attribution. Duplicate `plugins[N] plugin.json →` manifest findings from the marketplace run are dropped, notes surface as info, and a run that returns no report is `AB6022` with the CLI's stderr. The native Claude eval gate validates `plugin.json` the same way. (#474) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 36320acaa..31ace269b 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -46,6 +46,32 @@ even when no error diagnostic was reported. | `AB8xxx` | Development server configuration. | | `AB9xxx` | Eval selection, harnesses, and persisted runs. | +## Claude Code host validation (`AB6019`–`AB6022`) + +`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 +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" → +"Marketplace validation errors"). Agent Bundle emits both manifests side by +side, so it runs `claude plugin validate /.claude-plugin/plugin.json --strict` +first, which covers `plugin.json`, `hooks/hooks.json`, and the `skills/`, +`agents/`, and `commands/` directories, and then +`claude plugin validate /.claude-plugin/marketplace.json --strict`, dropping +the marketplace run's `plugins[N] plugin.json →` copies of manifest findings the +plugin run already reported. On Claude Code 2.1.259 or later both runs add +`--json` and each finding is attributed to its file (`generatedPath`); older +releases fall back to the text report, attributed by its `Validating : +` headers. + +| 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`. | + ## Cursor built-artifact validation (`AB6026`–`AB6029`) | Code | Severity | Trigger | Recovery | diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 69b91c54e..e5843c547 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -132,11 +132,21 @@ manifests at files inside those payloads without compiling them. Payload files c ### Validate Claude bundles with Claude Code -Artifact validation runs `claude plugin validate --strict` for emitted `claude` -and unified `plugin` targets when Claude Code is on `PATH`. Host errors become Agent Bundle -errors; host warnings remain warnings unless `agent-bundle validate --strict` is set. A missing -binary is reported as an explicit informational skip, never as fabricated success. Use -`--no-host-validation` when a deterministic schema-only check is required. +When Claude Code is on `PATH`, artifact validation runs its validator for emitted `claude` and +unified `plugin` targets. Claude Code treats a directory that holds both `.claude-plugin/plugin.json` +and `.claude-plugin/marketplace.json` as a marketplace and then never opens the plugin's hook, +skill, agent, or command files, so Agent Bundle names each manifest: + +```sh +claude plugin validate /.claude-plugin/plugin.json --strict +claude plugin validate /.claude-plugin/marketplace.json --strict +``` + +On Claude Code 2.1.259 or later both runs use `--json`; older releases are parsed from the text +report. Host errors become Agent Bundle errors (`AB6021`); host warnings remain warnings +(`AB6020`) unless `agent-bundle validate --strict` is set, and every finding names the validated +file. A missing binary is reported as an explicit informational skip (`AB6019`), never as +fabricated success. Use `--no-host-validation` when a deterministic schema-only check is required. CI should use strict validation: diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index bb673ee4c..562407efe 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -799,6 +799,8 @@ "Placeholder substitution for LSP servers is limited to command, args, env, and workspaceFolder.", "Codex and Cursor publish no plugin LSP surface at their pinned revisions, so the unified bundle's .lsp.json reaches Claude Code only.", "Plugin developer tools reference: `claude plugin validate ` checks plugin.json, hooks/hooks.json, and default-directory Skill, agent, and command frontmatter; manifest-less component directories require 2.1.233 or later.", + "2026-09-03: plugins-reference (plugin validate) documents `--json` as requiring Claude Code v2.1.259 or later, with a report of `success`, `strict`, `target`, `manifest`, and per-file `contents` entries carrying `errors`, `warnings`, and `notes`; the pinned 2.1.250 revision answers `error: unknown option '--json'`, so Agent Bundle gates the flag on the probed version and parses the text report below it.", + "2026-09-03: plugin-marketplaces (Marketplace validation errors) states that from a marketplace directory Claude Code does not open the plugins' skill, agent, command, or hook files; because the emitted bundle places .claude-plugin/marketplace.json beside plugin.json, a directory run validates the marketplace only, and artifact validation names each manifest explicitly.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents plugin commands/ as Markdown files with optional YAML frontmatter fields description, argument-hint, allowed-tools, model, and disable-model-invocation; the filename stem is the plugin-namespaced command name.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents plugin-root bin/ as \"Plugin executables added to PATH\", with executables \"Invokable as bare command in Bash tool\"; its file-locations table says: \"Executables added to the Bash tool's PATH and invokable as bare commands while the plugin is enabled. You can't include this directory in a plugin you distribute through claude.ai organization settings\".", "2026-09-01: https://code.claude.com/docs/en/plugins documents plugin-root bin/ as \"Executables added to the Bash tool's PATH while the plugin is enabled. You can't include this directory in a plugin you distribute through claude.ai organization settings\".", diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 1363b396d..539ac4ac3 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -52,7 +52,7 @@ "url": "https://code.claude.com/docs/en/plugins-reference" } }, - "validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Artifact validation can additionally invoke `claude plugin validate --strict`; Agent Bundle preserves host warnings unless its own strict option is enabled, and reports an explicit unavailable diagnostic when the CLI is absent.", + "validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Artifact validation additionally invokes `claude plugin validate /.claude-plugin/plugin.json --strict` and, when the bundle emits one, `claude plugin validate /.claude-plugin/marketplace.json --strict`; a directory run would validate the marketplace and never open hooks/, skills/, agents/, or commands/ (plugin-marketplaces reference, Marketplace validation errors). Both runs add `--json` on Claude Code 2.1.259 or later (plugins-reference, plugin validate) and fall back to the text report before that. Agent Bundle preserves host warnings unless its own strict option is enabled, and reports an explicit unavailable diagnostic when the CLI is absent.", "developerTools": { "retrievedAt": "2026-09-01", "source": "https://code.claude.com/docs/en/plugins-reference", diff --git a/packages/agent-bundle/src/build/artifact-diagnostics.ts b/packages/agent-bundle/src/build/artifact-diagnostics.ts index 9e440f81c..df2c3eecd 100644 --- a/packages/agent-bundle/src/build/artifact-diagnostics.ts +++ b/packages/agent-bundle/src/build/artifact-diagnostics.ts @@ -50,8 +50,8 @@ export const artifactDiagnosticRecoveries: Readonly --strict`, repair the warning, and rebuild.', - AB6021: 'Run `claude plugin validate --strict`, repair the error, and rebuild.', + AB6020: 'Run `claude plugin validate /.claude-plugin/plugin.json --strict`, repair the warning, and rebuild.', + AB6021: 'Run `claude plugin validate /.claude-plugin/plugin.json --strict`, repair the error, and rebuild.', AB6022: 'Restore a bounded Claude validator process, then rerun artifact validation.', AB6023: 'Rebuild the artifact so every built-in target includes its generated INSTALL.md.', AB6024: 'Rebuild the Cursor-compatible artifact so it includes its generated install.mjs.', 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 872089e5f..d28b17a29 100644 --- a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts @@ -1,5 +1,5 @@ -import { readFile, readdir } from 'node:fs/promises'; -import { dirname, join, posix, resolve } from 'node:path'; +import { access, readFile, readdir } from 'node:fs/promises'; +import { dirname, join, posix, relative, resolve, sep } from 'node:path'; import { claudeArtifactValidation } from '../adapters/claude.ts'; import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; @@ -69,31 +69,14 @@ const diagnostic = ( 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 --strict`.' - : 'Run `claude plugin validate --strict`, repair the reported Claude artifact, and rebuild.', + ? '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.', severity, target, }); -const issueLines = (output: string): readonly { readonly message: string; readonly severity: 'error' | 'warning' }[] => { - const issues: { message: string; severity: 'error' | 'warning' }[] = []; - let section: 'error' | 'warning' | undefined; - for (const rawLine of output.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (/Found \d+ warnings?:/u.test(line)) { - section = 'warning'; - continue; - } - if (/Found \d+ errors?:/u.test(line)) { - section = 'error'; - continue; - } - if (!line.startsWith('❯ ') || section === undefined) continue; - issues.push(Object.freeze({ message: line.slice(2).trim(), severity: section })); - } - return Object.freeze(issues); -}; - const matchingDocumentPaths = async ( root: string, contractPath: string, @@ -201,6 +184,223 @@ export const validateClaudePluginFiles = async ( return freezeDiagnostics(diagnostics); }; +type ClaudeFindingSeverity = 'error' | 'note' | 'warning'; + +interface ClaudeFinding { + /** Plugin-relative path of the validated file, when the report attributed one. */ + readonly file?: string; + readonly message: string; + readonly path?: string; + readonly severity: ClaudeFindingSeverity; + /** Claude's file type label: `plugin`, `marketplace`, `hooks`, `skill`, `agent`, `command`. */ + readonly type?: string; +} + +type ClaudeValidationRun = 'marketplace' | 'plugin'; + +/** `claude plugin validate --json` landed in Claude Code 2.1.259 (plugins-reference §plugin validate). */ +const jsonReportMinimumVersion: readonly [number, number, number] = [2, 1, 259]; + +const parseVersion = (version: string): readonly [number, number, number] | undefined => { + const match = /^(\d+)\.(\d+)\.(\d+)/u.exec(version); + return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])]; +}; + +export const claudeSupportsJsonValidationReport = (version: string | undefined): boolean => { + if (version === undefined) return false; + const parsed = parseVersion(version); + if (parsed === undefined) return false; + for (let index = 0; index < 3; index += 1) { + if (parsed[index] !== jsonReportMinimumVersion[index]) return parsed[index] > jsonReportMinimumVersion[index]; + } + return true; +}; + +const relativeToPlugin = (file: string, pluginDirectory: string): string => { + const absolute = resolve(pluginDirectory, file); + const rel = relative(pluginDirectory, absolute); + return rel === '' ? '.' : rel.split(sep).join('/'); +}; + +/** One finding line: `❯ : `; a line without `: ` carries only a message. */ +const splitFindingLine = (line: string): { readonly message: string; readonly path?: string } => { + const separator = line.indexOf(': '); + if (separator === -1) return { message: line }; + const path = line.slice(0, separator).trim(); + if (path === '' || /\s/u.test(path.replace(/^plugins\[\d+\] plugin\.json → /u, ''))) return { message: line }; + return { message: line.slice(separator + 2).trim(), path }; +}; + +const findingsFromText = (output: string, pluginDirectory: string): readonly ClaudeFinding[] => { + const findings: ClaudeFinding[] = []; + let section: 'error' | 'warning' | undefined; + let file: string | undefined; + let type: string | undefined; + for (const rawLine of output.split(/\r?\n/u)) { + const line = rawLine.trim(); + const header = /^Validating ([a-z]+(?: [a-z]+)?): (.+)$/u.exec(line); + if (header !== null) { + type = header[1] === 'plugin manifest' ? 'plugin' : header[1] === 'marketplace manifest' ? 'marketplace' : header[1]; + file = relativeToPlugin(header[2].trim(), pluginDirectory); + section = undefined; + continue; + } + if (/Found \d+ warnings?:/u.test(line)) { + section = 'warning'; + continue; + } + if (/Found \d+ errors?:/u.test(line)) { + section = 'error'; + continue; + } + if (!line.startsWith('❯ ') || section === undefined) continue; + findings.push(Object.freeze({ + ...splitFindingLine(line.slice(2).trim()), + severity: section, + ...(file === undefined ? {} : { file }), + ...(type === undefined ? {} : { type }), + })); + } + return Object.freeze(findings); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const findingsFromReportEntry = ( + entry: unknown, + severity: ClaudeFindingSeverity, + pluginDirectory: string, + fallbackType: string, +): readonly ClaudeFinding[] => { + if (!isRecord(entry)) return Object.freeze([]); + const key = severity === 'error' ? 'errors' : severity === 'warning' ? 'warnings' : 'notes'; + const items = entry[key]; + if (!Array.isArray(items)) return Object.freeze([]); + const file = typeof entry.file === 'string' ? relativeToPlugin(entry.file, pluginDirectory) : undefined; + const type = typeof entry.type === 'string' ? entry.type : fallbackType; + const findings: ClaudeFinding[] = []; + for (const item of items) { + if (!isRecord(item) || typeof item.message !== 'string') continue; + findings.push(Object.freeze({ + message: item.message, + severity, + type, + ...(typeof item.path === 'string' && item.path !== '' ? { path: item.path } : {}), + ...(file === undefined ? {} : { file }), + })); + } + return Object.freeze(findings); +}; + +/** + * Parse a `claude plugin validate --json` report (plugins-reference §plugin validate): + * `{ success, strict, target, manifest | null, contents[] }`, where `manifest` and each + * `contents[]` entry carry `file`, `type`, `errors`, `warnings`, and `notes`. + */ +const findingsFromJsonReport = ( + stdout: string, + pluginDirectory: string, + run: ClaudeValidationRun, +): { readonly findings: readonly ClaudeFinding[]; readonly success: boolean } | undefined => { + const start = stdout.indexOf('{'); + if (start === -1) return undefined; + let report: unknown; + try { + report = JSON.parse(stdout.slice(start)) as unknown; + } catch { + return undefined; + } + if (!isRecord(report) || typeof report.success !== 'boolean') return undefined; + const findings: ClaudeFinding[] = []; + const entries: unknown[] = [report.manifest, ...(Array.isArray(report.contents) ? report.contents : [])]; + for (const entry of entries) { + for (const severity of ['error', 'warning', 'note'] as const) { + findings.push(...findingsFromReportEntry(entry, severity, pluginDirectory, run)); + } + } + return Object.freeze({ findings: Object.freeze(findings), success: report.success }); +}; + +const marketplaceEntryPrefix = /^plugins\[\d+\] plugin\.json → /u; + +/** + * A marketplace run re-reports each local plugin's manifest findings prefixed with the entry + * index (plugin-marketplaces §Marketplace validation errors). Drop the ones the plugin run + * already reported so the artifact report lists each manifest finding once. + */ +const withoutDuplicateManifestFindings = ( + pluginFindings: readonly ClaudeFinding[], + marketplaceFindings: readonly ClaudeFinding[], +): readonly ClaudeFinding[] => { + const seen = new Set(pluginFindings + .filter((finding) => finding.type === 'plugin') + .map((finding) => `${finding.severity}\u0000${finding.path ?? ''}\u0000${finding.message}`)); + return Object.freeze(marketplaceFindings.filter((finding) => { + if (finding.path === undefined || !marketplaceEntryPrefix.test(finding.path)) return true; + const path = finding.path.replace(marketplaceEntryPrefix, ''); + return !seen.has(`${finding.severity}\u0000${path}\u0000${finding.message}`); + })); +}; + +const findingDiagnostic = (finding: ClaudeFinding, strict: boolean, target: string): Diagnostic => { + const location = finding.file === undefined + ? finding.type === undefined ? '' : ` (${finding.type})` + : ` (${finding.type ?? 'file'} ${finding.file})`; + const detail = finding.path === undefined ? finding.message : `${finding.path}: ${finding.message}`; + const base = diagnostic( + finding.severity === 'error' ? 'AB6021' : 'AB6020', + `Claude plugin validation${location}: ${detail}`, + finding.severity === 'error' || (finding.severity === 'warning' && strict) ? 'error' + : finding.severity === 'warning' ? 'warning' : 'info', + target, + ); + return finding.file === undefined ? base : Object.freeze({ ...base, generatedPath: finding.file }); +}; + +const failedReport = ( + message: string, + target: string, + version: string | undefined, +): ClaudePluginValidationReport => Object.freeze({ + diagnostics: freezeDiagnostics([diagnostic('AB6022', message, 'error', target)]), + host: 'claude', + status: 'failed', + target, + ...(version === undefined ? {} : { version }), +}); + +const fileExists = async (path: string): Promise => { + try { + await access(path); + return true; + } catch { + return false; + } +}; + +interface ClaudeValidationTarget { + readonly path: string; + readonly run: ClaudeValidationRun; +} + +/** + * `claude plugin validate ` validates the marketplace when `.claude-plugin/marketplace.json` + * is present and, from a marketplace, never opens the plugin's skill, agent, command, or hook files + * (plugin-marketplaces §Marketplace validation errors). Agent Bundle emits both manifests side by + * side, so run the plugin manifest first (covers `plugin.json`, `hooks/hooks.json`, `skills/`, + * `agents/`, `commands/`) and the marketplace manifest second. + */ +const validationTargets = async (pluginDirectory: string): Promise => { + const manifestDirectory = join(pluginDirectory, '.claude-plugin'); + const pluginManifest = join(manifestDirectory, 'plugin.json'); + const marketplaceManifest = join(manifestDirectory, 'marketplace.json'); + const targets: ClaudeValidationTarget[] = []; + targets.push({ path: (await fileExists(pluginManifest)) ? pluginManifest : pluginDirectory, run: 'plugin' }); + if (await fileExists(marketplaceManifest)) targets.push({ path: marketplaceManifest, run: 'marketplace' }); + return Object.freeze(targets); +}; + export const validateClaudePlugin = async ( options: ValidateClaudePluginOptions, ): Promise => { @@ -212,36 +412,20 @@ export const validateClaudePlugin = async ( try { const probe = await run(Object.freeze({ args: Object.freeze(['--version']), cwd, executable })); if (probe.exitCode !== 0 || probe.termination !== undefined) { - return Object.freeze({ - diagnostics: freezeDiagnostics([diagnostic( - 'AB6022', - 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'}.`, - 'error', - options.target, - )]), - host: 'claude', - status: 'failed', - target: options.target, - }); + 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, + ); } version = versionFrom(`${probe.stdout}\n${probe.stderr}`); } catch (error) { if (!isErrno(error, 'ENOENT')) { - return Object.freeze({ - diagnostics: freezeDiagnostics([diagnostic( - 'AB6022', - 'Claude CLI version probe could not be started.', - 'error', - options.target, - )]), - host: 'claude', - status: 'failed', - target: options.target, - }); + return failedReport('Claude CLI version probe could not be started.', options.target, undefined); } return Object.freeze({ diagnostics: freezeDiagnostics([diagnostic( @@ -256,71 +440,74 @@ export const validateClaudePlugin = async ( }); } - let result: ClaudePluginCommandResult; - try { - result = await run(Object.freeze({ - args: Object.freeze(['plugin', 'validate', pluginDirectory, '--strict']), - cwd, - executable, - })); - } catch { - return Object.freeze({ - diagnostics: freezeDiagnostics([diagnostic( - 'AB6022', - 'Claude host artifact validation could not be started.', - 'error', - options.target, - )]), - host: 'claude', - status: 'failed', - target: options.target, - ...(version === undefined ? {} : { version }), - }); - } - - if (result.termination !== undefined) { - return Object.freeze({ - diagnostics: freezeDiagnostics([diagnostic( - 'AB6022', + const jsonReport = claudeSupportsJsonValidationReport(version); + const findingsByRun: Partial> = {}; + for (const validationTarget of await validationTargets(pluginDirectory)) { + const label = validationTarget.run === 'marketplace' + ? 'Claude marketplace manifest validation' + : 'Claude host artifact validation'; + let result: ClaudePluginCommandResult; + try { + result = await run(Object.freeze({ + args: Object.freeze([ + 'plugin', + 'validate', + validationTarget.path, + '--strict', + ...(jsonReport ? ['--json'] : []), + ]), + cwd, + executable, + })); + } catch { + return failedReport(`${label} could not be started.`, options.target, version); + } + if (result.termination !== undefined) { + return failedReport( result.termination === 'timed-out' - ? 'Claude host artifact validation timed out.' - : 'Claude host artifact validation exceeded its output limit.', - 'error', + ? `${label} timed out.` + : `${label} exceeded its output limit.`, options.target, - )]), - host: 'claude', - status: 'failed', - target: options.target, - ...(version === undefined ? {} : { version }), - }); + version, + ); + } + if (jsonReport) { + const report = findingsFromJsonReport(result.stdout, pluginDirectory, validationTarget.run); + if (report === undefined) { + // Exit 2 writes nothing to stdout; the reason is on stderr. + const reason = result.stderr.trim(); + return failedReport( + `${label} did not return a JSON report (exit code ${result.exitCode ?? 'unknown'})` + + `${reason === '' ? '' : `: ${reason}`}.`, + options.target, + version, + ); + } + if (!report.success && report.findings.every((finding) => finding.severity === 'note')) { + return failedReport(`${label} failed without structured issue output.`, options.target, version); + } + findingsByRun[validationTarget.run] = report.findings; + continue; + } + const findings = findingsFromText(`${result.stdout}\n${result.stderr}`, pluginDirectory); + if (result.exitCode !== 0 && findings.length === 0) { + return failedReport(`${label} failed without structured issue output.`, options.target, version); + } + findingsByRun[validationTarget.run] = findings; } - const parsed = issueLines(`${result.stdout}\n${result.stderr}`); - const diagnostics = freezeDiagnostics(parsed.map((issue) => diagnostic( - issue.severity === 'warning' ? 'AB6020' : 'AB6021', - `Claude plugin validation: ${issue.message}`, - issue.severity === 'warning' && options.strict !== true ? 'warning' : 'error', - options.target, - ))); - if (result.exitCode !== 0 && diagnostics.length === 0) { - return Object.freeze({ - diagnostics: freezeDiagnostics([diagnostic( - 'AB6022', - 'Claude host artifact validation failed without structured issue output.', - 'error', - options.target, - )]), - host: 'claude', - status: 'failed', - target: options.target, - ...(version === undefined ? {} : { version }), - }); - } + const pluginFindings = findingsByRun.plugin ?? []; + const marketplaceFindings = withoutDuplicateManifestFindings(pluginFindings, findingsByRun.marketplace ?? []); + const diagnostics = freezeDiagnostics( + [...pluginFindings, ...marketplaceFindings] + .map((finding) => findingDiagnostic(finding, options.strict === true, options.target)), + ); const failed = diagnostics.some((entry) => entry.severity === 'error'); + const blocking = diagnostics.some((entry) => entry.severity !== 'info'); return Object.freeze({ diagnostics, host: 'claude', - status: failed ? 'failed' : diagnostics.length === 0 ? 'passed' : 'warnings', + status: failed ? 'failed' : blocking ? 'warnings' : 'passed', target: options.target, ...(version === undefined ? {} : { version }), }); diff --git a/packages/agent-bundle/src/host-contracts/native-claude-contract.ts b/packages/agent-bundle/src/host-contracts/native-claude-contract.ts index 0dd485d16..b385fd80d 100644 --- a/packages/agent-bundle/src/host-contracts/native-claude-contract.ts +++ b/packages/agent-bundle/src/host-contracts/native-claude-contract.ts @@ -622,8 +622,10 @@ const runNativeClaudeSmokeUnchecked = async (options: NativeClaudeSmokeOptions): }); } + // Name the plugin manifest, not the directory: with `.claude-plugin/marketplace.json` beside it, + // a directory run validates the marketplace and never opens hooks/, skills/, or agents/. const validationRequest: NativeClaudeProcessRequest = Object.freeze({ - args: Object.freeze(['plugin', 'validate', '--strict', options.pluginDirectory]), + args: Object.freeze(['plugin', 'validate', '--strict', join(options.pluginDirectory, '.claude-plugin', 'plugin.json')]), cwd: options.cwd, environment, executable: 'claude', diff --git a/packages/agent-bundle/tests/claude-plugin-validation.test.ts b/packages/agent-bundle/tests/claude-plugin-validation.test.ts index a340894c2..74af32c05 100644 --- a/packages/agent-bundle/tests/claude-plugin-validation.test.ts +++ b/packages/agent-bundle/tests/claude-plugin-validation.test.ts @@ -1,10 +1,11 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { afterEach, expect, it } from '@rstest/core'; import { + claudeSupportsJsonValidationReport, validateClaudePlugin, validateClaudePluginFiles, type ClaudePluginCommandRunner, @@ -12,6 +13,43 @@ import { const fixtureRoots: string[] = []; +/** Reports recorded from the real Claude CLI on this machine; `/bundle/claude` stands in for the bundle path. */ +const recordedReport = async (name: string, pluginDirectory: string): Promise => + (await readFile(new URL(`./fixtures/claude-plugin-validate/${name}`, import.meta.url), 'utf8')) + .replaceAll('/bundle/claude', pluginDirectory); + +/** A bundle directory shaped like `agent-bundle build --target claude` output: both manifests side by side. */ +const emittedClaudeBundle = async (withMarketplace = true): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-bundle-')); + fixtureRoots.push(root); + await mkdir(join(root, '.claude-plugin'), { recursive: true }); + await writeFile(join(root, '.claude-plugin', 'plugin.json'), '{"name":"fixture"}\n'); + if (withMarketplace) { + await writeFile(join(root, '.claude-plugin', 'marketplace.json'), '{"name":"fixture","plugins":[]}\n'); + } + return root; +}; + +const runByTarget = ( + responses: Readonly>>, + version = '2.1.259', +): { readonly calls: readonly string[][]; readonly run: ClaudePluginCommandRunner } => { + const calls: string[][] = []; + return { + calls, + run: async (request) => { + calls.push([...request.args]); + if (request.args[0] === '--version') { + return { exitCode: 0, signal: null, stderr: '', stdout: `${version} (Claude Code)\n` }; + } + const target = request.args[2] ?? ''; + const response = responses[target.endsWith('marketplace.json') ? 'marketplace' : 'plugin']; + if (response === undefined) throw new Error(`unexpected validation target ${target}`); + return { exitCode: response.exitCode, signal: null, stderr: response.stderr ?? '', stdout: response.stdout }; + }, + }; +}; + afterEach(async () => { await Promise.all(fixtureRoots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); }); @@ -320,3 +358,176 @@ it('fails host validation when the Claude version probe cannot be spawned', asyn status: 'failed', }); }); + +it('gates the --json report on Claude Code 2.1.259 or later', () => { + expect(claudeSupportsJsonValidationReport(undefined)).toBe(false); + expect(claudeSupportsJsonValidationReport('2.1.250')).toBe(false); + expect(claudeSupportsJsonValidationReport('2.1.258')).toBe(false); + expect(claudeSupportsJsonValidationReport('2.1.259')).toBe(true); + expect(claudeSupportsJsonValidationReport('2.1.300')).toBe(true); + expect(claudeSupportsJsonValidationReport('2.2.0')).toBe(true); + expect(claudeSupportsJsonValidationReport('3.0.0')).toBe(true); + expect(claudeSupportsJsonValidationReport('nightly')).toBe(false); +}); + +it('validates the plugin manifest and the marketplace manifest separately with --json on 2.1.259', async () => { + const bundle = await emittedClaudeBundle(); + const fixture = runByTarget({ + 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(fixture.calls).toEqual([ + ['--version'], + ['plugin', 'validate', join(bundle, '.claude-plugin', 'plugin.json'), '--strict', '--json'], + ['plugin', 'validate', join(bundle, '.claude-plugin', 'marketplace.json'), '--strict', '--json'], + ]); + expect(report).toEqual({ diagnostics: [], host: 'claude', status: 'passed', target: 'claude', version: '2.1.259' }); +}); + +it('skips the marketplace run when the bundle emits no marketplace.json', async () => { + const bundle = await emittedClaudeBundle(false); + const fixture = runByTarget({ + 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')]); +}); + +it('attributes --json findings to their file and de-duplicates marketplace re-reports of manifest findings', async () => { + const bundle = await emittedClaudeBundle(); + const fixture = runByTarget({ + marketplace: { exitCode: 1, stdout: await recordedReport('2.1.259-marketplace-strict-findings.json', bundle) }, + plugin: { exitCode: 1, stdout: await recordedReport('2.1.259-plugin-strict-findings.json', bundle) }, + }); + const report = await validateClaudePlugin({ pluginDirectory: bundle, run: fixture.run, target: 'claude' }); + + expect(report.status).toBe('warnings'); + expect(report.diagnostics).toEqual([ + expect.objectContaining({ + code: 'AB6020', + generatedPath: '.claude-plugin/plugin.json', + message: "Claude plugin validation (plugin .claude-plugin/plugin.json): displayNme: Unknown field 'displayNme' " + + "— did you mean 'displayName'? Claude Code ignores unrecognized fields at load time, so this field has no effect.", + severity: 'warning', + }), + expect.objectContaining({ + code: 'AB6020', + generatedPath: '.claude-plugin/plugin.json', + message: expect.stringContaining("bogus: Unknown field 'bogus'"), + }), + expect.objectContaining({ + code: 'AB6020', + generatedPath: 'agents/bad.md', + message: expect.stringContaining('(agent agents/bad.md): description: No description in frontmatter'), + }), + expect.objectContaining({ + code: 'AB6020', + generatedPath: 'hooks/hooks.json', + message: expect.stringContaining('(hooks hooks/hooks.json): hooks: hooks.Stop.0.hooks.0: Unknown hook type "bogus"'), + }), + expect.objectContaining({ + code: 'AB6020', + generatedPath: 'hooks/hooks.json', + message: expect.stringContaining('hooks.postToolUse: unknown hook event'), + }), + ]); + // The marketplace run's `plugins[0] plugin.json → …` copies of the two manifest warnings are dropped. + expect(report.diagnostics.filter((entry) => entry.message.includes('plugins[0]'))).toEqual([]); + + const strict = await validateClaudePlugin({ pluginDirectory: bundle, run: fixture.run, strict: true, target: 'claude' }); + expect(strict.status).toBe('failed'); + expect(strict.diagnostics.every((entry) => entry.severity === 'error')).toBe(true); +}); + +it('keeps marketplace-only findings and maps JSON errors to AB6021', async () => { + const bundle = await emittedClaudeBundle(); + const marketplace = JSON.stringify({ + contents: [], + manifest: { + errors: [{ code: null, message: 'Duplicate plugin name "fixture" found in marketplace', path: 'plugins' }], + file: join(bundle, '.claude-plugin', 'marketplace.json'), + notes: [{ code: null, message: 'Marketplace has one plugin.', path: null }], + type: 'marketplace', + warnings: [{ code: null, message: 'No marketplace description provided', path: 'description' }], + }, + strict: true, + success: false, + target: join(bundle, '.claude-plugin', 'marketplace.json'), + }); + const fixture = runByTarget({ + marketplace: { exitCode: 1, stdout: marketplace }, + 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.diagnostics).toEqual([ + expect.objectContaining({ + code: 'AB6021', + generatedPath: '.claude-plugin/marketplace.json', + message: 'Claude plugin validation (marketplace .claude-plugin/marketplace.json): plugins: ' + + 'Duplicate plugin name "fixture" found in marketplace', + severity: 'error', + }), + expect.objectContaining({ code: 'AB6020', severity: 'warning', message: expect.stringContaining('No marketplace description') }), + expect.objectContaining({ code: 'AB6020', severity: 'info', message: expect.stringContaining('Marketplace has one plugin.') }), + ]); +}); + +it('reports AB6022 when a 2.1.259 run returns no JSON report (exit 2 writes only to stderr)', async () => { + const bundle = await emittedClaudeBundle(false); + const fixture = runByTarget({ + plugin: { exitCode: 2, stderr: 'Error: EACCES: permission denied', stdout: '' }, + }); + const report = await validateClaudePlugin({ pluginDirectory: bundle, run: fixture.run, target: 'claude' }); + + expect(report).toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB6022', + message: 'Claude host artifact validation did not return a JSON report (exit code 2): Error: EACCES: permission denied.', + severity: 'error', + })], + status: 'failed', + version: '2.1.259', + }); +}); + +it('falls back to text parsing with file attribution on the CI-pinned Claude Code 2.1.250', async () => { + const bundle = await emittedClaudeBundle(); + const text = await recordedReport('2.1.250-plugin-strict-findings.txt', bundle); + const fixture = runByTarget({ + marketplace: { exitCode: 0, stdout: `Validating marketplace manifest: ${join(bundle, '.claude-plugin', 'marketplace.json')}\n\n✔ Validation passed\n` }, + plugin: { exitCode: 1, stdout: text }, + }, '2.1.250'); + const report = await validateClaudePlugin({ pluginDirectory: bundle, run: fixture.run, target: 'claude' }); + + expect(fixture.calls[1]).toEqual(['plugin', 'validate', join(bundle, '.claude-plugin', 'plugin.json'), '--strict']); + expect(fixture.calls[2]).toEqual(['plugin', 'validate', join(bundle, '.claude-plugin', 'marketplace.json'), '--strict']); + expect(report.status).toBe('failed'); + expect(report.diagnostics).toEqual([ + expect.objectContaining({ + code: 'AB6020', + generatedPath: '.claude-plugin/plugin.json', + message: expect.stringContaining("(plugin .claude-plugin/plugin.json): displayNme: Unknown field 'displayNme'"), + }), + expect.objectContaining({ code: 'AB6020', generatedPath: '.claude-plugin/plugin.json' }), + expect.objectContaining({ + code: 'AB6020', + generatedPath: 'agents/bad.md', + message: expect.stringContaining('(agent agents/bad.md): description: No description in frontmatter'), + }), + expect.objectContaining({ + code: 'AB6021', + generatedPath: 'hooks/hooks.json', + message: 'Claude plugin validation (hooks hooks/hooks.json): hooks.Stop.0.hooks.0.type: Invalid input', + severity: 'error', + }), + expect.objectContaining({ + code: 'AB6021', + generatedPath: 'hooks/hooks.json', + message: 'Claude plugin validation (hooks hooks/hooks.json): hooks.postToolUse: Invalid key in record', + }), + ]); +}); diff --git a/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.250-plugin-strict-findings.txt b/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.250-plugin-strict-findings.txt new file mode 100644 index 000000000..08489920a --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.250-plugin-strict-findings.txt @@ -0,0 +1,21 @@ +Validating plugin manifest: /bundle/claude/.claude-plugin/plugin.json + +⚠ Found 2 warnings: + + ❯ displayNme: Unknown field 'displayNme' — did you mean 'displayName'? Claude Code ignores unrecognized fields at load time, so this field has no effect. + ❯ bogus: Unknown field 'bogus'. Claude Code ignores it at load time. + +Validating agent: /bundle/claude/agents/bad.md + +⚠ Found 1 warning: + + ❯ description: No description in frontmatter. A description helps users and Claude understand when to use this agent. + +Validating hooks: /bundle/claude/hooks/hooks.json + +✘ Found 2 errors: + + ❯ hooks.Stop.0.hooks.0.type: Invalid input + ❯ hooks.postToolUse: Invalid key in record + +✘ Validation failed diff --git a/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.259-marketplace-strict-findings.json b/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.259-marketplace-strict-findings.json new file mode 100644 index 000000000..a099043ff --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.259-marketplace-strict-findings.json @@ -0,0 +1,24 @@ +{ + "success": false, + "strict": true, + "target": "/bundle/claude/.claude-plugin/marketplace.json", + "manifest": { + "file": "/bundle/claude/.claude-plugin/marketplace.json", + "type": "marketplace", + "errors": [], + "warnings": [ + { + "path": "plugins[0] plugin.json → displayNme", + "message": "Unknown field 'displayNme' — did you mean 'displayName'? Claude Code ignores unrecognized fields at load time, so this field has no effect.", + "code": null + }, + { + "path": "plugins[0] plugin.json → bogus", + "message": "Unknown field 'bogus'. Claude Code ignores it at load time.", + "code": null + } + ], + "notes": [] + }, + "contents": [] +} diff --git a/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.259-plugin-strict-findings.json b/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.259-plugin-strict-findings.json new file mode 100644 index 000000000..6adffd21b --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.259-plugin-strict-findings.json @@ -0,0 +1,56 @@ +{ + "success": false, + "strict": true, + "target": "/bundle/claude/.claude-plugin/plugin.json", + "manifest": { + "file": "/bundle/claude/.claude-plugin/plugin.json", + "type": "plugin", + "errors": [], + "warnings": [ + { + "path": "displayNme", + "message": "Unknown field 'displayNme' — did you mean 'displayName'? Claude Code ignores unrecognized fields at load time, so this field has no effect.", + "code": null + }, + { + "path": "bogus", + "message": "Unknown field 'bogus'. Claude Code ignores it at load time.", + "code": null + } + ], + "notes": [] + }, + "contents": [ + { + "file": "/bundle/claude/agents/bad.md", + "type": "agent", + "errors": [], + "warnings": [ + { + "path": "description", + "message": "No description in frontmatter. A description helps users and Claude understand when to use this agent.", + "code": null + } + ], + "notes": [] + }, + { + "file": "/bundle/claude/hooks/hooks.json", + "type": "hooks", + "errors": [], + "warnings": [ + { + "path": "hooks", + "message": "hooks.Stop.0.hooks.0: Unknown hook type \"bogus\"; entry ignored at runtime", + "code": null + }, + { + "path": "hooks", + "message": "hooks.postToolUse: unknown hook event; entry ignored at runtime", + "code": null + } + ], + "notes": [] + } + ] +} diff --git a/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.259-plugin-strict-passed.json b/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.259-plugin-strict-passed.json new file mode 100644 index 000000000..725ce46ec --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/claude-plugin-validate/2.1.259-plugin-strict-passed.json @@ -0,0 +1,13 @@ +{ + "success": true, + "strict": true, + "target": "/bundle/claude/.claude-plugin/plugin.json", + "manifest": { + "file": "/bundle/claude/.claude-plugin/plugin.json", + "type": "plugin", + "errors": [], + "warnings": [], + "notes": [] + }, + "contents": [] +} diff --git a/packages/agent-bundle/tests/native-claude-contract.test.ts b/packages/agent-bundle/tests/native-claude-contract.test.ts index 4da8338c6..1db341bdf 100644 --- a/packages/agent-bundle/tests/native-claude-contract.test.ts +++ b/packages/agent-bundle/tests/native-claude-contract.test.ts @@ -193,7 +193,7 @@ it('runs strict validation before the subscription-backed stream command and ret executable: 'claude', }, { - args: ['plugin', 'validate', '--strict', '/candidate/plugin'], + args: ['plugin', 'validate', '--strict', '/candidate/plugin/.claude-plugin/plugin.json'], cwd: '/fresh/fixture', environment: { PATH: '/usr/bin' }, executable: 'claude', @@ -494,7 +494,7 @@ it('requires the signed-in subscription preflight, loaded candidate plugin, and expect(calls).toEqual([ { args: ['--version'], cwd: '/fresh/fixture', environment: { PATH: '/usr/bin' }, executable: 'claude' }, { args: ['auth', 'status', '--json'], cwd: '/fresh/fixture', environment: { PATH: '/usr/bin' }, executable: 'claude' }, - { args: ['plugin', 'validate', '--strict', '/candidate/plugin'], cwd: '/fresh/fixture', environment: { PATH: '/usr/bin' }, executable: 'claude' }, + { args: ['plugin', 'validate', '--strict', '/candidate/plugin/.claude-plugin/plugin.json'], cwd: '/fresh/fixture', environment: { PATH: '/usr/bin' }, executable: 'claude' }, expect.objectContaining({ executable: 'claude' }), ]); }); diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index e1643901e..26e6ffc01 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -35,12 +35,28 @@ 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 -`claude plugin validate --strict` for emitted `claude` and unified `plugin` targets: +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 +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: -- Host **errors** become Agent Bundle errors. -- Host **warnings** stay warnings unless `agent-bundle validate --strict` is set. -- A missing binary is reported as an explicit informational **skip**, never as fabricated success. +```sh +# plugin.json, hooks/hooks.json, skills/, agents/, commands/ +claude plugin validate /.claude-plugin/plugin.json --strict +# marketplace schema, plugin names, source paths +claude plugin validate /.claude-plugin/marketplace.json --strict +``` + +- Host **errors** become Agent Bundle errors (`AB6021`). +- Host **warnings** stay warnings (`AB6020`) unless `agent-bundle validate --strict` is set. +- Each finding names the file Claude Code checked, for example + `(hooks hooks/hooks.json): hooks: hooks.postToolUse: unknown hook event`. On Claude Code 2.1.259 + or later Agent Bundle reads the CLI's `--json` report; older releases are parsed from the text + 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`. CI should use strict validation: @@ -51,7 +67,15 @@ npx agent-bundle validate --artifact artifact --strict Use `--no-host-validation` when a deterministic schema-only check is required — a machine without the hosts installed must still be able to run the same gate and get the same schema verdict. -During development you can load a built target without installing it and verify registration: +During development, load the built target for one session without installing it, then iterate: + +```sh +claude --plugin-dir artifact/claude +``` + +Run `/reload-plugins` inside that session after `agent-bundle build` rewrites `hooks/`, +`.mcp.json`, or `agents/`; `SKILL.md` edits take effect without it. A `--plugin-dir` plugin is not +visible to a bare `claude plugin list`; put the flag before the subcommand to inspect it: ```sh claude --plugin-dir artifact/claude plugin list --json diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 79d4bef0e..80b3601db 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -31,11 +31,25 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, ## Claude Code 严格校验 当 Claude Code 位于 `PATH` 上时,产物校验会为输出的 `claude` target 以及统一的 `plugin` target 运行 -`claude plugin validate --strict`: +已安装的 `claude plugin validate`。Claude Code 根据被指向的清单决定检查什么:一个同时包含 +`.claude-plugin/plugin.json` 与 `.claude-plugin/marketplace.json` 的 bundle 目录会被当作 marketplace, +而 marketplace 校验从不打开插件的 hook、skill、agent 或 command 文件。因此 Agent Bundle 运行两条命令: -- 宿主的 **error** 会变成 Agent Bundle 的 error。 -- 宿主的 **warning** 保持为 warning,除非设置了 `agent-bundle validate --strict`。 -- 二进制文件缺失会被报告为一次显式的信息性**跳过**,绝不会伪造成功。 +```sh +# plugin.json、hooks/hooks.json、skills/、agents/、commands/ +claude plugin validate /.claude-plugin/plugin.json --strict +# marketplace schema、插件名、source 路径 +claude plugin validate /.claude-plugin/marketplace.json --strict +``` + +- 宿主的 **error** 会变成 Agent Bundle 的 error(`AB6021`)。 +- 宿主的 **warning** 保持为 warning(`AB6020`),除非设置了 `agent-bundle validate --strict`。 +- 每条发现都会点名 Claude Code 检查的文件,例如 + `(hooks hooks/hooks.json): hooks: hooks.postToolUse: unknown hook event`。在 Claude Code 2.1.259 + 及更高版本上,Agent Bundle 读取 CLI 的 `--json` 报告;更早的版本则解析文本报告。marketplace 那次 + 运行中对插件运行已报告过的清单发现的 `plugins[0] plugin.json →` 副本会被丢弃。 +- 二进制文件缺失会被报告为一次显式的信息性**跳过**(`AB6019`),绝不会伪造成功;探测或运行无法完成 + 时为 `AB6022`。 CI 应当使用严格校验: @@ -46,7 +60,15 @@ npx agent-bundle validate --artifact artifact --strict 当需要确定性的、只看 schema 的检查时使用 `--no-host-validation` ——没有安装这些宿主的机器也必须能运行 同一道门禁,并得到同样的 schema 结论。 -在开发期,你可以在不安装的情况下加载一个已构建 target 并核实注册情况: +在开发期,先在不安装的情况下为单个会话加载已构建 target,然后迭代: + +```sh +claude --plugin-dir artifact/claude +``` + +在 `agent-bundle build` 重写了 `hooks/`、`.mcp.json` 或 `agents/` 之后,在该会话中运行 +`/reload-plugins`;`SKILL.md` 的修改无需它即可生效。`--plugin-dir` 插件对不带参数的 +`claude plugin list` 不可见;把该标志放在子命令之前即可查看: ```sh claude --plugin-dir artifact/claude plugin list --json