diff --git a/README.md b/README.md index 634c797..708c5b5 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,17 @@ skillsync import hermes When a same-named skill already exists in the vault, SkillSync keeps identical content as one skill and asks before resolving different content. Non-interactive commands skip different-content conflicts unless you choose a conflict policy explicitly. +## Audit the active catalog + +Validate Agent Skills metadata, find duplicate or conflicting copies across configured targets, and estimate the description tokens loaded by each active catalog: + +```bash +skillsync audit +skillsync audit --json +``` + +`skillsync audit` is read-only and scans configured target paths directly instead of relying on cached inventory. `skillsync doctor` includes the catalog summary. Audit errors cover every hard Agent Skills frontmatter constraint; longer descriptions and oversized skill bodies are warnings. + ## Automatic skill adoption New targets automatically adopt new skills created inside their managed folder. For example, if Codex creates `~/.codex/skills/my-new-skill`, the next SkillSync run adds it to the vault, assigns it to Codex on that device, and replaces the standalone folder with a managed projection. @@ -405,6 +416,7 @@ skillsync Open TUI skillsync setup [--name skills] [--repo owner/repo|url] [--path path] [--yes] skillsync connect [--path path] skillsync status +skillsync audit [--json] skillsync list skillsync installed [--device id] skillsync matrix [--edit] diff --git a/package-lock.json b/package-lock.json index 5368225..f5fdca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.12.1", "license": "MIT", "dependencies": { - "@inquirer/prompts": "^8.5.2" + "@inquirer/prompts": "^8.5.2", + "yaml": "^2.9.0" }, "bin": { "skillsync": "src/cli.js" @@ -428,6 +429,21 @@ "funding": { "url": "https://github.com/sponsors/isaacs" } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index b282296..122e784 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "start": "node src/cli.js" }, "dependencies": { - "@inquirer/prompts": "^8.5.2" + "@inquirer/prompts": "^8.5.2", + "yaml": "^2.9.0" }, "devDependencies": {}, "engines": { diff --git a/src/cli.js b/src/cli.js index 9810ae3..cf8c49a 100755 --- a/src/cli.js +++ b/src/cli.js @@ -5,6 +5,7 @@ import { homedir, platform } from 'node:os'; import path from 'node:path'; import { loadConfig, saveConfig, defaultRepoPath } from './core/config.js'; +import { auditCatalog } from './core/audit.js'; import { addTarget, defaultDeviceId, @@ -113,6 +114,8 @@ async function main() { return connect(rest); case 'status': return status(); + case 'audit': + return auditCommand(rest); case 'list': return listSkills(); case 'installed': @@ -1568,6 +1571,35 @@ async function deleteSkill(rest) { console.log(`Deleted ${skillName} from the vault.`); } +function printAudit(result) { + console.log(`Vault: ${result.summary.skills} skills, ${result.summary.errors} errors, ${result.summary.warnings} warnings`); + for (const skill of result.skills) { + for (const item of skill.findings) { + console.log(`${item.level === 'error' ? '✗' : '!'} ${skill.name} [${item.code}]: ${item.message}`); + } + } + for (const skill of result.externalSkills) { + for (const item of skill.findings) { + console.log(`${item.level === 'error' ? '✗' : '!'} ${skill.name} in ${skill.targets.join(', ')} [${item.code}]: ${item.message}`); + } + } + for (const duplicate of result.duplicates) { + console.log(`${duplicate.status === 'conflicting' ? '✗' : '!'} ${duplicate.name}: ${duplicate.status} copies in ${duplicate.copies.map((copy) => copy.target).join(', ')}`); + } + for (const [target, catalog] of Object.entries(result.targets)) { + console.log(`Catalog ${target}: ${catalog.activeSkills} active skills (${catalog.assignedSkills} assigned), ~${catalog.estimatedDescriptionTokens} description tokens`); + } +} + +async function auditCommand(rest) { + const config = await configured(); + const device = await loadLocalDevice(config.repoPath, config.deviceId, { ensure: false }); + const result = await auditCatalog({ vaultPath: config.repoPath, device }); + if (hasFlag(rest, '--json')) console.log(JSON.stringify(result, null, 2)); + else printAudit(result); + if (result.summary.errors || result.summary.conflictingDuplicates) process.exitCode = 1; +} + function parseTargets(rest) { const targets = []; if (hasFlag(rest, '--global') || hasFlag(rest, '-g')) targets.push('global'); @@ -1838,6 +1870,12 @@ async function doctor() { console.log(`${await isGitRepo(config.repoPath) ? '✓' : '✗'} vault git repo`); const result = await checkVault(config.repoPath); console.log(`✓ vault checked: ${result.skills} skills, ${result.files} files`); + const device = await loadLocalDevice(config.repoPath, config.deviceId, { ensure: false }); + const audit = await auditCatalog({ vaultPath: config.repoPath, device }); + console.log(`${audit.summary.errors ? '✗' : '✓'} catalog: ${audit.summary.skills} skills, ${audit.summary.errors} errors, ${audit.summary.warnings} warnings`); + for (const [target, catalog] of Object.entries(audit.targets)) { + console.log(` ${target}: ${catalog.activeSkills} active (${catalog.assignedSkills} assigned), ~${catalog.estimatedDescriptionTokens} description tokens`); + } } catch (error) { console.log(`✗ config/vault: ${error.message}`); } @@ -2899,5 +2937,5 @@ async function instructionProfileSettingsScreen(config, device) { } function help() { - console.log(`SkillSync\n\nUsage:\n skillsync Open TUI\n skillsync setup [--name skills] [--repo owner/repo|url] [--path path] [--yes]\n skillsync connect [--path path]\n skillsync status\n skillsync list\n skillsync installed [--device id]\n skillsync matrix [--edit]\n skillsync instructions status\n skillsync instructions profiles\n skillsync instructions import [--name profile] [--from path] [--to path] [--separate]\n skillsync instructions use [--device id] [--path path]\n skillsync instructions use-device [--device target-device]\n skillsync instructions fork [profile]\n skillsync instructions link \n skillsync instructions unlink \n skillsync instructions enable [--profile profile] [--path path] [--from-local|--use-vault]\n skillsync instructions disable [--device id]\n skillsync plugins status\n skillsync plugins profiles\n skillsync plugins show \n skillsync plugins import --name [--plugin plugin@marketplace] [--auto-adopt|--no-auto-adopt]\n skillsync plugins use [--device id]\n skillsync plugins auto-adopt \n skillsync device list\n skillsync device show \n skillsync groups [--summary]\n skillsync pack list\n skillsync pack show \n skillsync pack install [--target targets] [--global]\n skillsync add [--name name] [--skill name] [--target targets] [--global] [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync import [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync install [--device id] [--target targets] [--global]\n skillsync uninstall [--device id] [--target targets] [--global]\n skillsync delete [--yes]\n skillsync target add [--mode symlink|copy] [--scan-path path] [--no-auto-adopt]\n skillsync target remove \n skillsync target auto-adopt \n skillsync auto-adopt [show|on|off]\n skillsync policy show\n skillsync policy set delete-unassigned-skills \n skillsync scan [--json]\n skillsync sync [--dry-run] [--no-pull] [--discard-local-changes]\n skillsync rollback\n skillsync check\n skillsync service install\n skillsync doctor\n skillsync daemon\n`); + console.log(`SkillSync\n\nUsage:\n skillsync Open TUI\n skillsync setup [--name skills] [--repo owner/repo|url] [--path path] [--yes]\n skillsync connect [--path path]\n skillsync status\n skillsync audit [--json]\n skillsync list\n skillsync installed [--device id]\n skillsync matrix [--edit]\n skillsync instructions status\n skillsync instructions profiles\n skillsync instructions import [--name profile] [--from path] [--to path] [--separate]\n skillsync instructions use [--device id] [--path path]\n skillsync instructions use-device [--device target-device]\n skillsync instructions fork [profile]\n skillsync instructions link \n skillsync instructions unlink \n skillsync instructions enable [--profile profile] [--path path] [--from-local|--use-vault]\n skillsync instructions disable [--device id]\n skillsync plugins status\n skillsync plugins profiles\n skillsync plugins show \n skillsync plugins import --name [--plugin plugin@marketplace] [--auto-adopt|--no-auto-adopt]\n skillsync plugins use [--device id]\n skillsync plugins auto-adopt \n skillsync device list\n skillsync device show \n skillsync groups [--summary]\n skillsync pack list\n skillsync pack show \n skillsync pack install [--target targets] [--global]\n skillsync add [--name name] [--skill name] [--target targets] [--global] [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync import [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync install [--device id] [--target targets] [--global]\n skillsync uninstall [--device id] [--target targets] [--global]\n skillsync delete [--yes]\n skillsync target add [--mode symlink|copy] [--scan-path path] [--no-auto-adopt]\n skillsync target remove \n skillsync target auto-adopt \n skillsync auto-adopt [show|on|off]\n skillsync policy show\n skillsync policy set delete-unassigned-skills \n skillsync scan [--json]\n skillsync sync [--dry-run] [--no-pull] [--discard-local-changes]\n skillsync rollback\n skillsync check\n skillsync service install\n skillsync doctor\n skillsync daemon\n`); } diff --git a/src/core/audit.js b/src/core/audit.js new file mode 100644 index 0000000..bc83e2e --- /dev/null +++ b/src/core/audit.js @@ -0,0 +1,262 @@ +import { lstat, readFile, readdir, realpath, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { parseDocument } from 'yaml'; + +import { expandHome, hashDirectory } from './fs.js'; +import { discoverSkillFolders } from './source.js'; + +const PORTABLE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const RECOMMENDED_DESCRIPTION_LENGTH = 400; + +function finding(level, code, message) { + return { level, code, message }; +} + +async function discoverTargetSkills(root) { + const results = []; + const seen = new Set(); + + async function walk(current) { + const canonical = await realpath(current).catch(() => path.resolve(current)); + if (seen.has(canonical)) return; + seen.add(canonical); + + try { + await stat(path.join(current, 'SKILL.md')); + results.push({ path: current }); + return; + } catch { + // Keep looking below folders that are not themselves skills. + } + + const entries = await readdir(current, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const child = path.join(current, entry.name); + const directory = entry.isDirectory() + || (entry.isSymbolicLink() && await stat(child).then((info) => info.isDirectory()).catch(() => false)); + if (directory) await walk(child); + } + } + + await walk(path.resolve(root)); + return results.sort((a, b) => a.path.localeCompare(b.path)); +} + +async function isManagedProjection(skillPath, vaultPath, name) { + const info = await lstat(skillPath).catch(() => null); + if (info?.isSymbolicLink()) { + const [actual, expected] = await Promise.all([ + realpath(skillPath).catch(() => null), + realpath(path.join(vaultPath, 'skills', name)).catch(() => null), + ]); + return actual !== null && actual === expected; + } + try { + const marker = JSON.parse(await readFile(path.join(skillPath, '.skillsync-owned.json'), 'utf8')); + return marker.skill === name && path.resolve(marker.vault) === path.resolve(vaultPath); + } catch { + return false; + } +} + +export async function auditSkillFolder(skillPath, { expectedName = path.basename(skillPath) } = {}) { + const skillFile = path.join(skillPath, 'SKILL.md'); + const findings = []; + let raw; + try { + raw = await readFile(skillFile, 'utf8'); + } catch (error) { + return { + path: skillPath, + name: expectedName, + descriptionLength: 0, + estimatedDescriptionTokens: 0, + findings: [finding('error', 'missing-skill-file', error.code === 'ENOENT' + ? 'SKILL.md is missing' + : `SKILL.md could not be read: ${error.message}`)], + }; + } + + const match = raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/); + let metadata = new Map(); + if (!match) { + findings.push(finding('error', 'missing-frontmatter', 'SKILL.md must start with YAML frontmatter')); + } else { + const document = parseDocument(match[1], { prettyErrors: false, uniqueKeys: true }); + for (const error of document.errors) { + findings.push(finding('error', 'invalid-frontmatter', error.message.split('\n')[0])); + } + if (!document.errors.length) { + try { + const parsed = document.toJS({ mapAsMap: true, maxAliasCount: 20 }); + if (parsed instanceof Map) metadata = parsed; + else findings.push(finding('error', 'invalid-frontmatter-type', 'Frontmatter must be a YAML mapping')); + } catch (error) { + findings.push(finding('error', 'invalid-frontmatter', error.message.split('\n')[0])); + } + } + } + + const rawName = metadata.get('name'); + const rawDescription = metadata.get('description'); + const name = typeof rawName === 'string' ? rawName : ''; + const description = typeof rawDescription === 'string' ? rawDescription : ''; + if (!name) { + findings.push(finding('error', 'missing-name', 'Frontmatter name must be a non-empty string')); + } else { + if (name.length > 64) findings.push(finding('error', 'name-too-long', 'Name exceeds 64 characters')); + if (!PORTABLE_NAME.test(name)) { + findings.push(finding('error', 'invalid-name', 'Name must use lowercase letters, numbers, and single hyphens')); + } + if (name !== expectedName) { + findings.push(finding('error', 'name-directory-mismatch', `Frontmatter name "${name}" does not match directory "${expectedName}"`)); + } + } + + if (!description.trim()) { + findings.push(finding('error', 'missing-description', 'Frontmatter description must be a non-empty string')); + } else { + if (description.length > 1024) { + findings.push(finding('error', 'description-too-long', `Description is ${description.length} characters; maximum is 1024`)); + } else if (description.length > RECOMMENDED_DESCRIPTION_LENGTH) { + findings.push(finding('warning', 'description-verbose', `Description is ${description.length} characters; consider keeping discovery metadata under ${RECOMMENDED_DESCRIPTION_LENGTH}`)); + } + } + + if (metadata.has('license') && typeof metadata.get('license') !== 'string') { + findings.push(finding('error', 'invalid-license', 'License must be a string')); + } + + if (metadata.has('compatibility')) { + const compatibility = metadata.get('compatibility'); + if (typeof compatibility !== 'string' || !compatibility.trim()) { + findings.push(finding('error', 'invalid-compatibility', 'Compatibility must be a non-empty string when provided')); + } else if (compatibility.length > 500) { + findings.push(finding('error', 'compatibility-too-long', `Compatibility is ${compatibility.length} characters; maximum is 500`)); + } + } + + if (metadata.has('metadata')) { + const customMetadata = metadata.get('metadata'); + if (!(customMetadata instanceof Map)) { + findings.push(finding('error', 'invalid-metadata', 'Metadata must be a mapping from string keys to string values')); + } else if ([...customMetadata].some(([key, value]) => typeof key !== 'string' || typeof value !== 'string')) { + findings.push(finding('error', 'invalid-metadata-entry', 'Metadata keys and values must be strings')); + } + } + + if (metadata.has('allowed-tools')) { + const allowedTools = metadata.get('allowed-tools'); + if (typeof allowedTools !== 'string' || !allowedTools.trim()) { + findings.push(finding('error', 'invalid-allowed-tools', 'Allowed tools must be a non-empty space-separated string')); + } + } + + const body = match ? raw.slice(match[0].length) : raw; + const bodyLines = body.split(/\r?\n/).length; + const estimatedBodyTokens = Math.ceil(body.length / 4); + if (bodyLines > 500) findings.push(finding('warning', 'body-too-long', `Skill body is ${bodyLines} lines; recommended maximum is 500`)); + if (estimatedBodyTokens > 5000) findings.push(finding('warning', 'body-token-heavy', `Skill body is approximately ${estimatedBodyTokens} tokens; recommended maximum is 5000`)); + + return { + path: skillPath, + name: name || expectedName, + descriptionLength: description.length, + estimatedDescriptionTokens: Math.ceil(description.length / 4), + bodyLines, + estimatedBodyTokens, + findings, + }; +} + +export async function auditCatalog({ vaultPath, device }) { + const vaultSkills = await discoverSkillFolders(path.join(vaultPath, 'skills')); + const skills = []; + const vaultHashes = new Map(); + for (const skill of vaultSkills) { + skills.push(await auditSkillFolder(skill.path, { expectedName: path.basename(skill.path) })); + vaultHashes.set(skill.name, await hashDirectory(skill.path).catch(() => null)); + } + + const copiesByName = new Map(); + const descriptionsByTarget = new Map(); + const discoveredByTarget = new Map(); + const externalByContent = new Map(); + for (const [targetName, target] of Object.entries(device?.targets || {})) { + const root = expandHome(target.scan_path || target.path); + const discovered = await discoverTargetSkills(root); + discoveredByTarget.set(targetName, []); + for (const skill of discovered) { + const hash = await hashDirectory(skill.path).catch(() => null); + const audited = await auditSkillFolder(skill.path); + const skillName = audited.name; + discoveredByTarget.get(targetName).push(skillName); + if (!descriptionsByTarget.has(targetName)) descriptionsByTarget.set(targetName, new Map()); + descriptionsByTarget.get(targetName).set(skillName, audited.descriptionLength); + if (!hash || hash !== vaultHashes.get(skillName)) { + const key = `${skillName}\0${hash || skill.path}`; + const existing = externalByContent.get(key); + if (existing) existing.targets.push(targetName); + else externalByContent.set(key, { ...audited, targets: [targetName] }); + } + if (!await isManagedProjection(skill.path, vaultPath, skillName)) { + if (!copiesByName.has(skillName)) copiesByName.set(skillName, []); + copiesByName.get(skillName).push({ target: targetName, path: skill.path, hash }); + } + } + } + + const duplicates = []; + for (const [name, copies] of copiesByName) { + const compared = vaultHashes.has(name) + ? [{ target: 'vault', path: path.join(vaultPath, 'skills', name), hash: vaultHashes.get(name) }, ...copies] + : copies; + if (compared.length < 2) continue; + const hashes = new Set(compared.map((copy) => copy.hash).filter(Boolean)); + duplicates.push({ + name, + status: hashes.size <= 1 ? 'identical' : 'conflicting', + copies: compared.map(({ target, path: copyPath }) => ({ target, path: copyPath })), + }); + } + + const skillByName = new Map(skills.map((skill) => [skill.name, skill])); + const externalSkills = [...externalByContent.values()] + .map((skill) => ({ ...skill, targets: skill.targets.sort() })) + .sort((a, b) => a.name.localeCompare(b.name)); + const allAuditedSkills = [...skills, ...externalSkills]; + const targets = {}; + for (const targetName of Object.keys(device?.targets || {}).sort()) { + const assigned = Object.entries(device.installed || {}) + .filter(([, targetNames]) => Array.isArray(targetNames) && targetNames.includes(targetName)) + .map(([name]) => name) + .sort(); + const detected = discoveredByTarget.get(targetName) || []; + const active = [...new Set([...assigned, ...detected])].sort(); + const descriptionCharacters = active.reduce((total, name) => total + + (skillByName.get(name)?.descriptionLength || descriptionsByTarget.get(targetName)?.get(name) || 0), 0); + targets[targetName] = { + activeSkills: active.length, + assignedSkills: assigned.length, + unmanagedOrDetectedSkills: active.filter((name) => !assigned.includes(name)).length, + descriptionCharacters, + estimatedDescriptionTokens: Math.ceil(descriptionCharacters / 4), + }; + } + + return { + summary: { + skills: skills.length, + externalSkillVariants: externalSkills.length, + errors: allAuditedSkills.reduce((count, skill) => count + skill.findings.filter((item) => item.level === 'error').length, 0), + warnings: allAuditedSkills.reduce((count, skill) => count + skill.findings.filter((item) => item.level === 'warning').length, 0), + identicalDuplicates: duplicates.filter((item) => item.status === 'identical').length, + conflictingDuplicates: duplicates.filter((item) => item.status === 'conflicting').length, + }, + skills, + externalSkills, + duplicates: duplicates.sort((a, b) => a.name.localeCompare(b.name)), + targets, + }; +} diff --git a/test/catalog.test.js b/test/catalog.test.js new file mode 100644 index 0000000..a754b92 --- /dev/null +++ b/test/catalog.test.js @@ -0,0 +1,191 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { auditCatalog, auditSkillFolder } from '../src/core/audit.js'; +import { rebuildRegistry } from '../src/core/registry.js'; + +async function tempDir() { + return mkdtemp(path.join(tmpdir(), 'skillsync-catalog-test-')); +} + +async function makeSkill(root, name, description = 'Use this skill for focused test work.', body = '# Instructions\n') { + const dir = path.join(root, name); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n${body}`); + return dir; +} + +test('auditSkillFolder validates required Agent Skills metadata', async () => { + const root = await tempDir(); + const invalid = path.join(root, 'wrong-directory'); + await mkdir(invalid); + await writeFile(path.join(invalid, 'SKILL.md'), '---\nname: Bad:Name\ndescription: ""\n---\n# Bad\n'); + + const result = await auditSkillFolder(invalid); + assert.deepEqual( + result.findings.map((item) => item.code), + ['invalid-name', 'name-directory-mismatch', 'missing-description'], + ); +}); + +test('auditSkillFolder enforces required metadata length limits', async () => { + const root = await tempDir(); + const name = 'a'.repeat(65); + const skill = path.join(root, name); + await mkdir(skill); + await writeFile(path.join(skill, 'SKILL.md'), `---\nname: ${name}\ndescription: ${'x'.repeat(1025)}\n---\n# Instructions\n`); + + assert.deepEqual( + (await auditSkillFolder(skill)).findings.map((item) => item.code), + ['name-too-long', 'description-too-long'], + ); +}); + +test('auditSkillFolder accepts every optional Agent Skills metadata field', async () => { + const root = await tempDir(); + const skill = path.join(root, 'pdf-processing'); + await mkdir(skill); + await writeFile(path.join(skill, 'SKILL.md'), `--- +name: pdf-processing +description: Extract PDF text and fill forms. Use when handling PDFs. +license: Apache-2.0 +compatibility: Requires pdftotext +metadata: + author: example-org + version: "1.0" +allowed-tools: Bash(pdftotext:*) Read +--- +# Instructions +`); + + assert.deepEqual((await auditSkillFolder(skill)).findings, []); +}); + +test('auditSkillFolder rejects every invalid optional metadata shape', async () => { + const root = await tempDir(); + const skill = path.join(root, 'invalid-options'); + await mkdir(skill); + await writeFile(path.join(skill, 'SKILL.md'), `--- +name: invalid-options +description: Validate optional fields. +license: + family: MIT +compatibility: ${'x'.repeat(501)} +metadata: + version: 1 +allowed-tools: + - Read +--- +# Instructions +`); + + assert.deepEqual( + (await auditSkillFolder(skill)).findings.map((item) => item.code), + ['invalid-license', 'compatibility-too-long', 'invalid-metadata-entry', 'invalid-allowed-tools'], + ); +}); + +test('auditSkillFolder rejects non-mapping metadata and empty optional strings', async () => { + const root = await tempDir(); + const skill = path.join(root, 'invalid-containers'); + await mkdir(skill); + await writeFile(path.join(skill, 'SKILL.md'), `--- +name: invalid-containers +description: Validate optional containers. +compatibility: "" +metadata: + - author +allowed-tools: "" +--- +# Instructions +`); + + assert.deepEqual( + (await auditSkillFolder(skill)).findings.map((item) => item.code), + ['invalid-compatibility', 'invalid-metadata', 'invalid-allowed-tools'], + ); +}); + +test('auditSkillFolder requires frontmatter to be a mapping', async () => { + const root = await tempDir(); + const skill = path.join(root, 'not-a-map'); + await mkdir(skill); + await writeFile(path.join(skill, 'SKILL.md'), '---\n- name: not-a-map\n- description: Invalid root\n---\n# Instructions\n'); + + assert.deepEqual( + (await auditSkillFolder(skill)).findings.map((item) => item.code), + ['invalid-frontmatter-type', 'missing-name', 'missing-description'], + ); +}); + +test('auditCatalog scans current target contents instead of cached inventory', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const codex = path.join(root, 'codex'); + await makeSkill(path.join(vault, 'skills'), 'review', 'Review code changes carefully.'); + await makeSkill(codex, 'new-skill', 'Use the new workflow.'); + await rebuildRegistry(vault); + + const result = await auditCatalog({ + vaultPath: vault, + device: { + targets: { codex: { path: codex } }, + installed: {}, + detected: { codex: [{ name: 'deleted-skill', path: 'deleted-skill' }] }, + }, + }); + + assert.deepEqual(result.externalSkills.map((skill) => skill.name), ['new-skill']); + assert.equal(result.targets.codex.activeSkills, 1); + assert.equal(result.targets.codex.unmanagedOrDetectedSkills, 1); +}); + +test('auditCatalog reports conflicting target copies and active description cost', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const codex = path.join(root, 'codex'); + const claude = path.join(root, 'claude'); + await makeSkill(path.join(vault, 'skills'), 'review', 'Review code changes carefully.'); + await makeSkill(codex, 'review', 'Review code changes carefully.'); + await makeSkill(claude, 'review', 'Use a different review workflow.'); + await rebuildRegistry(vault); + + const result = await auditCatalog({ + vaultPath: vault, + device: { + targets: { codex: { path: codex }, claude: { path: claude } }, + installed: { review: ['codex', 'claude'] }, + }, + }); + + assert.equal(result.summary.conflictingDuplicates, 1); + assert.equal(result.duplicates[0].status, 'conflicting'); + assert.equal(result.targets.codex.assignedSkills, 1); + assert.ok(result.targets.codex.estimatedDescriptionTokens > 0); +}); + +test('auditCatalog does not report managed projections as duplicates', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const codex = path.join(root, 'codex'); + await makeSkill(path.join(vault, 'skills'), 'review', 'Review code changes carefully.'); + await rebuildRegistry(vault); + await mkdir(codex); + await symlink(path.join(vault, 'skills', 'review'), path.join(codex, 'review'), 'dir'); + + const result = await auditCatalog({ + vaultPath: vault, + device: { + targets: { codex: { path: codex } }, + installed: { review: ['codex'] }, + detected: {}, + }, + }); + + assert.equal(result.summary.identicalDuplicates, 0); + assert.equal(result.summary.conflictingDuplicates, 0); + assert.equal(result.targets.codex.activeSkills, 1); +}); diff --git a/test/cli.test.js b/test/cli.test.js index 9ccf840..ac8bcad 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -115,6 +115,38 @@ test('installed command marks missing managed projections', async () => { assert.match(stdout, /codex: .*paper-mcp \(missing; run skillsync sync\)/); }); +test('audit --json refreshes target inventory without changing persisted state', async () => { + const home = await tempDir(); + const vault = path.join(home, '.skillsync', 'repo'); + const target = path.join(home, '.codex', 'skills'); + const deviceId = 'test-device'; + await writeConfig(home, vault, deviceId); + await makeSkill(path.join(vault, 'skills'), 'review', '---\nname: review\ndescription: Review code changes.\n---\n# Review\n'); + await rebuildRegistry(vault); + await addTarget({ vaultPath: vault, deviceId, name: 'codex', targetPath: target }); + await installSkill({ vaultPath: vault, deviceId, skillName: 'review', targets: ['codex'] }); + await makeSkill(target, 'new-skill', '---\nname: new-skill\ndescription: Use the new local workflow.\n---\n# New\n'); + const registryPath = path.join(vault, 'registry.json'); + const localDevicePath = path.join(vault, '.git', 'skillsync', 'local', 'devices', `${deviceId}.json`); + const before = { + registry: await readFile(registryPath, 'utf8'), + device: await readFile(localDevicePath, 'utf8'), + }; + + const { stdout } = await execFileAsync(process.execPath, [path.resolve('src/cli.js'), 'audit', '--json'], { + cwd: path.resolve('.'), + env: cliEnv(home), + }); + const result = JSON.parse(stdout); + + assert.equal(result.summary.errors, 0); + assert.equal(result.targets.codex.assignedSkills, 1); + assert.deepEqual(result.externalSkills.map((skill) => skill.name), ['new-skill']); + assert.ok(result.targets.codex.estimatedDescriptionTokens > 0); + assert.equal(await readFile(registryPath, 'utf8'), before.registry); + assert.equal(await readFile(localDevicePath, 'utf8'), before.device); +}); + test('install --device records a pending assignment without touching remote paths', async () => { const home = await tempDir(); const vault = path.join(home, '.skillsync', 'repo');