diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 7f6e9a7f..2a8023fd 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -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. diff --git a/src/cli/commands/plugin.ts b/src/cli/commands/plugin.ts index b8665d92..f832b436 100644 --- a/src/cli/commands/plugin.ts +++ b/src/cli/commands/plugin.ts @@ -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, @@ -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; }, }); diff --git a/src/cli/metadata/plugin.ts b/src/cli/metadata/plugin.ts index c7994ad4..e4076e05 100644 --- a/src/cli/metadata/plugin.ts +++ b/src/cli/metadata/plugin.ts @@ -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', @@ -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' }], + }, }, }; diff --git a/src/validators/plugin.ts b/src/validators/plugin.ts new file mode 100644 index 00000000..e8995846 --- /dev/null +++ b/src/validators/plugin.ts @@ -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 { + 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; + } catch { + diagnostic(location, `Cannot read ${label.toLowerCase()} or parse it as ${format}`); + return undefined; + } + } + + async function hasResourceFiles(directory: string): Promise { + 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; + 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(); +} diff --git a/tests/e2e/plugin-validate.test.ts b/tests/e2e/plugin-validate.test.ts new file mode 100644 index 00000000..cd23a7bd --- /dev/null +++ b/tests/e2e/plugin-validate.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const repoRoot = join(import.meta.dir, '../..'); +const cli = join(repoRoot, 'dist/index.js'); +const skill = '---\nname: Display Name_with spaces\ndescription: A supported skill\n---\n# Instructions\n'; + +describe('plugin validate', () => { + let root: string; + let project: string; + let home: string; + + beforeAll(() => { + const build = Bun.spawnSync(['bun', 'run', 'build'], { + cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', + }); + if (build.exitCode !== 0) throw new Error(`CLI build failed: ${build.stderr.toString()}`); + }); + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'allagents-plugin-validate-')); + project = join(root, 'project'); + home = join(root, 'home'); + mkdirSync(project); + mkdirSync(home); + }); + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + function file(path: string, contents: string) { + const target = join(project, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + } + + function run(path: string, flag = '--json') { + const proc = Bun.spawnSync(['node', cli, 'plugin', 'validate', path, ...(flag ? [flag] : [])], { + cwd: project, + env: { ...process.env, HOME: home, ALLAGENTS_TEST_HOME: home, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', + }); + return { status: proc.exitCode, stdout: proc.stdout.toString(), stderr: proc.stderr.toString() }; + } + + test.each(['skills/group/example', 'collection/group/example', '.'])('accepts supported skill layout %s', (directory) => { + file(`plugin/${directory}/SKILL.md`, skill); + file(`plugin/${directory}/scripts/unused.sh`, 'exit 99\n'); + const result = run('./plugin'); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ + success: true, command: 'plugin validate', data: { path: './plugin', valid: true }, + }); + }); + + test.each(['commands/example.md', 'agents/example.md', 'hooks/example.sh', '.github/instructions/example.md', '.mcp.json'])('accepts conventional non-skill resource %s without requiring a manifest', (resource) => { + file(`plugin/${resource}`, resource.endsWith('.json') ? '{}' : 'Plugin resource\n'); + expect(run('./plugin').status).toBe(0); + }); + + test.each(['.mcp.json', 'hooks.json', 'hooks/hooks.json', '.codex/hooks.json'])('rejects malformed declaration %s', (declaration) => { + file(`plugin/${declaration}`, '{broken'); + for (const flag of ['--json', '']) { + const result = run('./plugin', flag); + expect(result.status).toBe(1); + expect(result.stderr).toBe(''); + if (flag) expect(JSON.parse(result.stdout).data.validation.diagnostics).toContainEqual(expect.objectContaining({ severity: 'error', path: declaration })); + else expect(result.stdout).toContain(`Error: ${declaration}:`); + } + }); + + test.each(['.mcp.json', 'hooks.json', 'hooks/hooks.json', '.codex/hooks.json'])('requires an object container in declaration %s', (declaration) => { + file(`plugin/${declaration}`, '[]'); + expect(run('./plugin').status).toBe(1); + }); + + test('accepts comments and trailing commas in MCP JSON5 declarations', () => { + file('plugin/.mcp.json', '{\n// User comments are supported\nmcpServers: { example: { command: "not-executed", }, },\n}'); + const result = run('./plugin'); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).data.valid).toBe(true); + }); + + test('rejects an unreadable linked declaration even when other plugin resources are valid', () => { + file('plugin/SKILL.md', skill); + symlinkSync(join(project, 'missing.json'), join(project, 'plugin/.mcp.json')); + const result = run('./plugin'); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).data.validation.diagnostics).toContainEqual(expect.objectContaining({ severity: 'error', path: '.mcp.json' })); + }); + + test.each(['hooks.json', 'hooks/hooks.json'])('requires strict JSON in hook declaration %s', (declaration) => { + file(`plugin/${declaration}`, '{ "hooks": {}, }'); + expect(run('./plugin').status).toBe(1); + }); + + test.each(['hooks.json', 'hooks/hooks.json'])('accepts hook declaration %s without executing commands', (declaration) => { + file(`plugin/${declaration}`, '{"version":1,"hooks":{"sessionStart":[{"command":"touch executed"}]}}'); + expect(run('./plugin').status).toBe(0); + expect(readdirSync(project)).toEqual(['plugin']); + }); + + test('accepts an explicitly marked Pi package without executing its extension', () => { + file('plugin/package.json', '{"name":"example","pi":{"extensions":["./index.ts"]}}'); + file('plugin/index.ts', 'throw new Error("must not execute");\n'); + expect(run('./plugin').status).toBe(0); + }); + + test.each(['extensions', 'prompts', 'themes'])('accepts manifestless Pi %s resources', (resource) => { + mkdirSync(join(project, 'plugin', resource), { recursive: true }); + expect(run('./plugin').status).toBe(0); + file(`plugin/${resource}/example.txt`, 'Native resource\n'); + expect(run('./plugin').status).toBe(0); + }); + + test('does not treat an ordinary Node package as a plugin', () => { + file('plugin/package.json', '{"name":"ordinary-node-project","main":"./index.js"}'); + file('plugin/index.js', 'export default {};\n'); + expect(run('./plugin').status).toBe(1); + }); + + test('preserves a root skill with supporting prompts and an unnamed sidecar package', () => { + file('plugin/SKILL.md', skill); + file('plugin/package.json', '{"private":true,"scripts":{"prepare":"touch executed"}}'); + file('plugin/prompts/review.md', '# Supporting prompt\n'); + const result = run('./plugin'); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).data).toMatchObject({ valid: true, validation: { skills: 1, diagnostics: [] } }); + expect(readdirSync(project)).toEqual(['plugin']); + }); + + test('requires a named JSON manifest when Pi resources have package metadata', () => { + file('plugin/extensions/example.ts', 'throw new Error("must not execute");\n'); + for (const contents of ['{broken', '[]', '{"pi":{}}']) { + file('plugin/package.json', contents); + const result = run('./plugin'); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).data.validation.diagnostics).toContainEqual(expect.objectContaining({ severity: 'error', path: 'package.json' })); + } + }); + + test('accepts linked command files without traversing linked directories', () => { + file('external/review.md', '# Review instructions\n'); + mkdirSync(join(project, 'plugin/commands'), { recursive: true }); + symlinkSync(join(project, 'external/review.md'), join(project, 'plugin/commands/review.md')); + expect(run('./plugin').status).toBe(0); + rmSync(join(project, 'plugin/commands/review.md')); + symlinkSync(join(project, 'external'), join(project, 'plugin/commands/linked-directory')); + symlinkSync(join(project, 'plugin/commands'), join(project, 'plugin/commands/loop')); + expect(run('./plugin').status).toBe(1); + }); + + test.each(['---\nname: missing-description\n---\n', '---\nname: [broken\n---\n', '# Missing metadata\n'])('rejects malformed recognized skills with relative locations', (contents) => { + file('plugin/skills/group/broken/SKILL.md', contents); + const result = run('./plugin'); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + success: false, command: 'plugin validate', data: { + path: './plugin', valid: false, + validation: { diagnostics: [expect.objectContaining({ severity: 'error', path: 'skills/group/broken/SKILL.md' })] }, + }, + }); + expect(result.stdout).not.toContain(project); + }); + + test.each(['.claude-plugin/plugin.json', '.github/plugin/plugin.json', '.codex-plugin/plugin.json', '.claude-plugin/marketplace.json', '.github/plugin/marketplace.json'])('rejects malformed manifest %s', (manifest) => { + file(`plugin/${manifest}`, '{bad json'); + expect(run('./plugin').status).toBe(1); + }); + + test.each(['[]', 'null'])('rejects non-object plugin manifests %s', (contents) => { + file('plugin/.claude-plugin/plugin.json', contents); + const result = run('./plugin'); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).data.validation.diagnostics).toEqual([ + { severity: 'error', path: '.claude-plugin/plugin.json', message: 'Plugin manifest must be a JSON object' }, + ]); + }); + + test('accepts a standalone plugin manifest without imposing client-specific fields', () => { + file('plugin/.claude-plugin/plugin.json', JSON.stringify({ name: 'example', custom: true })); + expect(run('./plugin').status).toBe(0); + }); + + test('rejects a marketplace manifest without a plugins array', () => { + file('plugin/.claude-plugin/marketplace.json', '{"name":"example"}'); + const result = run('./plugin'); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).data.validation.diagnostics).toEqual([ + { severity: 'error', path: '.claude-plugin/marketplace.json', message: 'Marketplace manifest must contain a "plugins" array' }, + ]); + }); + + test('uses installation skill and marketplace precedence', () => { + file('plugin/skills/example/SKILL.md', skill); + file('plugin/SKILL.md', '# Ignored when skills/ exists'); + file('plugin/.github/plugin/marketplace.json', JSON.stringify({ name: 'example', description: 'Example', plugins: [] })); + file('plugin/.claude-plugin/marketplace.json', '{ignored malformed fallback'); + const result = run('./plugin'); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).data.validation).toEqual({ skills: 1, diagnostics: [] }); + }); + + test('uses marketplace parser warnings without rejecting supported lenient manifests', () => { + file('plugin/.claude-plugin/marketplace.json', JSON.stringify({ plugins: [{ name: 'example', source: './' }] })); + const result = run('./plugin'); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).data.validation.diagnostics).toContainEqual(expect.objectContaining({ + severity: 'warning', path: '.claude-plugin/marketplace.json', message: expect.stringContaining('description'), + })); + }); + + test.each(['missing', 'empty', 'unsupported', 'regular-file'])('rejects %s paths in JSON and redirected human output', (path) => { + mkdirSync(join(project, 'empty')); + file('unsupported/README.md', '# Not a plugin\n'); + file('regular-file', 'not a directory'); + for (const flag of ['--json', '']) { + const result = run(`./${path}`, flag); + expect(result.status).toBe(1); + expect(result.stderr).toBe(''); + if (flag) expect(JSON.parse(result.stdout)).toMatchObject({ success: false, data: { path: `./${path}`, valid: false } }); + else expect(result.stdout).toContain('Validation failed'); + } + }); + + test('preserves top-level JSON field selection with and without diagnostics', () => { + file('plugin/SKILL.md', skill); + for (const [path, valid] of [['./plugin', true], ['./missing', false]] as const) { + const result = run(path, '--json=path,valid'); + expect(JSON.parse(result.stdout)).toEqual({ success: valid, command: 'plugin validate', data: { path, valid } }); + } + }); + + test('validates without running scripts or modifying plugin, project, or HOME', () => { + file('plugin/SKILL.md', skill); + file('plugin/package.json', JSON.stringify({ scripts: { prepare: 'touch executed' } })); + file('plugin/hooks/example.sh', 'touch executed\n'); + const snapshot = (directory: string): unknown => readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).map((entry) => [ + entry.name, entry.isDirectory() ? snapshot(join(directory, entry.name)) : readFileSync(join(directory, entry.name)).toString('base64'), + ]); + const before = snapshot(root); + for (const flag of ['', '--json']) { + const result = run('./plugin', flag); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(snapshot(root)).toEqual(before); + } + }); +});