Skip to content

feat(plugin): implement read-only local plugin validation - #599

Merged
christso merged 1 commit into
mainfrom
feat/plugin-validate
Oct 28, 2026
Merged

christso merged 1 commit into
mainfrom
feat/plugin-validate

Conversation

@christso

@christso christso commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

allagents plugin validate <path> now checks local plugin content and exits 1 when validation fails. The previous stub returned success for missing paths and invalid plugins.

Validation supports conventional file plugins, nested and root skills, and Pi package layouts. It reads metadata without fetching sources, installing dependencies, running plugin scripts, invoking clients, or writing an update-check cache. JSON retains one plugin validate envelope with data.path, data.valid, and data.message; nested diagnostics preserve --json=path,valid.

Validation boundaries

  • Skills use the existing discovery precedence and metadata validator.
  • Plugin manifests require JSON objects. Marketplace manifests retain the existing parser's precedence and recoverable warnings.
  • MCP declarations accept JSON5 objects, including comments and trailing commas. Hook declarations require JSON objects. Client-specific fields, connections, and script behavior are outside validation scope.
  • Pi recognition requires explicit pi metadata or conventional resources in an otherwise unsupported layout. Recognized skills and file plugins preserve unmarked sidecar packages as supporting content.
  • Linked resource files are recognized without recursing through directory links inside resource directories.

Verification

All results below are from final commit ce173bd7885cbea6b9e5deeb5f8a61435b2b6a37:

  • bun test tests/e2e/plugin-validate.test.ts tests/unit/validators/skill.test.ts tests/unit/core/skills.test.ts tests/unit/utils/marketplace-manifest-parser.test.ts tests/unit/cli/structured-help.test.ts tests/e2e/cli-enriched-help.test.ts: 210 passed, 0 failed, 489 assertions across six files. This includes 50 plugin-validation cases, whose setup builds the CLI and invokes node dist/index.js.
  • bun run typecheck: passed.
  • bun run lint: exit 0; 17 existing warnings in unchanged src/utils/workspace-source-editor.ts.
  • bunx biome lint src/validators/plugin.ts src/cli/commands/plugin.ts src/cli/metadata/plugin.ts: passed without warnings.
  • bun run docs:build: passed; 13 pages built. Astro reported an empty i18n collection warning. Generated tracked .astro outputs were restored; no generated changes are included.
  • bun run build: passed; self-contained production bundle, 1,559,203 bytes, 60 third-party package instances.
  • git diff --check: passed; worktree clean.
  • gh pr checks 599 --repo allagentsdev/allagents: all eight checks passed on this commit (Build, Docs, Lint, Typecheck, Test, Node 24 smoke, Windows command argv, Cloudflare Pages). CI run. Checked once after marking ready; no merge or release.
  • bun /tmp/allagents-plugin-validate-e2e.ts: 28 built-Node manual cases passed with isolated HOME/project fixtures. Each invocation checked stdout, stderr, exit status, JSON envelope/field selection where applicable, and byte/tree snapshots covering the plugin, project, HOME, and symlinks. All snapshots stayed unchanged and no scripts ran. Fixtures were removed afterward; the complete reproduction script is below.

The manual matrix covers a copy of bundled plugins/allagents, root and nested skills, an installed-skill symlink, malformed skills/manifests/MCP/hooks, JSON5 comments/trailing commas, warnings, missing/empty/unsupported paths, an explicitly marked Pi extension package, manifestless extensions/prompts/themes, an ordinary Node package, a root skill with unnamed sidecar package metadata and supporting prompts, linked commands, directory-link loops, and JSON field selection.

Before implementation, the built command was also reproduced with plugin validate ./missing in isolated HOME/project fixtures: human and JSON modes both incorrectly exited 0. The earlier 188-test/16-case evidence is superseded by the final results above.

Complete-suite integration verification remains with the parent orchestrator; this worktree intentionally did not repeat the full suite. Validation establishes metadata/resource structure only; it does not establish client runtime compatibility or execute resources.

Manual E2E reproduction

From the repository root, run bun run build, save this script as /tmp/allagents-plugin-validate-e2e.ts, then run bun /tmp/allagents-plugin-validate-e2e.ts.

import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { strict as assert } from 'node:assert';
const repo = process.cwd();
const root = mkdtempSync('/tmp/allagents-validate-final-');
const home = join(root, 'home');
const project = join(root, 'project');
mkdirSync(home); mkdirSync(project);
const file = (path: string, content: string) => { const target = join(project, path); mkdirSync(dirname(target), {recursive: true}); writeFileSync(target, content); };
const skill = '---\nname: Fixture Skill\ndescription: Built CLI validation fixture\n---\n# Instructions\n';
cpSync(join(repo, 'plugins/allagents'), join(project, 'first-party'), {recursive: true});
file('root-skill/SKILL.md', skill);
file('root-skill/scripts/prepare.sh', 'touch executed\nexit 99\n');
file('root-skill/package.json', JSON.stringify({scripts: {prepare: 'touch executed'}}));
file('root-skill/prompts/review.md', '# Supporting prompt\n');
file('nested/skills/group/example/SKILL.md', skill);
file('collection/group/example/SKILL.md', skill);
file('invalid/skills/group/broken/SKILL.md', '---\nname: missing-description\n---\n');
file('bad-manifest/.claude-plugin/plugin.json', '{bad json');
file('warning/.claude-plugin/marketplace.json', JSON.stringify({plugins: [{name: 'fixture', source: './'}]}));
file('unsupported/README.md', '# No plugin resources\n');
mkdirSync(join(project, 'empty'));
symlinkSync(join(project, 'root-skill'), join(project, 'installed-skill'));
file('bad-mcp/.mcp.json', '{broken');
file('bad-hooks/hooks.json', '{broken');
file('bad-nested-hooks/hooks/hooks.json', '{broken');
file('good-mcp/.mcp.json', '{ // supported comments\n mcpServers: { example: { command: "must-not-run", }, },\n}');
file('pi-package/package.json', '{"name":"example","pi":{"extensions":["./index.ts"]}}');
file('pi-package/index.ts', 'throw new Error("must not execute");\n');
for (const resource of ['extensions', 'prompts', 'themes']) file(`pi-${resource}/${resource}/example.txt`, 'Native resource\n');
file('ordinary-node/package.json', '{"name":"ordinary-node","main":"./index.js"}');
file('ordinary-node/index.js', 'throw new Error("must not execute");\n');
file('external/review.md', '# Review instructions\n');
mkdirSync(join(project, 'linked-command/commands'), {recursive: true});
symlinkSync(join(project, 'external/review.md'), join(project, 'linked-command/commands/review.md'));
mkdirSync(join(project, 'linked-directory/commands'), {recursive: true});
symlinkSync(join(project, 'external'), join(project, 'linked-directory/commands/directory'));
symlinkSync(join(project, 'linked-directory/commands'), join(project, 'linked-directory/commands/loop'));
function snapshot(directory: string): unknown {
 return readdirSync(directory, {withFileTypes: true}).sort((a,b) => a.name.localeCompare(b.name)).map(entry => [entry.name, entry.isSymbolicLink() ? ['symlink',readlinkSync(join(directory,entry.name))] : entry.isDirectory() ? snapshot(join(directory,entry.name)) : readFileSync(join(directory,entry.name)).toString('base64')]);
}
const before = snapshot(root);
const cases: [string, string, number][] = [
 ['./first-party','',0], ['./first-party','--json',0], ['./root-skill','',0], ['./installed-skill','--json',0],
 ['./nested','--json',0], ['./collection','--json',0], ['./invalid','',1], ['./invalid','--json',1],
 ['./bad-manifest','--json',1], ['./warning','',0], ['./missing','',1], ['./missing','--json',1],
 ['./empty','--json',1], ['./unsupported','--json',1], ['./root-skill','--json=path,valid',0], ['./missing','--json=path,valid',1],
 ['./bad-mcp','',1], ['./bad-mcp','--json',1], ['./bad-hooks','--json',1], ['./bad-nested-hooks','--json',1],
 ['./good-mcp','--json',0], ['./pi-package','--json',0], ['./pi-extensions','--json',0], ['./pi-prompts','--json',0],
 ['./pi-themes','--json',0], ['./ordinary-node','--json',1], ['./linked-command','--json',0], ['./linked-directory','--json',1],
];
try {
 for (const [path, flag, expected] of cases) {
  const args = ['node', join(repo,'dist/index.js'),'plugin','validate',path,...(flag ? [flag] : [])];
  const proc = Bun.spawnSync(args, {cwd: project, env:{...process.env, HOME:home, USERPROFILE:home, ALLAGENTS_TEST_HOME:home, XDG_CACHE_HOME:join(home,'.cache'), XDG_CONFIG_HOME:join(home,'.config'), BUN_RUNTIME_TRANSPILER_CACHE_PATH:'0'}, stdin:'ignore',stdout:'pipe',stderr:'pipe'});
  const stdout = proc.stdout.toString(); const stderr = proc.stderr.toString();
  console.log(JSON.stringify({command: ['node','<repo>/dist/index.js','plugin','validate',path,...(flag ? [flag] : [])], exit:proc.exitCode,stdout,stderr}));
  assert.equal(proc.exitCode,expected); assert.equal(stderr,'');
  if (flag) {
   const json = JSON.parse(stdout); assert.equal(json.success,expected===0); assert.equal(json.command,'plugin validate'); assert.equal(json.data.path,path); assert.equal(json.data.valid,expected===0);
   if (flag.includes('=')) assert.deepEqual(Object.keys(json.data),['path','valid']);
  }
  assert.deepEqual(snapshot(root),before);
 }
 console.log(`PASS: ${cases.length} built CLI cases; plugin, project, isolated HOME and symlink bytes unchanged; no scripts executed.`);
} finally {rmSync(root,{recursive:true,force:true});}

Combined batch verification

The parent integrated #598, #599 and #600 on clean baseline e331f44 (integration tree 54a8c5338bfa1caf0a187bae4c8cc94895338352) without conflicts. The integration tree is equivalent to applying these three PRs in that order.

  • bun test: 2,402 passed, 7 skipped, 0 failed, 9,742 assertions across 200 files (171.53s).
  • bun run build, bun run typecheck, bun run lint, bun run schema:check, bun run docs:build, bun run package:check and bun run package:smoke: passed. Combined lint has zero warnings. Package smoke checked 54 help nodes.
  • Manual built-Node CLI checks used isolated HOME/project/plugin fixtures. Valid and invalid validation preserved bytes and mtimes; root skills with supporting prompts and unnamed sidecar packages validated; repeated updates copied resources without executing scripts; user-owned sentinels survived update/removal; an MCP scalar edit preserved BOM, CRLF, comments and flow formatting; uninstall removed owned skills. Temporary fixtures were removed.
  • A bounded combined-build check used python3 /tmp/allagents-remaining-integration-manual.py (script reproduced in perf(sync): reduce repeated parent cleanup during updates #600) with five alternating baseline/candidate pairs after one materialization run per build. Its guards checked output/source/config/sentinel/state and client destination compatibility. Local-project median was 1,352.27ms → 1,183.30ms (12.49% lower). This is a corroborating warm-cache measurement; perf(sync): reduce repeated parent cleanup during updates #600's larger paired matrix is the primary performance evidence.
  • The first full local run hit four timeouts in existing skill-install/update tests. Both affected files then passed unchanged (27 tests), followed by the clean full-suite result above. No timeout limits or production code were changed between those runs, and the initial cause is not established.
  • All seven GitHub Actions CI jobs also passed separately on each PR's exact head, including Windows command argv and Node 24 smoke. Independent review findings are resolved.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying allagents with  Cloudflare Pages  Cloudflare Pages

Latest commit: ce173bd
Status: ✅  Deploy successful!
Preview URL: https://64e2d4ae.allagents.pages.dev
Branch Preview URL: https://feat-plugin-validate.allagents.pages.dev

View logs

@christso
christso marked this pull request as ready for review October 28, 2026 13:47
@christso
christso merged commit 80fec49 into main Oct 28, 2026
8 checks passed
@christso
christso deleted the feat/plugin-validate branch October 28, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant