From 2d6fd781d55818f39bfa3c04a35b07d5e0da7537 Mon Sep 17 00:00:00 2001 From: aliksir <62920013+aliksir@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:10:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(skill-validator):=20v1.3.0=20brushup=20?= =?UTF-8?q?=E2=80=94=20bin=20fix,=20tests,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit intent(bin): fix silent bin deletion on npm install -g caused by npm ignoring .mjs bin entries; rename to .js to keep ESM semantics via "type": "module" in package.json decision(rename): git mv skill-validator.mjs -> skill-validator.js rather than adding a .js wrapper — keeps single source of truth, no indirection, identical behavior with type:module learned(npm-bin): npm publish silently drops .mjs bin entries even when type:module is set; .js extension is required for bin to survive (first observed in lesson-skill-loop, now fixed here) intent(tests): add integration test suite covering 15 scenarios; node:test + spawnSync pattern matches lesson-skill-loop tests, zero external dependencies maintained intent(docs): fill missing options (--quiet, --strict, --update, --self-update, --no-version-check) and add commands/ plugin section Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 16 +- README.ja.md | 52 +++- README.md | 23 +- commands/skill-validate-fix.md | 6 +- commands/skill-validate.md | 18 +- package.json | 10 +- skill-validator.mjs => skill-validator.js | 0 test/skill-validator.test.mjs | 318 ++++++++++++++++++++++ 9 files changed, 422 insertions(+), 25 deletions(-) rename skill-validator.mjs => skill-validator.js (100%) create mode 100644 test/skill-validator.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c89237d..3478d74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,4 +15,6 @@ jobs: with: node-version: '20' - name: Syntax check - run: node --check *.mjs + run: node --check skill-validator.js + - name: Run tests + run: node --test test/skill-validator.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b0150..d82873f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## [1.3.0] - 2026-03-22 + +### Added +- Test suite `test/skill-validator.test.mjs` using `node:test` + `node:assert/strict` (15 tests, zero external dependencies) + - Covers: `--help`, `-h`, `--json` schema contract, `--skills-only`, `--commands-only`, `--dir` missing path, `--verbose`, `--quiet`, `--dry-run`, `--no-version-check`, frontmatter validation, syntax error detection +- `commands/` directory now included in npm package (`files` array updated) + +### Changed +- Main script renamed from `skill-validator.mjs` to `skill-validator.js` — fixes silent bin deletion on `npm install -g` caused by npm ignoring `.mjs` bin entries; `"type": "module"` in `package.json` ensures ESM semantics are preserved +- `bin` entry updated to `skill-validator.js` +- `scripts.test` added: `node --test test/skill-validator.test.mjs` +- CI workflow updated: syntax check now targets `skill-validator.js`; test step added +- CHANGELOG: merged duplicate `[1.2.0]` entries (Added + Changed sections combined) + ## [1.2.0] - 2026-03-17 ### Added @@ -20,8 +34,6 @@ All notable changes to this project will be documented in this file. - `isNpx()`: Detects npx execution via `npm_execpath`, `argv[1]`, and `npm list -g` fallback - `selfUpdate()`: Orchestrates the self-update flow with pre/post version display -## [1.2.0] - 2026-03-17 - ### Changed - `checkCommandReferences`: チェック対象をコードフェンスブロック(` ```bash/sh/shell/zsh/console ``` `)内のコマンドと `$ ` プレフィックス行のみに限定 - インラインバッククォート(`` `word` ``)は完全スキップ — 用語囲みをコマンドと誤検知する最大の原因を排除 diff --git a/README.ja.md b/README.ja.md index 10aadb6..484c7dd 100644 --- a/README.ja.md +++ b/README.ja.md @@ -28,22 +28,28 @@ npx claude-skill-validator claude-skill-validator # 詳細出力 -node skill-validator.mjs --verbose +claude-skill-validator --verbose # アップデートチェック付き -node skill-validator.mjs --update-check +claude-skill-validator --update-check # JSON出力 -node skill-validator.mjs --json +claude-skill-validator --json # skills/ のみ -node skill-validator.mjs --skills-only +claude-skill-validator --skills-only # commands/ のみ -node skill-validator.mjs --commands-only +claude-skill-validator --commands-only # 別ディレクトリを指定 -node skill-validator.mjs --dir /path/to/.claude +claude-skill-validator --dir /path/to/.claude + +# FAILのみ表示 +claude-skill-validator --quiet + +# WARNも含めて厳密チェック +claude-skill-validator --strict ``` ## 出力例 @@ -93,6 +99,40 @@ claude-skill-validator --dry-run 2. `package.json` の `repository` フィールド 3. `.git/` ディレクトリの `origin` リモート +## オプション一覧 + +| オプション | 説明 | +|-----------|------| +| `--dir ` | Claude設定ディレクトリ(デフォルト: `~/.claude`) | +| `--skills-only` | `skills/` のみスキャン | +| `--commands-only` | `commands/` のみスキャン | +| `--json` | JSON形式で出力 | +| `--verbose` | PASS含む全結果を表示 | +| `--quiet` | FAILのみ表示(WARN件数はサマリーのみ) | +| `--strict` | frontmatter WARNも表示(デフォルト非表示) | +| `--update-check` | ソースリポジトリからのアップデートチェック | +| `--update` | アップデートを適用(`--update-check` を含む) | +| `--fix` | 修正可能な問題を自動修復(バックアップ付き) | +| `--dry-run` | 修正内容のプレビュー(実際には変更しない) | +| `--self-update` | `claude-skill-validator` 自体を最新版に更新 | +| `--no-version-check` | npm バージョンチェックをスキップ(CI環境向け) | +| `--help`, `-h` | ヘルプを表示 | + +## プラグインコマンド(`commands/`) + +このパッケージには Claude Code スラッシュコマンドが同梱されています: + +| コマンド | ファイル | 機能 | +|---------|---------|------| +| `/skill-validate` | `commands/skill-validate.md` | 全オプション対応の一括スキャン | +| `/skill-validate-fix` | `commands/skill-validate-fix.md` | dry-run → fix → 確認の3ステップフロー | + +グローバルインストール後、Claude Code でそのまま使えます: + +```bash +npm install -g claude-skill-validator +``` + ## 動作環境 - Node.js 18+ diff --git a/README.md b/README.md index 9c4edee..8f2198f 100644 --- a/README.md +++ b/README.md @@ -86,10 +86,31 @@ claude-skill-validator --dry-run | `--skills-only` | Scan `skills/` directory only | | `--commands-only` | Scan `commands/` directory only | | `--json` | Output results as JSON | +| `--verbose` | Show all checks including PASS | +| `--quiet` | Show FAIL only; WARN count appears in summary | +| `--strict` | Show frontmatter WARN entries (hidden by default) | | `--update-check` | Check for updates from source repositories | -| `--verbose` | Verbose output | +| `--update` | Apply available updates (implies `--update-check`) | | `--fix` | Auto-fix fixable issues (creates backups) | | `--dry-run` | Preview fixes without applying changes | +| `--self-update` | Update `claude-skill-validator` itself to the latest version | +| `--no-version-check` | Skip npm version check (useful in CI) | +| `--help`, `-h` | Show help message | + +## Plugin Commands (`commands/`) + +This package ships two Claude Code slash commands in `commands/`: + +| Command | File | What it does | +|---------|------|-------------| +| `/skill-validate` | `commands/skill-validate.md` | Run a full scan (all options exposed) | +| `/skill-validate-fix` | `commands/skill-validate-fix.md` | Guided dry-run → fix → verify flow | + +Install the package globally and the commands become available in Claude Code immediately: + +```bash +npm install -g claude-skill-validator +``` ## Exit Codes diff --git a/commands/skill-validate-fix.md b/commands/skill-validate-fix.md index e68bd17..0fe03fa 100644 --- a/commands/skill-validate-fix.md +++ b/commands/skill-validate-fix.md @@ -10,19 +10,19 @@ description: Auto-fix repairable issues in installed Claude Code skills ### ステップ1: 修正プレビュー(ドライラン) ``` -node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs --dry-run +node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js --dry-run ``` 修正内容を確認し、意図しない変更がないかチェックする。 ### ステップ2: 自動修復を実行 ``` -node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs --fix +node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js --fix ``` 自動修正可能な項目(壊れた参照・非推奨ツール・構文エラー等)を修復する。 ### ステップ3: 修復結果の確認 ``` -node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs +node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js ``` 修復後に再チェックして全件PASSであることを確認する。 diff --git a/commands/skill-validate.md b/commands/skill-validate.md index ef83eba..7bccb04 100644 --- a/commands/skill-validate.md +++ b/commands/skill-validate.md @@ -5,14 +5,14 @@ description: "Validate and repair installed Claude Code skills. Usage: /skill-va Claude Codeスキルの健全性チェックと自動修復ツール。 使い方: -- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs` — 全スキルをチェック -- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs --fix` — 自動修正可能な項目を修正 -- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs --dry-run` — 修正プレビュー -- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs --skills-only` — skills/のみチェック -- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs --commands-only` — commands/のみチェック -- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs --json` — JSON形式出力 -- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs --verbose` — 詳細出力 -- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs --quiet` — FAILのみ表示 -- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.mjs --strict` — WARN も表示 +- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js` — 全スキルをチェック +- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js --fix` — 自動修正可能な項目を修正 +- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js --dry-run` — 修正プレビュー +- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js --skills-only` — skills/のみチェック +- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js --commands-only` — commands/のみチェック +- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js --json` — JSON形式出力 +- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js --verbose` — 詳細出力 +- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js --quiet` — FAILのみ表示 +- `node ${CLAUDE_PLUGIN_ROOT}/skill-validator.js --strict` — WARN も表示 引数$ARGUMENTSがあればオプションとして渡す。 diff --git a/package.json b/package.json index 1d47d36..0b145a9 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,17 @@ { "name": "claude-skill-validator", - "version": "1.2.0", + "version": "1.3.0", "description": "Validate and repair Claude Code skills — detect broken references, deprecated tools, syntax errors, and auto-fix issues", "type": "module", "bin": { - "claude-skill-validator": "./skill-validator.mjs" + "claude-skill-validator": "./skill-validator.js" + }, + "scripts": { + "test": "node --test test/skill-validator.test.mjs" }, "files": [ - "skill-validator.mjs", + "skill-validator.js", + "commands/", "README.md", "README.ja.md", "LICENSE" diff --git a/skill-validator.mjs b/skill-validator.js similarity index 100% rename from skill-validator.mjs rename to skill-validator.js diff --git a/test/skill-validator.test.mjs b/test/skill-validator.test.mjs new file mode 100644 index 0000000..b481f1c --- /dev/null +++ b/test/skill-validator.test.mjs @@ -0,0 +1,318 @@ +/** + * skill-validator.test.mjs — integration tests for skill-validator.js + * + * Uses node:test + node:assert/strict + spawnSync (no external dependencies). + * Each test spawns a fresh process to avoid shared state. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const VALIDATOR = join(__dirname, '..', 'skill-validator.js'); + +/** spawnSync wrapper — always returns { status, stdout, stderr } as strings */ +function run(args = [], opts = {}) { + const result = spawnSync(process.execPath, [VALIDATOR, ...args], { + encoding: 'utf-8', + timeout: 15000, + ...opts, + }); + return { + status: result.status ?? -1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} + +/** Create a minimal temporary ~/.claude-like directory for scan tests */ +function makeTempClaudeDir(opts = {}) { + const base = mkdtempSync(join(tmpdir(), 'sv-test-')); + const skillsDir = join(base, 'skills'); + const cmdsDir = join(base, 'commands'); + mkdirSync(skillsDir); + mkdirSync(cmdsDir); + + if (opts.withSkill) { + const skillDir = join(skillsDir, 'test-skill'); + mkdirSync(skillDir); + // Valid SKILL.md with proper frontmatter + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: test-skill\ndescription: A test skill for unit testing\n---\n\nDoes nothing.\n' + ); + } + + if (opts.withInvalidSkill) { + const skillDir = join(skillsDir, 'bad-skill'); + mkdirSync(skillDir); + // SKILL.md with no frontmatter — triggers WARN + writeFileSync(join(skillDir, 'SKILL.md'), '# bad-skill\n\nNo frontmatter here.\n'); + } + + if (opts.withSyntaxError) { + const skillDir = join(skillsDir, 'syntax-bad'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: syntax-bad\ndescription: Has broken script\n---\n' + ); + const scriptsDir = join(skillDir, 'scripts'); + mkdirSync(scriptsDir); + writeFileSync(join(scriptsDir, 'broken.js'), 'this is not valid javascript ===== !!!'); + } + + if (opts.withFileRef) { + const skillDir = join(skillsDir, 'fileref-skill'); + mkdirSync(skillDir); + // References a file that does not exist → FAIL + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: fileref-skill\ndescription: Has broken file ref\n---\n\nSee INSTRUCTIONS.md for details.\n' + ); + writeFileSync( + join(skillDir, 'INSTRUCTIONS.md'), + 'This skill uses scripts/nonexistent.sh\n' + ); + } + + return base; +} + +// --------------------------------------------------------------------------- +// --help / -h +// --------------------------------------------------------------------------- + +describe('help flags', () => { + it('--help exits 0 and shows Usage', () => { + const r = run(['--help']); + assert.equal(r.status, 0); + assert.match(r.stdout, /Usage/i); + }); + + it('-h is equivalent to --help', () => { + const r = run(['-h']); + assert.equal(r.status, 0); + assert.match(r.stdout, /Usage/i); + }); +}); + +// --------------------------------------------------------------------------- +// --json output contract +// --------------------------------------------------------------------------- + +describe('--json output', () => { + it('outputs parseable JSON with required fields', () => { + const dir = makeTempClaudeDir({ withSkill: true }); + try { + const r = run(['--dir', dir, '--json', '--no-version-check']); + // exit 0 (no FAILs in valid skill) + assert.equal(r.status, 0); + let parsed; + assert.doesNotThrow(() => { parsed = JSON.parse(r.stdout); }, 'stdout must be valid JSON'); + assert.ok('total' in parsed, 'must have "total"'); + assert.ok('pass' in parsed, 'must have "pass"'); + assert.ok('warn' in parsed, 'must have "warn"'); + assert.ok('fail' in parsed, 'must have "fail"'); + assert.ok('results' in parsed, 'must have "results"'); + assert.ok(Array.isArray(parsed.results), '"results" must be an array'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Default scan (requires ~/.claude to exist — assumed on dev machines) +// --------------------------------------------------------------------------- + +describe('default scan', () => { + it('exits 0 or 1 (not 2) when scanning ~/.claude', () => { + const r = run(['--no-version-check']); + // exit 2 means a tool error — that must never happen when ~/.claude exists + assert.notEqual(r.status, 2, `Tool error: ${r.stderr}`); + }); +}); + +// --------------------------------------------------------------------------- +// --skills-only / --commands-only +// --------------------------------------------------------------------------- + +describe('scope flags', () => { + it('--skills-only exits 0 or 1', () => { + const dir = makeTempClaudeDir({ withSkill: true }); + try { + const r = run(['--dir', dir, '--skills-only', '--no-version-check']); + assert.notEqual(r.status, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('--commands-only exits 0 or 1', () => { + const dir = makeTempClaudeDir({ withSkill: true }); + try { + const r = run(['--dir', dir, '--commands-only', '--no-version-check']); + assert.notEqual(r.status, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// --dir with non-existent path +// --------------------------------------------------------------------------- + +describe('--dir with missing path', () => { + it('prints error to stderr when skills/ is absent', () => { + const r = run(['--dir', '/nonexistent-path-99999999', '--no-version-check']); + // stderr should mention the missing directory + const combined = r.stdout + r.stderr; + assert.match(combined, /skills.*見つかりません|skills.*not found/i); + }); +}); + +// --------------------------------------------------------------------------- +// --verbose +// --------------------------------------------------------------------------- + +describe('--verbose', () => { + it('includes PASS results in output', () => { + const dir = makeTempClaudeDir({ withSkill: true }); + try { + const r = run(['--dir', dir, '--verbose', '--no-version-check']); + assert.match(r.stdout, /PASS|✅/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// --quiet +// --------------------------------------------------------------------------- + +describe('--quiet', () => { + it('does not show WARN lines (only summary)', () => { + const dir = makeTempClaudeDir({ withInvalidSkill: true }); + try { + // Without --quiet, WARN should appear somewhere; with --quiet it should not + const withQuiet = run(['--dir', dir, '--quiet', '--no-version-check', '--strict']); + // WARN icon should not appear in main output (may appear in summary count) + // We check that individual ⚠️ lines are not in per-skill section + // The easiest proxy: no "⚠️ [" pattern (which is the per-result format) + assert.doesNotMatch(withQuiet.stdout, /⚠️ \[/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// --dry-run +// --------------------------------------------------------------------------- + +describe('--dry-run', () => { + it('exits 0 when combined with scan (no crash)', () => { + const dir = makeTempClaudeDir({ withSkill: true }); + try { + const r = run(['--dir', dir, '--dry-run', '--no-version-check']); + // --dry-run should not crash (exit 2 is a tool error) + assert.notEqual(r.status, 2, `Tool error: ${r.stderr}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('includes [dry-run] marker when there are fixable issues', () => { + // Create a skill with a deprecated tool reference that can be fixed + const base = mkdtempSync(join(tmpdir(), 'sv-test-')); + const skillsDir = join(base, 'skills'); + const cmdsDir = join(base, 'commands'); + mkdirSync(skillsDir); + mkdirSync(cmdsDir); + const skillDir = join(skillsDir, 'fixable-skill'); + mkdirSync(skillDir); + // Use a deprecated tool reference that the fixer will pick up + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: fixable-skill\ndescription: Has deprecated tool ref\n---\n\nUse mcp__claude-in-chrome__tabs_context_mcp for tabs.\n' + ); + try { + const r = run(['--dir', base, '--fix', '--dry-run', '--no-version-check']); + // [dry-run] marker must appear when there are fixable items + assert.match(r.stdout, /\[dry-run\]/i); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// --no-version-check +// --------------------------------------------------------------------------- + +describe('--no-version-check', () => { + it('suppresses version notification line', () => { + const dir = makeTempClaudeDir({ withSkill: true }); + try { + const r = run(['--dir', dir, '--no-version-check']); + // The version check outputs "💡 新バージョン" when a newer version exists + // With --no-version-check it must be absent + assert.doesNotMatch(r.stdout, /新バージョン/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Frontmatter validation (temp skill dir) +// --------------------------------------------------------------------------- + +describe('frontmatter check', () => { + it('valid skill with proper frontmatter → exit 0', () => { + const dir = makeTempClaudeDir({ withSkill: true }); + try { + const r = run(['--dir', dir, '--skills-only', '--no-version-check']); + assert.equal(r.status, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('skill without frontmatter → WARN appears in JSON (with --strict)', () => { + const dir = makeTempClaudeDir({ withInvalidSkill: true }); + try { + const r = run(['--dir', dir, '--skills-only', '--json', '--strict', '--no-version-check']); + const parsed = JSON.parse(r.stdout); + const warns = parsed.results.filter(x => x.status === 'WARN'); + assert.ok(warns.length > 0, 'expected at least one WARN for missing frontmatter'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Syntax error detection (temp skill with broken script) +// --------------------------------------------------------------------------- + +describe('syntax check', () => { + it('broken JS script → FAIL and exit 1', () => { + const dir = makeTempClaudeDir({ withSyntaxError: true }); + try { + const r = run(['--dir', dir, '--skills-only', '--no-version-check']); + assert.equal(r.status, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});