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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/claude-validation-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Resolve Claude plugin directories before invoking the host validator, and fail validation when the Claude CLI version probe cannot complete successfully.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { dirname } from 'node:path';
import { dirname, resolve } from 'node:path';

import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts';
import { freezeDiagnostics } from '../core/diagnostics.ts';
Expand Down Expand Up @@ -61,7 +61,9 @@ const diagnostic = (
message,
recovery: code === 'AB6019'
? 'Install Claude Code and ensure `claude` is on PATH, then rerun artifact validation.'
: 'Run `claude plugin validate <bundle-dir> --strict`, repair the reported Claude artifact, and rebuild.',
: code === 'AB6022'
? 'Verify the Claude CLI starts and responds, then rerun `claude plugin validate <bundle-dir> --strict`.'
: 'Run `claude plugin validate <bundle-dir> --strict`, repair the reported Claude artifact, and rebuild.',
severity,
target,
});
Expand All @@ -88,33 +90,49 @@ const issueLines = (output: string): readonly { readonly message: string; readon
export const validateClaudePlugin = async (
options: ValidateClaudePluginOptions,
): Promise<ClaudePluginValidationReport> => {
const pluginDirectory = resolve(options.pluginDirectory);
const executable = options.executable ?? 'claude';
const run = options.run ?? runClaudeCommand;
const cwd = dirname(options.pluginDirectory);
const cwd = dirname(pluginDirectory);
let version: string | undefined;
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(
'AB6019',
'The Claude CLI is unavailable for host artifact validation.',
'info',
'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: 'unavailable',
status: 'failed',
target: options.target,
});
}
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 Object.freeze({
diagnostics: freezeDiagnostics([diagnostic(
'AB6019',
isErrno(error, 'ENOENT')
? 'The Claude CLI is not installed or is not on PATH; host artifact validation was skipped.'
: 'The Claude CLI could not be started; host artifact validation was skipped.',
'The Claude CLI is not installed or is not on PATH; host artifact validation was skipped.',
'info',
options.target,
)]),
Expand All @@ -127,7 +145,7 @@ export const validateClaudePlugin = async (
let result: ClaudePluginCommandResult;
try {
result = await run(Object.freeze({
args: Object.freeze(['plugin', 'validate', options.pluginDirectory, '--strict']),
args: Object.freeze(['plugin', 'validate', pluginDirectory, '--strict']),
cwd,
executable,
}));
Expand Down
106 changes: 106 additions & 0 deletions packages/agent-bundle/tests/claude-plugin-validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { dirname, resolve } from 'node:path';

import { expect, it } from '@rstest/core';

import {
Expand Down Expand Up @@ -44,6 +46,24 @@ it('runs the installed Claude validator without shell interpolation', async () =
});
});

it('resolves a multi-segment relative plugin directory before invoking Claude', async () => {
const fixture = runWith({ exitCode: 0, stdout: '✔ Validation passed\n' });
const pluginDirectory = resolve('fixtures/plugin');
await validateClaudePlugin({
pluginDirectory: 'fixtures/plugin',
run: fixture.run,
target: 'claude',
});

expect(fixture.calls).toEqual([
expect.objectContaining({ args: ['--version'], cwd: dirname(pluginDirectory) }),
expect.objectContaining({
args: ['plugin', 'validate', pluginDirectory, '--strict'],
cwd: dirname(pluginDirectory),
}),
]);
});

it('keeps host warnings as warnings unless framework strict mode is enabled', async () => {
const output = [
'⚠ Found 2 warnings:',
Expand Down Expand Up @@ -122,3 +142,89 @@ it('reports an honest informational skip when Claude is absent', async () => {
target: 'claude',
});
});

it('fails host validation when the Claude version probe times out', async () => {
const report = await validateClaudePlugin({
pluginDirectory: '/tmp/plugin',
run: async () => ({
exitCode: null,
signal: 'SIGTERM',
stderr: '',
stdout: '',
termination: 'timed-out',
}),
target: 'claude',
});

expect(report).toMatchObject({
diagnostics: [expect.objectContaining({
code: 'AB6022',
message: expect.stringContaining('version probe timed out'),
severity: 'error',
})],
status: 'failed',
});
});

it('fails host validation when the Claude version probe exits nonzero', async () => {
const report = await validateClaudePlugin({
pluginDirectory: '/tmp/plugin',
run: async () => ({
exitCode: 2,
signal: null,
stderr: 'version failed',
stdout: '',
}),
target: 'claude',
});

expect(report).toMatchObject({
diagnostics: [expect.objectContaining({
code: 'AB6022',
message: expect.stringContaining('version probe exited with code 2'),
severity: 'error',
})],
status: 'failed',
});
});

it('fails host validation when the Claude version probe exceeds its output limit', async () => {
const report = await validateClaudePlugin({
pluginDirectory: '/tmp/plugin',
run: async () => ({
exitCode: null,
signal: 'SIGTERM',
stderr: '',
stdout: '',
termination: 'output-limit',
}),
target: 'claude',
});

expect(report).toMatchObject({
diagnostics: [expect.objectContaining({
code: 'AB6022',
message: expect.stringContaining('version probe exceeded its output limit'),
severity: 'error',
})],
status: 'failed',
});
});

it('fails host validation when the Claude version probe cannot be spawned', async () => {
const denied = Object.assign(new Error('spawn claude EACCES'), { code: 'EACCES' });
const report = await validateClaudePlugin({
pluginDirectory: '/tmp/plugin',
run: async () => { throw denied; },
target: 'claude',
});

expect(report).toMatchObject({
diagnostics: [expect.objectContaining({
code: 'AB6022',
message: expect.stringContaining('version probe could not be started'),
severity: 'error',
})],
status: 'failed',
});
});
Loading