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
50 changes: 50 additions & 0 deletions docs/src/content/docs/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,56 @@ With `--json`, each entry includes the friendly `name`, raw configured `spec`, `
}
```

### plugin validate

Check a local source or installed plugin directory without changing files,
checking for updates, installing dependencies, or running plugin scripts:

```bash
allagents plugin validate ./my-plugin
allagents plugin validate ./my-plugin --json
allagents plugin validate ./my-plugin --json=path,valid
```

Supports conventional plugin resources (`skills/`, `commands/`, `agents/`,
`hooks/`, `.github/` content, `.mcp.json`, `hooks.json`, and root instruction
files), nested skill collections, and [single-skill repositories](/docs/guides/plugins/#single-skill-repositories).
Manifests are optional. Skill discovery uses the same precedence as installation:
`skills/` first, otherwise nested skills, otherwise root `SKILL.md`.
Links to resource files, such as `commands/review.md`, are supported; scanning
does not recurse through directory links inside resource directories.

Pi packages are recognized by a `package.json` with a `pi` object and a nonempty
package `name`, or by conventional `extensions/`, `prompts/`, or `themes/`
directories without a manifest. When those directories establish an otherwise
unsupported layout, any package manifest must be a JSON object with a nonempty
`name`. Recognized skills and file plugins retain unmarked sidecar packages as
supporting content. An ordinary Node package with only
`package.json` and source files is not considered a plugin. Extensions and other
native resources are recognized without loading them or invoking Pi.

Recognized skills must have valid YAML frontmatter with a nonempty `name` and
`description`, using the existing skill metadata rules. Plugin manifests at
`.claude-plugin/plugin.json`, `.github/plugin/plugin.json`, and
`.codex-plugin/plugin.json` must parse as JSON objects; client-specific manifest
fields are not checked. Marketplace manifests use the installation parser,
preferring `.github/plugin/marketplace.json` over
`.claude-plugin/marketplace.json`. Its recoverable metadata issues remain
warnings. `.mcp.json` must be a readable JSON5 object (comments and trailing
commas are supported). `hooks.json`, `hooks/hooks.json`, and `.codex/hooks.json`
must be readable JSON objects. Invalid syntax or a non-object container is an
error; client-specific declaration fields are not checked. Hook commands, MCP
connections, supporting scripts, and Markdown body references are not executed
or semantically checked.

Exit status is `0` for valid input (including warnings) and `1` for errors such
as missing paths, empty or unsupported layouts, malformed manifests or
declarations, or invalid recognized skills. Diagnostics identify locations relative to the plugin root;
`.` means the root itself. JSON emits one envelope with `command: "plugin validate"`,
`success`, and `data.path`, `data.valid`, `data.message`, plus
`data.validation.skills` and `data.validation.diagnostics`. Each diagnostic has
`severity`, `path`, and `message`.

### plugin install

Install a plugin, then sync the selected scope.
Expand Down
17 changes: 11 additions & 6 deletions src/cli/commands/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
} from '../../core/plugin.js';
import { UpdateContext } from '../../core/update-context.js';
import { getAllSkillsFromPlugins } from '../../core/skills.js';
import { validateLocalPlugin } from '../../validators/plugin.js';
import {
getWorkspaceStatus,
type NativePluginStatus,
Expand Down Expand Up @@ -1168,17 +1169,21 @@ const pluginValidateCmd = command({
path: positional({ type: string, displayName: 'path' }),
},
handler: async ({ path }) => {
const result = await validateLocalPlugin(path);
if (isJsonMode()) {
jsonOutput({
success: true,
success: result.valid,
command: 'plugin validate',
data: { path, valid: false, message: 'not yet implemented' },
data: result,
});
return;
} else {
console.log(`${result.valid ? 'Valid plugin' : 'Validation failed'} at: ${terminalSafe(path)}`);
console.log(`Checked ${result.validation.skills} skill(s).`);
for (const diagnostic of result.validation.diagnostics) {
console.log(`${diagnostic.severity === 'error' ? 'Error' : 'Warning'}: ${terminalSafe(diagnostic.path)}: ${terminalSafe(diagnostic.message)}`);
}
}
// TODO: Implement plugin validation
console.log(`Validating plugin at: ${path}`);
console.log('(validation not yet implemented)');
if (!result.valid) process.exitCode = 1;
},
});

Expand Down
7 changes: 6 additions & 1 deletion src/cli/metadata/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ export const pluginListMeta: AgentCommandMeta = {

export const pluginValidateMeta: AgentCommandMeta = {
command: 'plugin validate',
description: 'Validate plugin structure at the given path',
description: 'Validate local plugin structure and skill metadata without modifying files',
skipUpdateCheck: true,
whenToUse: 'When developing a plugin, to check that its structure conforms to the expected format',
examples: [
'allagents plugin validate ./my-plugin',
Expand All @@ -156,6 +157,10 @@ export const pluginValidateMeta: AgentCommandMeta = {
path: 'string',
valid: 'boolean',
message: 'string',
validation: {
skills: 'number',
diagnostics: [{ severity: 'error | warning', path: 'string', message: 'string' }],
},
},
};

Expand Down
226 changes: 226 additions & 0 deletions src/validators/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { lstat, readFile, readdir, stat } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import JSON5 from 'json5';
import { discoverSkillEntriesFromPluginRoot } from '../core/skills.js';
import { parseMarketplaceManifest } from '../utils/marketplace-manifest-parser.js';
import { validateSkill } from './skill.js';

interface PluginDiagnostic {
severity: 'error' | 'warning';
/** Location relative to the plugin root; "." refers to the root itself. */
path: string;
message: string;
}

export interface PluginValidationResult {
path: string;
valid: boolean;
message: string;
// Keep arrays nested so --json=path,valid projects the top-level fields.
validation: { skills: number; diagnostics: PluginDiagnostic[] };
}

/** Inspect local content only. Never resolve sources or invoke plugin code. */
export async function validateLocalPlugin(
path: string,
): Promise<PluginValidationResult> {
const root = resolve(path);
const diagnostics: PluginDiagnostic[] = [];
let skills = 0;
let recognized = false;

function diagnostic(
location: string,
message: string,
severity: PluginDiagnostic['severity'] = 'error',
) {
diagnostics.push({
severity,
path: location,
message: message.replaceAll(root, '.'),
});
}

async function inspect(location: string) {
let linked = false;
try {
const entry = await lstat(join(root, location));
if (!entry.isSymbolicLink()) return entry;
linked = true;
return await stat(join(root, location));
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (linked || code !== 'ENOENT')
diagnostic(
location,
`Cannot inspect path (${code ?? 'unknown error'})`,
);
return undefined;
}
}

async function readObject(location: string, label: string, format: 'JSON' | 'JSON5' = 'JSON') {
try {
const content = await readFile(join(root, location), 'utf8');
const value: unknown = format === 'JSON5' ? JSON5.parse(content) : JSON.parse(content.replace(/^\uFEFF/, ''));
if (!value || typeof value !== 'object' || Array.isArray(value)) {
diagnostic(location, `${label} must be a ${format} object`);
return undefined;
}
return value as Record<string, unknown>;
} catch {
diagnostic(location, `Cannot read ${label.toLowerCase()} or parse it as ${format}`);
return undefined;
}
}

async function hasResourceFiles(directory: string): Promise<boolean> {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = join(directory, entry.name);
if (entry.isFile() || (entry.isSymbolicLink() && (await stat(target)).isFile())) return true;
// Directory links are deliberately not traversed, just as in skill discovery.
if (entry.isDirectory() && await hasResourceFiles(target)) return true;
}
return false;
}

function result(): PluginValidationResult {
const valid = !diagnostics.some((entry) => entry.severity === 'error');
return {
path,
valid,
message: valid ? 'Plugin is valid' : 'Plugin validation failed',
validation: { skills, diagnostics },
};
}

const rootStat = await inspect('.');
if (!rootStat) {
if (diagnostics.length === 0) diagnostic('.', 'Plugin path does not exist');
return result();
}
if (!rootStat.isDirectory()) {
diagnostic('.', 'Plugin path must be a directory');
return result();
}

for (const location of [
'.claude-plugin/plugin.json',
'.github/plugin/plugin.json',
'.codex-plugin/plugin.json',
]) {
if (!(await inspect(location))) continue;
recognized = true;
await readObject(location, 'Plugin manifest');
}

// These declarations have different accepted syntaxes but share an object container.
for (const [location, label, format] of [
['.mcp.json', 'MCP declaration', 'JSON5'],
['hooks.json', 'Hook declaration', 'JSON'],
['hooks/hooks.json', 'Hook declaration', 'JSON'],
['.codex/hooks.json', 'Hook declaration', 'JSON'],
] as const) {
if (!(await inspect(location))) continue;
recognized = true;
await readObject(location, label, format);
}

// Match the precedence and lenient semantics used during installation.
for (const location of [
'.github/plugin/marketplace.json',
'.claude-plugin/marketplace.json',
]) {
if (!(await inspect(location))) continue;
recognized = true;
const manifest = await parseMarketplaceManifest(root);
if (!manifest.success) diagnostic(location, manifest.error);
else
for (const warning of manifest.warnings)
diagnostic(location, warning, 'warning');
break;
}

try {
const warnings: string[] = [];
const entries = await discoverSkillEntriesFromPluginRoot(root, warnings);
for (const warning of warnings) diagnostic('.', warning);
skills = entries.length;
recognized ||= skills > 0;
for (const entry of entries.sort((a, b) =>
a.skillPath.localeCompare(b.skillPath),
)) {
const skill = await validateSkill(entry.skillPath);
if (!skill.valid)
diagnostic(
relative(root, skill.path).replaceAll('\\', '/'),
skill.error ?? 'Invalid skill',
);
}
} catch (error) {
diagnostic(
'.',
`Cannot discover skills (${(error as NodeJS.ErrnoException).code ?? 'read error'})`,
);
}

for (const location of ['commands', 'agents', 'hooks', '.github']) {
const entry = await inspect(location);
if (!entry) continue;
if (!entry.isDirectory()) {
diagnostic(location, 'Resource path must be a directory');
continue;
}
try {
const hasFiles = await hasResourceFiles(join(root, location));
recognized ||= hasFiles;
} catch (error) {
diagnostic(
location,
`Cannot read resource directory (${(error as NodeJS.ErrnoException).code ?? 'read error'})`,
);
}
}
for (const location of [
'AGENTS.md',
'CLAUDE.md',
]) {
const entry = await inspect(location);
if (!entry) continue;
if (entry.isFile()) recognized = true;
else diagnostic(location, 'Resource path must be a file');
}

// Infer Pi only after recognizing file plugins: their supporting directories and
// unmarked sidecar package.json files must not become Pi declarations.
let piResources = false;
for (const location of ['extensions', 'prompts', 'themes']) {
const entry = await inspect(location);
if (entry?.isDirectory()) piResources = true;
}
const piLayout = piResources && !recognized;
recognized ||= piResources;
if (await inspect('package.json')) {
let manifest: unknown;
try {
manifest = JSON.parse((await readFile(join(root, 'package.json'), 'utf8')).replace(/^\uFEFF/, ''));
} catch {
if (piLayout) diagnostic('package.json', 'Cannot read Pi package manifest or parse it as JSON');
}
if (manifest && typeof manifest === 'object' && !Array.isArray(manifest)) {
const value = manifest as Record<string, unknown>;
if (piLayout || (value.pi !== null && typeof value.pi === 'object' && !Array.isArray(value.pi))) {
recognized = true;
if (typeof value.name !== 'string' || !value.name) diagnostic('package.json', 'Pi package manifest must have a nonempty name');
}
} else if (manifest !== undefined && piLayout) diagnostic('package.json', 'Pi package manifest must be a JSON object');
}

if (!recognized && diagnostics.length === 0) {
diagnostic(
'.',
'No supported plugin resources found (expected skills, commands, agents, hooks, .github content, .mcp.json, instructions, a Pi package, or a plugin/marketplace manifest)',
);
}
return result();
}
Loading
Loading