From 3ddd3cbaad2d3ae76bbcd4eba032400ae71c54c5 Mon Sep 17 00:00:00 2001 From: Josh Kennedy Date: Sat, 1 Aug 2026 19:05:39 -0500 Subject: [PATCH 1/8] feat: add catalog reliability controls --- README.md | 31 ++++++++ package-lock.json | 18 ++++- package.json | 3 +- src/cli.js | 142 ++++++++++++++++++++++++++++++++-- src/core/audit.js | 176 +++++++++++++++++++++++++++++++++++++++++++ src/core/device.js | 6 +- src/core/packs.js | 28 +++++++ src/core/registry.js | 41 ++++++++-- src/core/source.js | 15 ++++ src/core/update.js | 56 ++++++++++++++ test/catalog.test.js | 139 ++++++++++++++++++++++++++++++++++ test/cli.test.js | 50 ++++++++++++ 12 files changed, 687 insertions(+), 18 deletions(-) create mode 100644 src/core/audit.js create mode 100644 src/core/packs.js create mode 100644 src/core/update.js create mode 100644 test/catalog.test.js diff --git a/README.md b/README.md index 3360743..70c80c2 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,34 @@ 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. +Git imports retain their repository, ref, commit, and skill subpath. Check tracked skills without changing the vault, then apply one reviewed update explicitly: + +```bash +skillsync update --check +skillsync update example-skill +skillsync update example-skill --apply +``` + +## 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 doctor` includes the catalog summary. Audit errors cover malformed frontmatter, non-portable names, directory/name mismatches, missing descriptions, and specification limits. Longer-but-valid descriptions and oversized skill bodies are warnings. + +## Reconcile an exact skill pack + +Pack application previews changes by default. `--exact` removes SkillSync assignments for skills outside the pack on only the selected targets; unmanaged local folders and assignments on other targets remain untouched. + +```bash +skillsync pack apply core --target codex,claude --exact +skillsync pack apply core --target codex,claude --exact --apply +``` + ## 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. @@ -318,6 +346,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] @@ -337,11 +366,13 @@ skillsync groups [--summary] skillsync pack list skillsync pack show skillsync pack install [--target targets] [--global] +skillsync pack apply --target targets [--exact] [--apply] skillsync add [--name name] [--skill name] [--target targets] [--global] [--conflict skip|use-vault|overwrite-vault|rename] skillsync import [--conflict skip|use-vault|overwrite-vault|rename] skillsync install [--device id] [--target targets] [--global] skillsync uninstall [--device id] [--target targets] [--global] skillsync delete [--yes] +skillsync update [skill] [--check] [--apply] skillsync target add [--mode symlink|copy] [--scan-path path] [--no-auto-adopt] skillsync target remove skillsync target auto-adopt diff --git a/package-lock.json b/package-lock.json index e852150..d383e06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.10.5", "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 38e1b40..6383805 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 0b10d94..51d266e 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, applyLinks, @@ -40,6 +41,7 @@ import { } from './core/fs.js'; import { cloneRepo, commandExists, commitAllIfChanged, gh, git, isGitRepo, push, run } from './core/git.js'; import { generateGroups } from './core/groups.js'; +import { planPackApplication } from './core/packs.js'; import { DEFAULT_CLAUDE_INSTRUCTIONS_PATH, DEFAULT_GLOBAL_INSTRUCTIONS_PATH, @@ -73,6 +75,7 @@ import { import { cloneSkillSource, discoverSkillFolders, importSourceForAgent, isRemoteSkillSource, selectDiscoveredSkills, supportedImportSources } from './core/source.js'; import { bootstrapLaunchAgent, daemonInvocation, renderLaunchAgent, renderSystemdUserService } from './core/service.js'; import { syncVault } from './core/sync.js'; +import { inspectSkillUpdate } from './core/update.js'; const args = process.argv.slice(2); @@ -94,6 +97,8 @@ async function main() { return connect(rest); case 'status': return status(); + case 'audit': + return auditCommand(rest); case 'list': return listSkills(); case 'installed': @@ -121,6 +126,8 @@ async function main() { return uninstall(rest); case 'delete': return deleteSkill(rest); + case 'update': + return updateCommand(rest); case 'target': return target(rest); case 'auto-adopt': @@ -524,6 +531,9 @@ async function loadPack(config, packName) { if (!pack?.name || !Array.isArray(pack.skills)) { throw new Error(`Invalid pack manifest: ${packPath}`); } + const registry = await loadRegistry(config.repoPath); + const missing = pack.skills.filter((skillName) => !registry.skills[skillName]); + if (missing.length) throw new Error(`Pack contains skills missing from the vault: ${missing.join(', ')}`); return pack; } @@ -568,7 +578,50 @@ async function packCommand(rest) { console.log(`Installed pack ${pack.name} (${pack.skills.length} skills).`); return; } - throw new Error('Usage: skillsync pack list|show |install [--target codex,claude] [--global]'); + if (sub === 'apply') { + const packName = rest[1]; + if (!packName) throw new Error('Usage: skillsync pack apply --target codex,claude [--exact] [--apply]'); + const targets = parseTargets(rest.slice(2)); + if (!targets?.length) throw new Error('Pack apply requires --target or --global'); + const deviceId = requestedDeviceId(config, rest); + await pullBeforeRemoteEdit(config, deviceId); + const device = await requireKnownDevice(config.repoPath, deviceId); + for (const targetName of targets.filter((targetName) => targetName !== 'global')) { + if (!device.targets?.[targetName]) throw new Error(`Unknown target on ${deviceId}: ${targetName}`); + } + const pack = await loadPack(config, packName); + const changes = planPackApplication({ + device, + skills: pack.skills, + targets, + exact: hasFlag(rest, '--exact'), + }); + if (!changes.length) { + console.log(`Pack ${pack.name} already matches ${targets.join(', ')} on ${deviceId}.`); + return; + } + console.log(`${hasFlag(rest, '--apply') ? 'Applying' : 'Previewing'} ${changes.length} assignment change${changes.length === 1 ? '' : 's'}:`); + for (const change of changes) { + console.log(`- ${change.skillName}: ${change.before.join(', ') || 'none'} -> ${change.after.join(', ') || 'none'}`); + } + if (!hasFlag(rest, '--apply')) { + console.log('Dry run only. Re-run with --apply to change assignments.'); + return; + } + for (const change of changes) { + await setSkillTargets({ + vaultPath: config.repoPath, + deviceId, + skillName: change.skillName, + targets: change.after, + }); + } + if (deviceId === config.deviceId) await applyLinks({ vaultPath: config.repoPath, deviceId }); + await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); + console.log(`Applied pack ${pack.name} to ${targets.join(', ')} on ${deviceId}.`); + return; + } + throw new Error('Usage: skillsync pack list|show |install |apply --target targets [--exact] [--apply]'); } function projectionDetailsBySkill(projections) { @@ -1039,15 +1092,16 @@ async function renamedSkillName({ rest, currentName }) { return input({ message: `New vault name for ${currentName}:`, default: `${currentName}-local` }); } -async function addSkillWithConflictResolution({ config, sourcePath, name, rest = [], targets = [] }) { +async function addSkillWithConflictResolution({ config, sourcePath, name, rest = [], targets = [], source }) { const comparison = await compareSkillToVault({ vaultPath: config.repoPath, sourcePath, name }); if (comparison.status === 'new') { - const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name }); + const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name, source }); const managed = await installManagedSkill({ config, skillName: added.name, targets, sourcePath }); return { name: added.name, status: managed.replacedTarget ? 'added-and-linked' : 'added' }; } if (comparison.status === 'identical') { + await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name, source }); const managed = await installManagedSkill({ config, skillName: comparison.name, targets, sourcePath }); return { name: comparison.name, status: managed.replacedTarget ? 'consolidated' : 'identical' }; } @@ -1071,14 +1125,14 @@ async function addSkillWithConflictResolution({ config, sourcePath, name, rest = } if (action === 'overwrite-vault') { - const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name, overwrite: true }); + const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name, overwrite: true, source }); const managed = await installManagedSkill({ config, skillName: added.name, targets, sourcePath }); return { name: added.name, status: managed.replacedTarget ? 'overwritten-and-linked' : 'overwritten' }; } if (action === 'rename') { const newName = await renamedSkillName({ rest, currentName: comparison.name }); - const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name: newName }); + const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name: newName, source }); return { name: added.name, status: 'renamed' }; } @@ -1159,6 +1213,12 @@ async function addRemoteSkills(source, rest, config) { name: skill.name, rest, targets, + source: { + url: cloned.sourceUrl, + ...(cloned.ref ? { ref: cloned.ref } : {}), + commit: cloned.commit, + subpath: skill.relative, + }, }); results.push(result); } @@ -1278,6 +1338,70 @@ 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(); + await refreshChangedRegistryEntries(config.repoPath); + const device = await loadLocalDevice(config.repoPath, config.deviceId); + 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; +} + +async function updateCommand(rest) { + const config = await configured(); + const requested = rest[0] && !rest[0].startsWith('-') ? rest[0] : null; + const apply = hasFlag(rest, '--apply'); + if (apply && !requested) throw new Error('Applying updates requires one explicit skill name'); + const registry = await loadRegistry(config.repoPath); + const names = requested + ? [requested] + : Object.keys(registry.skills).filter((name) => registry.skills[name].source?.url).sort(); + if (!names.length) { + console.log('No source-tracked skills in the vault.'); + return; + } + for (const skillName of names) { + try { + const result = await inspectSkillUpdate({ vaultPath: config.repoPath, skillName, apply }); + if (result.status === 'available') { + console.log(`! ${skillName}: update available (${result.currentCommit || 'unknown'} -> ${result.availableCommit})`); + } else if (result.status === 'updated') { + console.log(`✓ ${skillName}: updated to ${result.commit}`); + } else if (result.status === 'current') { + console.log(`✓ ${skillName}: current at ${result.commit}`); + } else { + console.log(`○ ${skillName}: source is not tracked`); + } + } catch (error) { + if (apply) throw error; + console.log(`✗ ${skillName}: ${error.message}`); + process.exitCode = 1; + } + } + if (apply) await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); +} + function parseTargets(rest) { const targets = []; if (hasFlag(rest, '--global') || hasFlag(rest, '-g')) targets.push('global'); @@ -1498,6 +1622,12 @@ async function doctor() { console.log(`${await isGitRepo(config.repoPath) ? '✓' : '✗'} vault git repo`); await refreshChangedRegistryEntries(config.repoPath); console.log('✓ registry checked/rebuilt'); + const device = await loadLocalDevice(config.repoPath, config.deviceId); + 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}`); } @@ -2550,5 +2680,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 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\n skillsync sync\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 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 pack apply --target targets [--exact] [--apply]\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 update [skill] [--check] [--apply]\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\n skillsync sync\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..9cc3942 --- /dev/null +++ b/src/core/audit.js @@ -0,0 +1,176 @@ +import { readFile } 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 }; +} + +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 = {}; + 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({ maxAliasCount: 20 }); + metadata = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (error) { + findings.push(finding('error', 'invalid-frontmatter', error.message.split('\n')[0])); + } + } + } + + const name = typeof metadata.name === 'string' ? metadata.name : ''; + const description = typeof metadata.description === 'string' ? metadata.description.trim() : ''; + 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) { + 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}`)); + } + } + + 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 externalByContent = new Map(); + for (const [targetName, target] of Object.entries(device?.targets || {})) { + const root = expandHome(target.scan_path || target.path); + const detected = device.detected?.[targetName]; + const discovered = Array.isArray(detected) + ? detected.map((skill) => ({ name: skill.name, path: path.resolve(root, skill.path) })) + : await discoverSkillFolders(root).catch(() => []); + for (const skill of discovered) { + const hash = await hashDirectory(skill.path).catch(() => null); + const audited = await auditSkillFolder(skill.path); + if (!descriptionsByTarget.has(targetName)) descriptionsByTarget.set(targetName, new Map()); + descriptionsByTarget.get(targetName).set(skill.name, audited.descriptionLength); + if (!hash || hash !== vaultHashes.get(skill.name)) { + const key = `${skill.name}\0${hash || skill.path}`; + const existing = externalByContent.get(key); + if (existing) existing.targets.push(targetName); + else externalByContent.set(key, { ...audited, targets: [targetName] }); + } + if (!copiesByName.has(skill.name)) copiesByName.set(skill.name, []); + copiesByName.get(skill.name).push({ target: targetName, path: skill.path, hash }); + } + } + + const duplicates = []; + for (const [name, copies] of copiesByName) { + if (copies.length < 2) continue; + const hashes = new Set(copies.map((copy) => copy.hash).filter(Boolean)); + duplicates.push({ + name, + status: hashes.size <= 1 ? 'identical' : 'conflicting', + copies: copies.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 = (device.detected?.[targetName] || []).map((skill) => skill.name); + 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/src/core/device.js b/src/core/device.js index 351ea98..06f2728 100644 --- a/src/core/device.js +++ b/src/core/device.js @@ -741,8 +741,10 @@ export async function setSkillTargets({ vaultPath, deviceId = defaultDeviceId(), const registry = await loadRegistry(vaultPath); if (!registry.skills[skillName]) throw new Error(`Skill not found in vault: ${skillName}`); const device = await loadDevice(vaultPath, deviceId); - const requestedTargets = targets?.length ? targets : Object.keys(device.targets); - const { selectedTargets, wantsGlobalInstall } = await validateRequestedTargets(device, requestedTargets); + const requestedTargets = targets === undefined ? Object.keys(device.targets) : targets; + const { selectedTargets, wantsGlobalInstall } = requestedTargets.length + ? await validateRequestedTargets(device, requestedTargets) + : { selectedTargets: [], wantsGlobalInstall: false }; const previousTargets = device.installed[skillName] || []; const previousGlobal = (device.global_installed || []).includes(skillName); const changed = JSON.stringify(previousTargets) !== JSON.stringify(selectedTargets) diff --git a/src/core/packs.js b/src/core/packs.js new file mode 100644 index 0000000..bb43130 --- /dev/null +++ b/src/core/packs.js @@ -0,0 +1,28 @@ +import { skillAssignmentTargets } from './matrix.js'; + +export function planPackApplication({ device, skills, targets, exact = false }) { + const packSkills = new Set(skills); + const selectedTargets = new Set(targets); + const names = new Set([ + ...skills, + ...Object.keys(device.installed || {}), + ...(device.global_installed || []), + ]); + const changes = []; + + for (const skillName of names) { + const before = skillAssignmentTargets(device, skillName); + const after = new Set(before); + if (packSkills.has(skillName)) { + for (const target of selectedTargets) after.add(target); + } else if (exact) { + for (const target of selectedTargets) after.delete(target); + } + const normalizedAfter = [...after].sort(); + if (JSON.stringify(before) !== JSON.stringify(normalizedAfter)) { + changes.push({ skillName, before, after: normalizedAfter }); + } + } + + return changes.sort((a, b) => a.skillName.localeCompare(b.skillName)); +} diff --git a/src/core/registry.js b/src/core/registry.js index 700994b..3ae2512 100644 --- a/src/core/registry.js +++ b/src/core/registry.js @@ -105,6 +105,20 @@ export async function validateSkillFolder(sourcePath) { } } +function normalizeSourceMetadata(source) { + if (!source?.url) return undefined; + const subpath = source.subpath || '.'; + if (path.isAbsolute(subpath) || subpath.split(/[\\/]/).includes('..')) { + throw new Error(`Skill source subpath must stay inside its repository: ${subpath}`); + } + return { + url: String(source.url), + ...(source.ref ? { ref: String(source.ref) } : {}), + ...(source.commit ? { commit: String(source.commit) } : {}), + subpath, + }; +} + export async function compareSkillToVault({ vaultPath, sourcePath, name }) { await ensureVault(vaultPath); await validateSkillFolder(sourcePath); @@ -128,22 +142,25 @@ export async function compareSkillToVault({ vaultPath, sourcePath, name }) { }; } -export async function addSkillToVault({ vaultPath, sourcePath, name, overwrite = false }) { +export async function addSkillToVault({ vaultPath, sourcePath, name, overwrite = false, source }) { const comparison = await compareSkillToVault({ vaultPath, sourcePath, name }); if (comparison.status === 'different' && !overwrite) { throw new Error(`Skill already exists in vault with different content: ${comparison.name}`); } if (comparison.status === 'identical') { const registry = await loadRegistry(vaultPath); - if (!registry.skills[comparison.name] || registry.skills[comparison.name].hash !== comparison.vaultHash) { - registry.skills[comparison.name] = await registryEntryForSkill(vaultPath, comparison.name); + const sourceMetadata = normalizeSourceMetadata(source) || registry.skills[comparison.name]?.source; + if (!registry.skills[comparison.name] + || registry.skills[comparison.name].hash !== comparison.vaultHash + || JSON.stringify(registry.skills[comparison.name].source) !== JSON.stringify(sourceMetadata)) { + registry.skills[comparison.name] = await registryEntryForSkill(vaultPath, comparison.name, { source: sourceMetadata }); await saveRegistry(vaultPath, registry); } return { name: comparison.name, path: comparison.path, status: 'identical' }; } await copyDir(sourcePath, comparison.path); const registry = await loadRegistry(vaultPath); - registry.skills[comparison.name] = await registryEntryForSkill(vaultPath, comparison.name); + registry.skills[comparison.name] = await registryEntryForSkill(vaultPath, comparison.name, { source }); await saveRegistry(vaultPath, registry); return { name: comparison.name, @@ -155,24 +172,30 @@ export async function addSkillToVault({ vaultPath, sourcePath, name, overwrite = export async function ensureSkillInRegistry(vaultPath, skillName) { assertSafePathSegment(skillName, 'Skill name'); const registry = await loadRegistry(vaultPath); - registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName); + registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName, { + source: registry.skills[skillName]?.source, + }); await saveRegistry(vaultPath, registry); return registry.skills[skillName]; } -export async function registryEntryForSkill(vaultPath, skillName) { +export async function registryEntryForSkill(vaultPath, skillName, { source } = {}) { assertSafePathSegment(skillName, 'Skill name'); const skillPath = path.join(vaultPath, 'skills', skillName); await validateSkillFolder(skillPath); - return { + const entry = { path: path.posix.join('skills', skillName), hash: await hashDirectory(skillPath), updated_at: new Date().toISOString(), }; + const sourceMetadata = normalizeSourceMetadata(source); + if (sourceMetadata) entry.source = sourceMetadata; + return entry; } export async function rebuildRegistry(vaultPath) { await ensureVault(vaultPath); + const previous = await loadRegistry(vaultPath); const skillsDir = path.join(vaultPath, 'skills'); const entries = await readdir(skillsDir, { withFileTypes: true }); const registry = emptyRegistry(); @@ -180,7 +203,9 @@ export async function rebuildRegistry(vaultPath) { if (!entry.isDirectory()) continue; const skillName = assertSafePathSegment(entry.name, 'Skill name'); if (!await exists(path.join(skillsDir, skillName, 'SKILL.md'))) continue; - registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName); + registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName, { + source: previous.skills[skillName]?.source, + }); } await saveRegistry(vaultPath, registry); return registry; diff --git a/src/core/source.js b/src/core/source.js index f69c115..016e7e7 100644 --- a/src/core/source.js +++ b/src/core/source.js @@ -45,6 +45,17 @@ export function cloneUrlForSource(rawSource) { return { cloneUrl, ref }; } +function sanitizedSourceUrl(cloneUrl) { + try { + const url = new URL(cloneUrl); + url.username = ''; + url.password = ''; + return url.toString(); + } catch { + return cloneUrl; + } +} + export async function cloneSkillSource(rawSource) { const { cloneUrl, ref } = cloneUrlForSource(rawSource); const dir = await mkdtemp(path.join(tmpdir(), 'skillsync-source-')); @@ -53,8 +64,12 @@ export async function cloneSkillSource(rawSource) { args.push(cloneUrl, dir); try { await git(args); + const { stdout } = await git(['rev-parse', 'HEAD'], dir); return { path: dir, + sourceUrl: sanitizedSourceUrl(cloneUrl), + ref, + commit: stdout.trim(), cleanup: () => rm(dir, { recursive: true, force: true }), }; } catch (error) { diff --git a/src/core/update.js b/src/core/update.js new file mode 100644 index 0000000..705b02a --- /dev/null +++ b/src/core/update.js @@ -0,0 +1,56 @@ +import path from 'node:path'; + +import { hashDirectory } from './fs.js'; +import { addSkillToVault, loadRegistry, validateSkillFolder } from './registry.js'; +import { cloneSkillSource } from './source.js'; + +function sourceSpecifier(source) { + return `${source.url}${source.ref ? `#${source.ref}` : ''}`; +} + +export async function inspectSkillUpdate({ vaultPath, skillName, apply = false }) { + const registry = await loadRegistry(vaultPath); + const entry = registry.skills[skillName]; + if (!entry) throw new Error(`Skill not found in vault: ${skillName}`); + if (!entry.source?.url) return { skillName, status: 'untracked' }; + + const cloned = await cloneSkillSource(sourceSpecifier(entry.source)); + try { + const sourcePath = path.resolve(cloned.path, entry.source.subpath || '.'); + const cloneRoot = path.resolve(cloned.path); + if (sourcePath !== cloneRoot && !sourcePath.startsWith(`${cloneRoot}${path.sep}`)) { + throw new Error(`Stored source path escapes the repository: ${entry.source.subpath}`); + } + await validateSkillFolder(sourcePath); + const remoteHash = await hashDirectory(sourcePath); + if (remoteHash === entry.hash) { + return { skillName, status: 'current', commit: cloned.commit }; + } + if (!apply) { + return { + skillName, + status: 'available', + currentCommit: entry.source.commit, + availableCommit: cloned.commit, + }; + } + await addSkillToVault({ + vaultPath, + sourcePath, + name: skillName, + overwrite: true, + source: { + ...entry.source, + commit: cloned.commit, + }, + }); + return { + skillName, + status: 'updated', + previousCommit: entry.source.commit, + commit: cloned.commit, + }; + } finally { + await cloned.cleanup(); + } +} diff --git a/test/catalog.test.js b/test/catalog.test.js new file mode 100644 index 0000000..df1d157 --- /dev/null +++ b/test/catalog.test.js @@ -0,0 +1,139 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { auditCatalog, auditSkillFolder } from '../src/core/audit.js'; +import { addTarget, installSkill, loadDevice, setSkillTargets } from '../src/core/device.js'; +import { git } from '../src/core/git.js'; +import { planPackApplication } from '../src/core/packs.js'; +import { addSkillToVault, loadRegistry, rebuildRegistry } from '../src/core/registry.js'; +import { inspectSkillUpdate } from '../src/core/update.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 portable metadata and catalog size', 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('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 device = { + targets: { + codex: { path: codex }, + claude: { path: claude }, + }, + installed: { review: ['codex', 'claude'] }, + detected: { + codex: [{ name: 'review', path: 'review' }], + claude: [{ name: 'review', path: 'review' }], + }, + }; + const result = await auditCatalog({ vaultPath: vault, device }); + + 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('planPackApplication can exactly reconcile selected targets while preserving others', () => { + const device = { + installed: { + review: ['claude'], + legacy: ['claude', 'codex'], + }, + global_installed: ['global-helper'], + }; + + const changes = planPackApplication({ + device, + skills: ['review'], + targets: ['claude'], + exact: true, + }); + + assert.deepEqual(changes, [ + { skillName: 'legacy', before: ['claude', 'codex'], after: ['codex'] }, + ]); +}); + +test('setSkillTargets accepts an empty exact assignment', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const target = path.join(root, 'codex'); + await makeSkill(path.join(vault, 'skills'), 'review'); + await rebuildRegistry(vault); + await addTarget({ vaultPath: vault, deviceId: 'test', name: 'codex', targetPath: target }); + await installSkill({ vaultPath: vault, deviceId: 'test', skillName: 'review', targets: ['codex'] }); + + await setSkillTargets({ vaultPath: vault, deviceId: 'test', skillName: 'review', targets: [] }); + + const device = await loadDevice(vault, 'test'); + assert.equal(device.installed.review, undefined); +}); + +test('source provenance survives registry rebuild and supports explicit updates', async () => { + const root = await tempDir(); + const sourceRepo = path.join(root, 'source'); + const sourceSkill = await makeSkill(path.join(sourceRepo, 'skills'), 'tracked', 'Track upstream changes.', '# Version one\n'); + await git(['init', '--initial-branch=main'], sourceRepo); + await git(['config', 'user.email', 'test@example.com'], sourceRepo); + await git(['config', 'user.name', 'SkillSync Test'], sourceRepo); + await git(['add', '.'], sourceRepo); + await git(['commit', '-m', 'initial'], sourceRepo); + const firstCommit = (await git(['rev-parse', 'HEAD'], sourceRepo)).stdout.trim(); + const sourceUrl = pathToFileURL(sourceRepo).href; + const vault = path.join(root, 'vault'); + + await addSkillToVault({ + vaultPath: vault, + sourcePath: sourceSkill, + source: { url: sourceUrl, ref: 'main', commit: firstCommit, subpath: 'skills/tracked' }, + }); + await rebuildRegistry(vault); + assert.equal((await loadRegistry(vault)).skills.tracked.source.commit, firstCommit); + assert.equal((await inspectSkillUpdate({ vaultPath: vault, skillName: 'tracked' })).status, 'current'); + + await writeFile(path.join(sourceSkill, 'SKILL.md'), '---\nname: tracked\ndescription: Track upstream changes.\n---\n# Version two\n'); + await git(['add', '.'], sourceRepo); + await git(['commit', '-m', 'update'], sourceRepo); + const secondCommit = (await git(['rev-parse', 'HEAD'], sourceRepo)).stdout.trim(); + + const available = await inspectSkillUpdate({ vaultPath: vault, skillName: 'tracked' }); + assert.equal(available.status, 'available'); + assert.equal(available.availableCommit, secondCommit); + + const updated = await inspectSkillUpdate({ vaultPath: vault, skillName: 'tracked', apply: true }); + assert.equal(updated.status, 'updated'); + assert.equal((await loadRegistry(vault)).skills.tracked.source.commit, secondCommit); + assert.match(await readFile(path.join(vault, 'skills', 'tracked', 'SKILL.md'), 'utf8'), /Version two/); +}); diff --git a/test/cli.test.js b/test/cli.test.js index dd4224f..9b4949b 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -114,6 +114,56 @@ test('installed command marks missing managed projections', async () => { assert.match(stdout, /codex: .*paper-mcp \(missing; run skillsync sync\)/); }); +test('audit --json reports standards findings and active catalog cost', 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'] }); + + 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.ok(result.targets.codex.estimatedDescriptionTokens > 0); +}); + +test('pack apply previews by default and exactly reconciles only with --apply', 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'); + await makeSkill(path.join(vault, 'skills'), 'legacy'); + await rebuildRegistry(vault); + await mkdir(path.join(vault, 'packs'), { recursive: true }); + await writeFile(path.join(vault, 'packs', 'core.json'), JSON.stringify({ name: 'core', skills: ['review'] })); + await addTarget({ vaultPath: vault, deviceId, name: 'codex', targetPath: target }); + await installSkill({ vaultPath: vault, deviceId, skillName: 'legacy', targets: ['codex'] }); + + const preview = await execFileAsync(process.execPath, [ + path.resolve('src/cli.js'), 'pack', 'apply', 'core', '--target', 'codex', '--exact', + ], { cwd: path.resolve('.'), env: cliEnv(home) }); + assert.match(preview.stdout, /Dry run only/); + assert.deepEqual((await loadDevice(vault, deviceId)).installed.legacy, ['codex']); + + await execFileAsync(process.execPath, [ + path.resolve('src/cli.js'), 'pack', 'apply', 'core', '--target', 'codex', '--exact', '--apply', + ], { cwd: path.resolve('.'), env: cliEnv(home) }); + const device = await loadDevice(vault, deviceId); + assert.deepEqual(device.installed.review, ['codex']); + assert.equal(device.installed.legacy, undefined); +}); + test('install --device records a pending assignment without touching remote paths', async () => { const home = await tempDir(); const vault = path.join(home, '.skillsync', 'repo'); From f0b6546ac2a96a80a103263874dcfc5fd519564e Mon Sep 17 00:00:00 2001 From: Josh Kennedy Date: Sun, 2 Aug 2026 09:08:09 -0500 Subject: [PATCH 2/8] feat: safely consolidate duplicate skills --- README.md | 8 ++++ src/cli.js | 30 ++++++++++++++- src/core/cleanup.js | 92 +++++++++++++++++++++++++++++++++++++++++++++ test/cli.test.js | 34 +++++++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/core/cleanup.js diff --git a/README.md b/README.md index 70c80c2..066812e 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,13 @@ skillsync audit --json `skillsync doctor` includes the catalog summary. Audit errors cover malformed frontmatter, non-portable names, directory/name mismatches, missing descriptions, and specification limits. Longer-but-valid descriptions and oversized skill bodies are warnings. +Consolidate byte-identical copies across configured targets into one vault skill and managed projections. Cleanup previews by default and leaves same-name conflicts untouched: + +```bash +skillsync cleanup +skillsync cleanup --apply +``` + ## Reconcile an exact skill pack Pack application previews changes by default. `--exact` removes SkillSync assignments for skills outside the pack on only the selected targets; unmanaged local folders and assignments on other targets remain untouched. @@ -347,6 +354,7 @@ skillsync setup [--name skills] [--repo owner/repo|url] [--path path] [--yes] skillsync connect [--path path] skillsync status skillsync audit [--json] +skillsync cleanup [--apply] skillsync list skillsync installed [--device id] skillsync matrix [--edit] diff --git a/src/cli.js b/src/cli.js index 51d266e..9b6b2ff 100755 --- a/src/cli.js +++ b/src/cli.js @@ -6,6 +6,7 @@ import path from 'node:path'; import { loadConfig, saveConfig, defaultRepoPath } from './core/config.js'; import { auditCatalog } from './core/audit.js'; +import { applyDuplicateCleanup, planDuplicateCleanup } from './core/cleanup.js'; import { addTarget, applyLinks, @@ -17,6 +18,7 @@ import { managedProjections, migrateLegacyLocalPathState, removeTargetAndPrune, + scanTargets, setDeviceAutoImport, setGlobalInstructionsProfile, setSkillTargets, @@ -99,6 +101,8 @@ async function main() { return status(); case 'audit': return auditCommand(rest); + case 'cleanup': + return cleanupCommand(rest); case 'list': return listSkills(); case 'installed': @@ -1368,6 +1372,30 @@ async function auditCommand(rest) { if (result.summary.errors || result.summary.conflictingDuplicates) process.exitCode = 1; } +async function cleanupCommand(rest) { + const config = await configured(); + const apply = hasFlag(rest, '--apply'); + await scanTargets({ vaultPath: config.repoPath, deviceId: config.deviceId }); + const device = await loadLocalDevice(config.repoPath, config.deviceId); + const plan = await planDuplicateCleanup({ vaultPath: config.repoPath, device }); + + if (!plan.actions.length) console.log('No identical unmanaged duplicates to clean.'); + for (const action of plan.actions) { + console.log(`${apply ? '✓' : '!'} ${action.name}: ${action.paths.length} duplicate${action.paths.length === 1 ? '' : 's'} -> vault (${action.targets.join(', ')})`); + } + for (const conflict of plan.conflicts) { + console.log(`✗ ${conflict.name}: conflicting copies left unchanged${conflict.reason ? ` (${conflict.reason})` : ''}`); + } + if (!apply || !plan.actions.length) { + if (plan.actions.length) console.log('\nPreview only. Run skillsync cleanup --apply to make these changes.'); + return; + } + + await applyDuplicateCleanup({ vaultPath: config.repoPath, deviceId: config.deviceId, plan }); + await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); + console.log(`\nCleaned ${plan.actions.length} duplicate skill${plan.actions.length === 1 ? '' : 's'}.`); +} + async function updateCommand(rest) { const config = await configured(); const requested = rest[0] && !rest[0].startsWith('-') ? rest[0] : null; @@ -2680,5 +2708,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 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 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 pack apply --target targets [--exact] [--apply]\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 update [skill] [--check] [--apply]\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\n skillsync sync\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 cleanup [--apply]\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 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 pack apply --target targets [--exact] [--apply]\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 update [skill] [--check] [--apply]\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\n skillsync sync\n skillsync service install\n skillsync doctor\n skillsync daemon\n`); } diff --git a/src/core/cleanup.js b/src/core/cleanup.js new file mode 100644 index 0000000..cac40bf --- /dev/null +++ b/src/core/cleanup.js @@ -0,0 +1,92 @@ +import { lstat } from 'node:fs/promises'; +import path from 'node:path'; + +import { applyLinks, installSkill, managedProjections, scanTargets } from './device.js'; +import { exists, expandHome, hashDirectory, removePath } from './fs.js'; +import { addSkillToVault } from './registry.js'; + +export async function planDuplicateCleanup({ vaultPath, device }) { + const managed = new Set((await managedProjections({ vaultPath, deviceId: device.device_id })) + .filter((projection) => projection.status === 'ok') + .map((projection) => `${projection.targetName}\0${projection.skillName}`)); + const copiesByName = new Map(); + + for (const [targetName, target] of Object.entries(device.targets || {})) { + const root = path.resolve(expandHome(target.scan_path || target.path)); + const installRoot = path.resolve(expandHome(target.path)); + for (const skill of device.detected?.[targetName] || []) { + const copyPath = path.resolve(root, skill.path); + if (copyPath !== path.join(installRoot, skill.name)) continue; + if (!copiesByName.has(skill.name)) copiesByName.set(skill.name, []); + copiesByName.get(skill.name).push({ target: targetName, path: copyPath }); + } + } + + const actions = []; + const conflicts = []; + for (const [name, copies] of copiesByName) { + if (copies.length < 2) continue; + const vaultSkill = path.join(vaultPath, 'skills', name); + const vaultExists = await exists(vaultSkill); + const unmanaged = copies.filter((copy) => !managed.has(`${copy.target}\0${name}`)); + if (!unmanaged.length) continue; + + const hashes = new Set(); + if (vaultExists) hashes.add(await hashDirectory(vaultSkill)); + for (const copy of unmanaged) hashes.add(await hashDirectory(copy.path)); + if (hashes.size !== 1) { + conflicts.push({ name, copies }); + continue; + } + + let source = vaultExists ? vaultSkill : null; + if (!source) { + for (const copy of unmanaged) { + const info = await lstat(copy.path); + if (info.isDirectory() && !info.isSymbolicLink()) { + source = copy.path; + break; + } + } + } + if (!source) { + conflicts.push({ name, copies, reason: 'No standalone directory is available as the canonical source' }); + continue; + } + + actions.push({ + name, + source, + targets: [...new Set(copies.map((copy) => copy.target))].sort(), + paths: unmanaged.map((copy) => copy.path), + }); + } + + return { + actions: actions.sort((a, b) => a.name.localeCompare(b.name)), + conflicts: conflicts.sort((a, b) => a.name.localeCompare(b.name)), + }; +} + +export async function applyDuplicateCleanup({ vaultPath, deviceId, plan }) { + for (const action of plan.actions) { + await addSkillToVault({ vaultPath, sourcePath: action.source, name: action.name }); + const vaultHash = await hashDirectory(path.join(vaultPath, 'skills', action.name)); + for (const copyPath of action.paths) { + if (await hashDirectory(copyPath) !== vaultHash) { + throw new Error(`Refusing to clean changed skill: ${copyPath}`); + } + } + const paths = await Promise.all(action.paths.map(async (copyPath) => ({ + path: copyPath, + symlink: (await lstat(copyPath)).isSymbolicLink(), + }))); + for (const copy of paths.sort((a, b) => Number(b.symlink) - Number(a.symlink))) { + await removePath(copy.path); + } + await installSkill({ vaultPath, deviceId, skillName: action.name, targets: action.targets }); + } + await applyLinks({ vaultPath, deviceId }); + await scanTargets({ vaultPath, deviceId }); + return plan.actions.map((action) => action.name); +} diff --git a/test/cli.test.js b/test/cli.test.js index 9b4949b..b2bab9f 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -136,6 +136,40 @@ test('audit --json reports standards findings and active catalog cost', async () assert.ok(result.targets.codex.estimatedDescriptionTokens > 0); }); +test('cleanup previews then consolidates identical target copies', async () => { + const home = await tempDir(); + const vault = path.join(home, '.skillsync', 'repo'); + const agents = path.join(home, '.agents', 'skills'); + const codex = path.join(home, '.codex', 'skills'); + const deviceId = 'test-device'; + await writeConfig(home, vault, deviceId); + await makeSkill(agents, 'review', '# Identical\n'); + await makeSkill(codex, 'review', '# Identical\n'); + await makeSkill(agents, 'draft', '# Agents version\n'); + await makeSkill(codex, 'draft', '# Codex version\n'); + await addTarget({ vaultPath: vault, deviceId, name: 'agents', targetPath: agents }); + await addTarget({ vaultPath: vault, deviceId, name: 'codex', targetPath: codex }); + + const preview = await execFileAsync(process.execPath, [path.resolve('src/cli.js'), 'cleanup'], { + cwd: path.resolve('.'), + env: cliEnv(home), + }); + assert.match(preview.stdout, /Preview only/); + assert.match(preview.stdout, /draft: conflicting copies left unchanged/); + assert.equal((await lstat(path.join(agents, 'review'))).isSymbolicLink(), false); + + const applied = await execFileAsync(process.execPath, [path.resolve('src/cli.js'), 'cleanup', '--apply'], { + cwd: path.resolve('.'), + env: cliEnv(home), + }); + assert.match(applied.stdout, /Cleaned 1 duplicate skill/); + assert.equal((await lstat(path.join(agents, 'review'))).isSymbolicLink(), true); + assert.equal((await lstat(path.join(codex, 'review'))).isSymbolicLink(), true); + assert.equal((await lstat(path.join(agents, 'draft'))).isSymbolicLink(), false); + assert.equal((await lstat(path.join(codex, 'draft'))).isSymbolicLink(), false); + assert.ok((await loadRegistry(vault)).skills.review); +}); + test('pack apply previews by default and exactly reconciles only with --apply', async () => { const home = await tempDir(); const vault = path.join(home, '.skillsync', 'repo'); From 66c1080d8f58f8cf26db2b68d19487c2159ccc66 Mon Sep 17 00:00:00 2001 From: Josh Kennedy Date: Sun, 2 Aug 2026 09:14:07 -0500 Subject: [PATCH 3/8] feat: adopt complete local skill catalogs --- README.md | 6 +++++- src/cli.js | 12 +++++++----- src/core/cleanup.js | 9 ++++----- test/cli.test.js | 11 +++++++++-- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 066812e..ace9126 100644 --- a/README.md +++ b/README.md @@ -212,8 +212,12 @@ Consolidate byte-identical copies across configured targets into one vault skill ```bash skillsync cleanup skillsync cleanup --apply +skillsync cleanup --all +skillsync cleanup --all --apply ``` +`--all` also adopts unique unmanaged skills found under configured scan paths, preserving their current target assignments. + ## Reconcile an exact skill pack Pack application previews changes by default. `--exact` removes SkillSync assignments for skills outside the pack on only the selected targets; unmanaged local folders and assignments on other targets remain untouched. @@ -354,7 +358,7 @@ skillsync setup [--name skills] [--repo owner/repo|url] [--path path] [--yes] skillsync connect [--path path] skillsync status skillsync audit [--json] -skillsync cleanup [--apply] +skillsync cleanup [--all] [--apply] skillsync list skillsync installed [--device id] skillsync matrix [--edit] diff --git a/src/cli.js b/src/cli.js index 9b6b2ff..555a587 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1375,25 +1375,27 @@ async function auditCommand(rest) { async function cleanupCommand(rest) { const config = await configured(); const apply = hasFlag(rest, '--apply'); + const includeUnique = hasFlag(rest, '--all'); await scanTargets({ vaultPath: config.repoPath, deviceId: config.deviceId }); const device = await loadLocalDevice(config.repoPath, config.deviceId); - const plan = await planDuplicateCleanup({ vaultPath: config.repoPath, device }); + const plan = await planDuplicateCleanup({ vaultPath: config.repoPath, device, includeUnique }); if (!plan.actions.length) console.log('No identical unmanaged duplicates to clean.'); for (const action of plan.actions) { - console.log(`${apply ? '✓' : '!'} ${action.name}: ${action.paths.length} duplicate${action.paths.length === 1 ? '' : 's'} -> vault (${action.targets.join(', ')})`); + const operation = action.paths.length > 1 ? `${action.paths.length} copies` : '1 unmanaged skill'; + console.log(`${apply ? '✓' : '!'} ${action.name}: ${operation} -> vault (${action.targets.join(', ')})`); } for (const conflict of plan.conflicts) { console.log(`✗ ${conflict.name}: conflicting copies left unchanged${conflict.reason ? ` (${conflict.reason})` : ''}`); } if (!apply || !plan.actions.length) { - if (plan.actions.length) console.log('\nPreview only. Run skillsync cleanup --apply to make these changes.'); + if (plan.actions.length) console.log(`\nPreview only. Run skillsync cleanup${includeUnique ? ' --all' : ''} --apply to make these changes.`); return; } await applyDuplicateCleanup({ vaultPath: config.repoPath, deviceId: config.deviceId, plan }); await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); - console.log(`\nCleaned ${plan.actions.length} duplicate skill${plan.actions.length === 1 ? '' : 's'}.`); + console.log(`\nCanonicalized ${plan.actions.length} skill${plan.actions.length === 1 ? '' : 's'}.`); } async function updateCommand(rest) { @@ -2708,5 +2710,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 audit [--json]\n skillsync cleanup [--apply]\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 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 pack apply --target targets [--exact] [--apply]\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 update [skill] [--check] [--apply]\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\n skillsync sync\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 cleanup [--all] [--apply]\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 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 pack apply --target targets [--exact] [--apply]\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 update [skill] [--check] [--apply]\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\n skillsync sync\n skillsync service install\n skillsync doctor\n skillsync daemon\n`); } diff --git a/src/core/cleanup.js b/src/core/cleanup.js index cac40bf..9ae2e04 100644 --- a/src/core/cleanup.js +++ b/src/core/cleanup.js @@ -1,11 +1,11 @@ -import { lstat } from 'node:fs/promises'; +import { lstat, realpath } from 'node:fs/promises'; import path from 'node:path'; import { applyLinks, installSkill, managedProjections, scanTargets } from './device.js'; import { exists, expandHome, hashDirectory, removePath } from './fs.js'; import { addSkillToVault } from './registry.js'; -export async function planDuplicateCleanup({ vaultPath, device }) { +export async function planDuplicateCleanup({ vaultPath, device, includeUnique = false }) { const managed = new Set((await managedProjections({ vaultPath, deviceId: device.device_id })) .filter((projection) => projection.status === 'ok') .map((projection) => `${projection.targetName}\0${projection.skillName}`)); @@ -13,10 +13,8 @@ export async function planDuplicateCleanup({ vaultPath, device }) { for (const [targetName, target] of Object.entries(device.targets || {})) { const root = path.resolve(expandHome(target.scan_path || target.path)); - const installRoot = path.resolve(expandHome(target.path)); for (const skill of device.detected?.[targetName] || []) { const copyPath = path.resolve(root, skill.path); - if (copyPath !== path.join(installRoot, skill.name)) continue; if (!copiesByName.has(skill.name)) copiesByName.set(skill.name, []); copiesByName.get(skill.name).push({ target: targetName, path: copyPath }); } @@ -25,7 +23,7 @@ export async function planDuplicateCleanup({ vaultPath, device }) { const actions = []; const conflicts = []; for (const [name, copies] of copiesByName) { - if (copies.length < 2) continue; + if (!includeUnique && copies.length < 2) continue; const vaultSkill = path.join(vaultPath, 'skills', name); const vaultExists = await exists(vaultSkill); const unmanaged = copies.filter((copy) => !managed.has(`${copy.target}\0${name}`)); @@ -48,6 +46,7 @@ export async function planDuplicateCleanup({ vaultPath, device }) { break; } } + if (!source) source = await realpath(unmanaged[0].path); } if (!source) { conflicts.push({ name, copies, reason: 'No standalone directory is available as the canonical source' }); diff --git a/test/cli.test.js b/test/cli.test.js index b2bab9f..cca58b2 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -139,6 +139,8 @@ test('audit --json reports standards findings and active catalog cost', async () test('cleanup previews then consolidates identical target copies', async () => { const home = await tempDir(); const vault = path.join(home, '.skillsync', 'repo'); + const hermesRoot = path.join(home, '.hermes', 'skills'); + const hermes = path.join(hermesRoot, 'personal'); const agents = path.join(home, '.agents', 'skills'); const codex = path.join(home, '.codex', 'skills'); const deviceId = 'test-device'; @@ -147,6 +149,8 @@ test('cleanup previews then consolidates identical target copies', async () => { await makeSkill(codex, 'review', '# Identical\n'); await makeSkill(agents, 'draft', '# Agents version\n'); await makeSkill(codex, 'draft', '# Codex version\n'); + await makeSkill(path.join(hermesRoot, 'creative'), 'unique', '# Unique\n'); + await addTarget({ vaultPath: vault, deviceId, name: 'hermes', targetPath: hermes, scanPath: hermesRoot }); await addTarget({ vaultPath: vault, deviceId, name: 'agents', targetPath: agents }); await addTarget({ vaultPath: vault, deviceId, name: 'codex', targetPath: codex }); @@ -158,16 +162,19 @@ test('cleanup previews then consolidates identical target copies', async () => { assert.match(preview.stdout, /draft: conflicting copies left unchanged/); assert.equal((await lstat(path.join(agents, 'review'))).isSymbolicLink(), false); - const applied = await execFileAsync(process.execPath, [path.resolve('src/cli.js'), 'cleanup', '--apply'], { + const applied = await execFileAsync(process.execPath, [path.resolve('src/cli.js'), 'cleanup', '--all', '--apply'], { cwd: path.resolve('.'), env: cliEnv(home), }); - assert.match(applied.stdout, /Cleaned 1 duplicate skill/); + assert.match(applied.stdout, /Canonicalized 2 skills/); assert.equal((await lstat(path.join(agents, 'review'))).isSymbolicLink(), true); assert.equal((await lstat(path.join(codex, 'review'))).isSymbolicLink(), true); assert.equal((await lstat(path.join(agents, 'draft'))).isSymbolicLink(), false); assert.equal((await lstat(path.join(codex, 'draft'))).isSymbolicLink(), false); assert.ok((await loadRegistry(vault)).skills.review); + assert.ok((await loadRegistry(vault)).skills.unique); + await assert.rejects(() => lstat(path.join(hermesRoot, 'creative', 'unique'))); + assert.equal((await lstat(path.join(hermes, 'unique'))).isSymbolicLink(), true); }); test('pack apply previews by default and exactly reconciles only with --apply', async () => { From d817d0997832010d67c5f8cb1eb343a854f9b6eb Mon Sep 17 00:00:00 2001 From: Josh Kennedy Date: Sun, 2 Aug 2026 09:15:42 -0500 Subject: [PATCH 4/8] fix: preserve directory symlinks during migration --- src/core/device.js | 2 +- src/core/fs.js | 8 +++++--- test/cli.test.js | 5 ++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/core/device.js b/src/core/device.js index 06f2728..3f61722 100644 --- a/src/core/device.js +++ b/src/core/device.js @@ -1167,7 +1167,7 @@ async function createCopyProjection(source, destination, skillName, vaultPath) { throw new Error(`Refusing to overwrite unmanaged target path: ${destination}`); } } - await cp(source, destination, { recursive: true, force: true, dereference: false }); + await cp(source, destination, { recursive: true, force: true, dereference: false, verbatimSymlinks: true }); await writeFile(path.join(destination, '.skillsync-owned.json'), JSON.stringify({ skill: skillName, vault: vaultPath }, null, 2)); } diff --git a/src/core/fs.js b/src/core/fs.js index 741b822..7228d80 100644 --- a/src/core/fs.js +++ b/src/core/fs.js @@ -1,4 +1,4 @@ -import { chmod, cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { chmod, cp, lstat, mkdir, readFile, readlink, rename, rm, stat, writeFile } from 'node:fs/promises'; import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import path from 'node:path'; @@ -58,7 +58,7 @@ export async function writePrivateJson(filePath, value) { export async function copyDir(source, destination) { await rm(destination, { recursive: true, force: true }); await ensureDir(path.dirname(destination)); - await cp(source, destination, { recursive: true, force: true, dereference: false }); + await cp(source, destination, { recursive: true, force: true, dereference: false, verbatimSymlinks: true }); } export async function removePath(targetPath) { @@ -122,7 +122,9 @@ export async function hashDirectory(dirPath) { for (const relativePath of files) { hash.update(relativePath); hash.update('\0'); - hash.update(await readFile(path.join(dirPath, relativePath))); + const filePath = path.join(dirPath, relativePath); + const info = await lstat(filePath); + hash.update(info.isSymbolicLink() ? await readlink(filePath) : await readFile(filePath)); hash.update('\0'); } return `sha256:${hash.digest('hex')}`; diff --git a/test/cli.test.js b/test/cli.test.js index cca58b2..df12c33 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -149,7 +149,10 @@ test('cleanup previews then consolidates identical target copies', async () => { await makeSkill(codex, 'review', '# Identical\n'); await makeSkill(agents, 'draft', '# Agents version\n'); await makeSkill(codex, 'draft', '# Codex version\n'); - await makeSkill(path.join(hermesRoot, 'creative'), 'unique', '# Unique\n'); + const unique = await makeSkill(path.join(hermesRoot, 'creative'), 'unique', '# Unique\n'); + await mkdir(path.join(unique, 'assets')); + await writeFile(path.join(unique, 'assets', 'example.txt'), 'example\n'); + await symlink('assets', path.join(unique, 'assets-link'), 'dir'); await addTarget({ vaultPath: vault, deviceId, name: 'hermes', targetPath: hermes, scanPath: hermesRoot }); await addTarget({ vaultPath: vault, deviceId, name: 'agents', targetPath: agents }); await addTarget({ vaultPath: vault, deviceId, name: 'codex', targetPath: codex }); From 47e1ab45b8fc36d07429df88599e9ab29b5c8c8d Mon Sep 17 00:00:00 2001 From: Josh Kennedy Date: Sun, 2 Aug 2026 09:17:04 -0500 Subject: [PATCH 5/8] fix: allow prototype skill name --- src/core/fs.js | 2 +- test/core.test.js | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/fs.js b/src/core/fs.js index 7228d80..174e418 100644 --- a/src/core/fs.js +++ b/src/core/fs.js @@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import path from 'node:path'; -const RESERVED_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +const RESERVED_OBJECT_KEYS = new Set(['__proto__', 'constructor']); export function expandHome(value) { if (!value) return value; diff --git a/test/core.test.js b/test/core.test.js index e61c080..3b53bec 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -46,6 +46,7 @@ import { uninstallSkillAndPrune, } from '../src/core/device.js'; import { generateGroups } from '../src/core/groups.js'; +import { assertSafePathSegment } from '../src/core/fs.js'; import { applyGlobalInstructions, assignGlobalInstructionsProfile, @@ -1385,6 +1386,10 @@ test('path-like skill names and device IDs are rejected before filesystem access assert.equal(await readFile(path.join(outside, 'keep.txt'), 'utf8'), 'keep'); }); +test('prototype is a valid filesystem-safe skill name', () => { + assert.equal(assertSafePathSegment('prototype', 'Skill name'), 'prototype'); +}); + test('generated pack names cannot escape the vault output folders', async () => { const root = await tempDir(); const vault = path.join(root, 'vault'); From afa83640b406a30a5051bce3fcda64a2538cb810 Mon Sep 17 00:00:00 2001 From: Josh Kennedy Date: Sun, 2 Aug 2026 09:23:31 -0500 Subject: [PATCH 6/8] fix: exclude managed projections from duplicate audit --- src/core/audit.js | 34 ++++++++++++++++++++++++++++------ test/catalog.test.js | 24 +++++++++++++++++++++++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/core/audit.js b/src/core/audit.js index 9cc3942..a2de586 100644 --- a/src/core/audit.js +++ b/src/core/audit.js @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises'; +import { lstat, readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; import { parseDocument } from 'yaml'; @@ -12,6 +12,23 @@ function finding(level, code, message) { return { level, code, message }; } +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 = []; @@ -119,19 +136,24 @@ export async function auditCatalog({ vaultPath, device }) { if (existing) existing.targets.push(targetName); else externalByContent.set(key, { ...audited, targets: [targetName] }); } - if (!copiesByName.has(skill.name)) copiesByName.set(skill.name, []); - copiesByName.get(skill.name).push({ target: targetName, path: skill.path, hash }); + if (!await isManagedProjection(skill.path, vaultPath, skill.name)) { + if (!copiesByName.has(skill.name)) copiesByName.set(skill.name, []); + copiesByName.get(skill.name).push({ target: targetName, path: skill.path, hash }); + } } } const duplicates = []; for (const [name, copies] of copiesByName) { - if (copies.length < 2) continue; - const hashes = new Set(copies.map((copy) => copy.hash).filter(Boolean)); + 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: copies.map(({ target, path: copyPath }) => ({ target, path: copyPath })), + copies: compared.map(({ target, path: copyPath }) => ({ target, path: copyPath })), }); } diff --git a/test/catalog.test.js b/test/catalog.test.js index df1d157..6910c1e 100644 --- a/test/catalog.test.js +++ b/test/catalog.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -65,6 +65,28 @@ test('auditCatalog reports conflicting target copies and active description cost 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: { codex: [{ name: 'review', path: 'review' }] }, + }, + }); + + assert.equal(result.summary.identicalDuplicates, 0); + assert.equal(result.summary.conflictingDuplicates, 0); +}); + test('planPackApplication can exactly reconcile selected targets while preserving others', () => { const device = { installed: { From 0fc7de635c4e14df41993eb053d4b6eb3da6861c Mon Sep 17 00:00:00 2001 From: Josh Kennedy Date: Mon, 3 Aug 2026 08:08:11 -0500 Subject: [PATCH 7/8] feat: search dormant skill packs --- README.md | 8 ++++++ src/cli.js | 24 +++++++++++++++- src/core/find.js | 67 ++++++++++++++++++++++++++++++++++++++++++++ test/catalog.test.js | 20 +++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/core/find.js diff --git a/README.md b/README.md index ace9126..2b4b9fe 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,13 @@ skillsync cleanup --all --apply `--all` also adopts unique unmanaged skills found under configured scan paths, preserving their current target assignments. +Search a pack's skill metadata without installing or loading its full skill bodies: + +```bash +skillsync find "humanize AI writing" --pack cold +skillsync find "debug flaky tests" --pack cold --json +``` + ## Reconcile an exact skill pack Pack application previews changes by default. `--exact` removes SkillSync assignments for skills outside the pack on only the selected targets; unmanaged local folders and assignments on other targets remain untouched. @@ -360,6 +367,7 @@ skillsync status skillsync audit [--json] skillsync cleanup [--all] [--apply] skillsync list +skillsync find [--pack cold] [--limit 5] [--json] skillsync installed [--device id] skillsync matrix [--edit] skillsync instructions status diff --git a/src/cli.js b/src/cli.js index 555a587..ef4c773 100755 --- a/src/cli.js +++ b/src/cli.js @@ -43,6 +43,7 @@ import { } from './core/fs.js'; import { cloneRepo, commandExists, commitAllIfChanged, gh, git, isGitRepo, push, run } from './core/git.js'; import { generateGroups } from './core/groups.js'; +import { findSkills } from './core/find.js'; import { planPackApplication } from './core/packs.js'; import { DEFAULT_CLAUDE_INSTRUCTIONS_PATH, @@ -105,6 +106,8 @@ async function main() { return cleanupCommand(rest); case 'list': return listSkills(); + case 'find': + return findSkillsCommand(rest); case 'installed': return installedCommand(rest); case 'matrix': @@ -441,6 +444,25 @@ async function listSkills() { } } +async function findSkillsCommand(rest) { + const config = await configured(); + const pack = flagValue(rest, '--pack', 'cold'); + const limit = Number(flagValue(rest, '--limit', 5)); + const valueFlags = new Set(['--pack', '--limit']); + const query = rest.filter((arg, index) => !arg.startsWith('--') && !valueFlags.has(rest[index - 1])).join(' '); + if (!query) throw new Error('Usage: skillsync find [--pack cold] [--limit 5] [--json]'); + const results = await findSkills({ vaultPath: config.repoPath, query, pack, limit }); + if (hasFlag(rest, '--json')) { + console.log(JSON.stringify(results, null, 2)); + return; + } + if (!results.length) { + console.log(`No matching skills found in ${pack}.`); + return; + } + for (const result of results) console.log(`${result.name}\t${result.description}`); +} + function localSkillEntries(device, registry) { const byName = new Map(); const entryFor = (name) => { @@ -2710,5 +2732,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 audit [--json]\n skillsync cleanup [--all] [--apply]\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 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 pack apply --target targets [--exact] [--apply]\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 update [skill] [--check] [--apply]\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\n skillsync sync\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 cleanup [--all] [--apply]\n skillsync list\n skillsync find [--pack cold] [--limit 5] [--json]\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 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 pack apply --target targets [--exact] [--apply]\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 update [skill] [--check] [--apply]\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\n skillsync sync\n skillsync service install\n skillsync doctor\n skillsync daemon\n`); } diff --git a/src/core/find.js b/src/core/find.js new file mode 100644 index 0000000..dc8412f --- /dev/null +++ b/src/core/find.js @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { parseDocument } from 'yaml'; + +import { readJson } from './fs.js'; +import { loadRegistry } from './registry.js'; + +const STOP_WORDS = new Set([ + 'a', 'an', 'and', 'are', 'can', 'create', 'do', 'for', 'from', 'help', 'how', + 'i', 'in', 'is', 'it', 'make', 'me', 'my', 'need', 'of', 'on', 'or', 'the', + 'this', 'to', 'use', 'want', 'with', 'you', +]); + +function words(value) { + return [...new Set(String(value).toLowerCase().match(/[a-z0-9]+/g) || [])] + .filter((word) => word.length > 1 && !STOP_WORDS.has(word)); +} + +async function skillMetadata(vaultPath, skillName) { + const skillPath = path.join(vaultPath, 'skills', skillName, 'SKILL.md'); + const raw = await readFile(skillPath, 'utf8'); + const frontmatter = raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/); + if (!frontmatter) return { name: skillName, description: '', path: skillPath }; + const document = parseDocument(frontmatter[1], { prettyErrors: false, uniqueKeys: true }); + if (document.errors.length) return { name: skillName, description: '', path: skillPath }; + const metadata = document.toJS({ maxAliasCount: 20 }); + return { + name: typeof metadata?.name === 'string' ? metadata.name : skillName, + description: typeof metadata?.description === 'string' ? metadata.description.trim() : '', + path: skillPath, + }; +} + +export async function findSkills({ vaultPath, query, pack = 'cold', limit = 5 }) { + const queryWords = words(query); + if (!queryWords.length) return []; + const registry = await loadRegistry(vaultPath); + const manifest = pack + ? await readJson(path.join(vaultPath, 'packs', `${pack}.json`), null) + : null; + if (pack && !manifest) throw new Error(`Pack not found: ${pack}`); + const names = pack ? manifest.skills : Object.keys(registry.skills); + const candidates = await Promise.all(names.filter((name) => registry.skills[name]).map(async (name) => { + const metadata = await skillMetadata(vaultPath, name); + const nameWords = words(metadata.name); + const descriptionWords = words(metadata.description); + const score = queryWords.reduce((total, word) => { + const exactName = nameWords.includes(word); + return total + + (exactName ? (word.length <= 2 ? 2 : 6) : 0) + + (!exactName && word.length >= 4 + && nameWords.some((candidate) => candidate.length >= 4 && (candidate.startsWith(word) || word.startsWith(candidate))) ? 6 : 0) + + (descriptionWords.includes(word) ? (word.length <= 2 ? 1 : 2) : 0); + }, 0); + return { ...metadata, score, pack }; + })); + return candidates + .filter((candidate) => candidate.score > 0) + .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)) + .slice(0, Math.max(1, Number(limit) || 5)) + .map((candidate) => ({ + ...candidate, + description: candidate.description.length > 280 + ? `${candidate.description.slice(0, 277).trimEnd()}...` + : candidate.description, + })); +} diff --git a/test/catalog.test.js b/test/catalog.test.js index 6910c1e..15cd05b 100644 --- a/test/catalog.test.js +++ b/test/catalog.test.js @@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url'; import { auditCatalog, auditSkillFolder } from '../src/core/audit.js'; import { addTarget, installSkill, loadDevice, setSkillTargets } from '../src/core/device.js'; +import { findSkills } from '../src/core/find.js'; import { git } from '../src/core/git.js'; import { planPackApplication } from '../src/core/packs.js'; import { addSkillToVault, loadRegistry, rebuildRegistry } from '../src/core/registry.js'; @@ -123,6 +124,25 @@ test('setSkillTargets accepts an empty exact assignment', async () => { assert.equal(device.installed.review, undefined); }); +test('findSkills ranks matching metadata from the selected pack', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + await makeSkill(path.join(vault, 'skills'), 'humanizer', 'Humanize AI writing and add a natural voice.'); + await makeSkill(path.join(vault, 'skills'), 'make-pdf', 'Turn markdown into a publication-quality PDF.'); + await rebuildRegistry(vault); + await mkdir(path.join(vault, 'packs')); + await writeFile(path.join(vault, 'packs', 'cold.json'), JSON.stringify({ + name: 'cold', + skills: ['humanizer', 'make-pdf'], + })); + + const results = await findSkills({ vaultPath: vault, query: 'make AI writing sound human', pack: 'cold' }); + + assert.equal(results[0].name, 'humanizer'); + assert.match(results[0].path, /humanizer\/SKILL\.md$/); + assert.ok(results[0].score > 0); +}); + test('source provenance survives registry rebuild and supports explicit updates', async () => { const root = await tempDir(); const sourceRepo = path.join(root, 'source'); From 8216bbe7cfcf02e707412c3422acbba7d6e5f657 Mon Sep 17 00:00:00 2001 From: Josh Kennedy Date: Wed, 2 Sep 2026 08:35:01 -0500 Subject: [PATCH 8/8] fix: focus catalog audit review --- README.md | 41 +------- src/cli.js | 158 ++--------------------------- src/core/audit.js | 100 ++++++++++++++---- src/core/cleanup.js | 91 ----------------- src/core/device.js | 16 ++- src/core/find.js | 67 ------------ src/core/fs.js | 10 +- src/core/packs.js | 28 ----- src/core/registry.js | 41 ++------ src/core/source.js | 15 --- src/core/update.js | 56 ---------- test/catalog.test.js | 236 ++++++++++++++++++++++--------------------- test/cli.test.js | 84 ++------------- test/core.test.js | 5 - 14 files changed, 243 insertions(+), 705 deletions(-) delete mode 100644 src/core/cleanup.js delete mode 100644 src/core/find.js delete mode 100644 src/core/packs.js delete mode 100644 src/core/update.js diff --git a/README.md b/README.md index 2b4b9fe..02de62e 100644 --- a/README.md +++ b/README.md @@ -188,14 +188,6 @@ 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. -Git imports retain their repository, ref, commit, and skill subpath. Check tracked skills without changing the vault, then apply one reviewed update explicitly: - -```bash -skillsync update --check -skillsync update example-skill -skillsync update example-skill --apply -``` - ## 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: @@ -205,34 +197,7 @@ skillsync audit skillsync audit --json ``` -`skillsync doctor` includes the catalog summary. Audit errors cover malformed frontmatter, non-portable names, directory/name mismatches, missing descriptions, and specification limits. Longer-but-valid descriptions and oversized skill bodies are warnings. - -Consolidate byte-identical copies across configured targets into one vault skill and managed projections. Cleanup previews by default and leaves same-name conflicts untouched: - -```bash -skillsync cleanup -skillsync cleanup --apply -skillsync cleanup --all -skillsync cleanup --all --apply -``` - -`--all` also adopts unique unmanaged skills found under configured scan paths, preserving their current target assignments. - -Search a pack's skill metadata without installing or loading its full skill bodies: - -```bash -skillsync find "humanize AI writing" --pack cold -skillsync find "debug flaky tests" --pack cold --json -``` - -## Reconcile an exact skill pack - -Pack application previews changes by default. `--exact` removes SkillSync assignments for skills outside the pack on only the selected targets; unmanaged local folders and assignments on other targets remain untouched. - -```bash -skillsync pack apply core --target codex,claude --exact -skillsync pack apply core --target codex,claude --exact --apply -``` +`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 @@ -365,9 +330,7 @@ skillsync setup [--name skills] [--repo owner/repo|url] [--path path] [--yes] skillsync connect [--path path] skillsync status skillsync audit [--json] -skillsync cleanup [--all] [--apply] skillsync list -skillsync find [--pack cold] [--limit 5] [--json] skillsync installed [--device id] skillsync matrix [--edit] skillsync instructions status @@ -386,13 +349,11 @@ skillsync groups [--summary] skillsync pack list skillsync pack show skillsync pack install [--target targets] [--global] -skillsync pack apply --target targets [--exact] [--apply] skillsync add [--name name] [--skill name] [--target targets] [--global] [--conflict skip|use-vault|overwrite-vault|rename] skillsync import [--conflict skip|use-vault|overwrite-vault|rename] skillsync install [--device id] [--target targets] [--global] skillsync uninstall [--device id] [--target targets] [--global] skillsync delete [--yes] -skillsync update [skill] [--check] [--apply] skillsync target add [--mode symlink|copy] [--scan-path path] [--no-auto-adopt] skillsync target remove skillsync target auto-adopt diff --git a/src/cli.js b/src/cli.js index ef4c773..0d46b46 100755 --- a/src/cli.js +++ b/src/cli.js @@ -6,7 +6,6 @@ import path from 'node:path'; import { loadConfig, saveConfig, defaultRepoPath } from './core/config.js'; import { auditCatalog } from './core/audit.js'; -import { applyDuplicateCleanup, planDuplicateCleanup } from './core/cleanup.js'; import { addTarget, applyLinks, @@ -18,7 +17,6 @@ import { managedProjections, migrateLegacyLocalPathState, removeTargetAndPrune, - scanTargets, setDeviceAutoImport, setGlobalInstructionsProfile, setSkillTargets, @@ -43,8 +41,6 @@ import { } from './core/fs.js'; import { cloneRepo, commandExists, commitAllIfChanged, gh, git, isGitRepo, push, run } from './core/git.js'; import { generateGroups } from './core/groups.js'; -import { findSkills } from './core/find.js'; -import { planPackApplication } from './core/packs.js'; import { DEFAULT_CLAUDE_INSTRUCTIONS_PATH, DEFAULT_GLOBAL_INSTRUCTIONS_PATH, @@ -78,7 +74,6 @@ import { import { cloneSkillSource, discoverSkillFolders, importSourceForAgent, isRemoteSkillSource, selectDiscoveredSkills, supportedImportSources } from './core/source.js'; import { bootstrapLaunchAgent, daemonInvocation, renderLaunchAgent, renderSystemdUserService } from './core/service.js'; import { syncVault } from './core/sync.js'; -import { inspectSkillUpdate } from './core/update.js'; const args = process.argv.slice(2); @@ -102,12 +97,8 @@ async function main() { return status(); case 'audit': return auditCommand(rest); - case 'cleanup': - return cleanupCommand(rest); case 'list': return listSkills(); - case 'find': - return findSkillsCommand(rest); case 'installed': return installedCommand(rest); case 'matrix': @@ -133,8 +124,6 @@ async function main() { return uninstall(rest); case 'delete': return deleteSkill(rest); - case 'update': - return updateCommand(rest); case 'target': return target(rest); case 'auto-adopt': @@ -444,25 +433,6 @@ async function listSkills() { } } -async function findSkillsCommand(rest) { - const config = await configured(); - const pack = flagValue(rest, '--pack', 'cold'); - const limit = Number(flagValue(rest, '--limit', 5)); - const valueFlags = new Set(['--pack', '--limit']); - const query = rest.filter((arg, index) => !arg.startsWith('--') && !valueFlags.has(rest[index - 1])).join(' '); - if (!query) throw new Error('Usage: skillsync find [--pack cold] [--limit 5] [--json]'); - const results = await findSkills({ vaultPath: config.repoPath, query, pack, limit }); - if (hasFlag(rest, '--json')) { - console.log(JSON.stringify(results, null, 2)); - return; - } - if (!results.length) { - console.log(`No matching skills found in ${pack}.`); - return; - } - for (const result of results) console.log(`${result.name}\t${result.description}`); -} - function localSkillEntries(device, registry) { const byName = new Map(); const entryFor = (name) => { @@ -557,9 +527,6 @@ async function loadPack(config, packName) { if (!pack?.name || !Array.isArray(pack.skills)) { throw new Error(`Invalid pack manifest: ${packPath}`); } - const registry = await loadRegistry(config.repoPath); - const missing = pack.skills.filter((skillName) => !registry.skills[skillName]); - if (missing.length) throw new Error(`Pack contains skills missing from the vault: ${missing.join(', ')}`); return pack; } @@ -604,50 +571,7 @@ async function packCommand(rest) { console.log(`Installed pack ${pack.name} (${pack.skills.length} skills).`); return; } - if (sub === 'apply') { - const packName = rest[1]; - if (!packName) throw new Error('Usage: skillsync pack apply --target codex,claude [--exact] [--apply]'); - const targets = parseTargets(rest.slice(2)); - if (!targets?.length) throw new Error('Pack apply requires --target or --global'); - const deviceId = requestedDeviceId(config, rest); - await pullBeforeRemoteEdit(config, deviceId); - const device = await requireKnownDevice(config.repoPath, deviceId); - for (const targetName of targets.filter((targetName) => targetName !== 'global')) { - if (!device.targets?.[targetName]) throw new Error(`Unknown target on ${deviceId}: ${targetName}`); - } - const pack = await loadPack(config, packName); - const changes = planPackApplication({ - device, - skills: pack.skills, - targets, - exact: hasFlag(rest, '--exact'), - }); - if (!changes.length) { - console.log(`Pack ${pack.name} already matches ${targets.join(', ')} on ${deviceId}.`); - return; - } - console.log(`${hasFlag(rest, '--apply') ? 'Applying' : 'Previewing'} ${changes.length} assignment change${changes.length === 1 ? '' : 's'}:`); - for (const change of changes) { - console.log(`- ${change.skillName}: ${change.before.join(', ') || 'none'} -> ${change.after.join(', ') || 'none'}`); - } - if (!hasFlag(rest, '--apply')) { - console.log('Dry run only. Re-run with --apply to change assignments.'); - return; - } - for (const change of changes) { - await setSkillTargets({ - vaultPath: config.repoPath, - deviceId, - skillName: change.skillName, - targets: change.after, - }); - } - if (deviceId === config.deviceId) await applyLinks({ vaultPath: config.repoPath, deviceId }); - await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); - console.log(`Applied pack ${pack.name} to ${targets.join(', ')} on ${deviceId}.`); - return; - } - throw new Error('Usage: skillsync pack list|show |install |apply --target targets [--exact] [--apply]'); + throw new Error('Usage: skillsync pack list|show |install [--target codex,claude] [--global]'); } function projectionDetailsBySkill(projections) { @@ -1118,16 +1042,15 @@ async function renamedSkillName({ rest, currentName }) { return input({ message: `New vault name for ${currentName}:`, default: `${currentName}-local` }); } -async function addSkillWithConflictResolution({ config, sourcePath, name, rest = [], targets = [], source }) { +async function addSkillWithConflictResolution({ config, sourcePath, name, rest = [], targets = [] }) { const comparison = await compareSkillToVault({ vaultPath: config.repoPath, sourcePath, name }); if (comparison.status === 'new') { - const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name, source }); + const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name }); const managed = await installManagedSkill({ config, skillName: added.name, targets, sourcePath }); return { name: added.name, status: managed.replacedTarget ? 'added-and-linked' : 'added' }; } if (comparison.status === 'identical') { - await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name, source }); const managed = await installManagedSkill({ config, skillName: comparison.name, targets, sourcePath }); return { name: comparison.name, status: managed.replacedTarget ? 'consolidated' : 'identical' }; } @@ -1151,14 +1074,14 @@ async function addSkillWithConflictResolution({ config, sourcePath, name, rest = } if (action === 'overwrite-vault') { - const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name, overwrite: true, source }); + const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name, overwrite: true }); const managed = await installManagedSkill({ config, skillName: added.name, targets, sourcePath }); return { name: added.name, status: managed.replacedTarget ? 'overwritten-and-linked' : 'overwritten' }; } if (action === 'rename') { const newName = await renamedSkillName({ rest, currentName: comparison.name }); - const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name: newName, source }); + const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name: newName }); return { name: added.name, status: 'renamed' }; } @@ -1239,12 +1162,6 @@ async function addRemoteSkills(source, rest, config) { name: skill.name, rest, targets, - source: { - url: cloned.sourceUrl, - ...(cloned.ref ? { ref: cloned.ref } : {}), - commit: cloned.commit, - subpath: skill.relative, - }, }); results.push(result); } @@ -1386,74 +1303,13 @@ function printAudit(result) { async function auditCommand(rest) { const config = await configured(); - await refreshChangedRegistryEntries(config.repoPath); - const device = await loadLocalDevice(config.repoPath, config.deviceId); + 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; } -async function cleanupCommand(rest) { - const config = await configured(); - const apply = hasFlag(rest, '--apply'); - const includeUnique = hasFlag(rest, '--all'); - await scanTargets({ vaultPath: config.repoPath, deviceId: config.deviceId }); - const device = await loadLocalDevice(config.repoPath, config.deviceId); - const plan = await planDuplicateCleanup({ vaultPath: config.repoPath, device, includeUnique }); - - if (!plan.actions.length) console.log('No identical unmanaged duplicates to clean.'); - for (const action of plan.actions) { - const operation = action.paths.length > 1 ? `${action.paths.length} copies` : '1 unmanaged skill'; - console.log(`${apply ? '✓' : '!'} ${action.name}: ${operation} -> vault (${action.targets.join(', ')})`); - } - for (const conflict of plan.conflicts) { - console.log(`✗ ${conflict.name}: conflicting copies left unchanged${conflict.reason ? ` (${conflict.reason})` : ''}`); - } - if (!apply || !plan.actions.length) { - if (plan.actions.length) console.log(`\nPreview only. Run skillsync cleanup${includeUnique ? ' --all' : ''} --apply to make these changes.`); - return; - } - - await applyDuplicateCleanup({ vaultPath: config.repoPath, deviceId: config.deviceId, plan }); - await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); - console.log(`\nCanonicalized ${plan.actions.length} skill${plan.actions.length === 1 ? '' : 's'}.`); -} - -async function updateCommand(rest) { - const config = await configured(); - const requested = rest[0] && !rest[0].startsWith('-') ? rest[0] : null; - const apply = hasFlag(rest, '--apply'); - if (apply && !requested) throw new Error('Applying updates requires one explicit skill name'); - const registry = await loadRegistry(config.repoPath); - const names = requested - ? [requested] - : Object.keys(registry.skills).filter((name) => registry.skills[name].source?.url).sort(); - if (!names.length) { - console.log('No source-tracked skills in the vault.'); - return; - } - for (const skillName of names) { - try { - const result = await inspectSkillUpdate({ vaultPath: config.repoPath, skillName, apply }); - if (result.status === 'available') { - console.log(`! ${skillName}: update available (${result.currentCommit || 'unknown'} -> ${result.availableCommit})`); - } else if (result.status === 'updated') { - console.log(`✓ ${skillName}: updated to ${result.commit}`); - } else if (result.status === 'current') { - console.log(`✓ ${skillName}: current at ${result.commit}`); - } else { - console.log(`○ ${skillName}: source is not tracked`); - } - } catch (error) { - if (apply) throw error; - console.log(`✗ ${skillName}: ${error.message}`); - process.exitCode = 1; - } - } - if (apply) await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); -} - function parseTargets(rest) { const targets = []; if (hasFlag(rest, '--global') || hasFlag(rest, '-g')) targets.push('global'); @@ -2732,5 +2588,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 audit [--json]\n skillsync cleanup [--all] [--apply]\n skillsync list\n skillsync find [--pack cold] [--limit 5] [--json]\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 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 pack apply --target targets [--exact] [--apply]\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 update [skill] [--check] [--apply]\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\n skillsync sync\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 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\n skillsync sync\n skillsync service install\n skillsync doctor\n skillsync daemon\n`); } diff --git a/src/core/audit.js b/src/core/audit.js index a2de586..bc83e2e 100644 --- a/src/core/audit.js +++ b/src/core/audit.js @@ -1,4 +1,4 @@ -import { lstat, readFile, realpath } from 'node:fs/promises'; +import { lstat, readFile, readdir, realpath, stat } from 'node:fs/promises'; import path from 'node:path'; import { parseDocument } from 'yaml'; @@ -12,6 +12,37 @@ 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()) { @@ -48,7 +79,7 @@ export async function auditSkillFolder(skillPath, { expectedName = path.basename } const match = raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/); - let metadata = {}; + let metadata = new Map(); if (!match) { findings.push(finding('error', 'missing-frontmatter', 'SKILL.md must start with YAML frontmatter')); } else { @@ -58,16 +89,19 @@ export async function auditSkillFolder(skillPath, { expectedName = path.basename } if (!document.errors.length) { try { - const parsed = document.toJS({ maxAliasCount: 20 }); - metadata = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + 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 name = typeof metadata.name === 'string' ? metadata.name : ''; - const description = typeof metadata.description === 'string' ? metadata.description.trim() : ''; + 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 { @@ -80,7 +114,7 @@ export async function auditSkillFolder(skillPath, { expectedName = path.basename } } - if (!description) { + if (!description.trim()) { findings.push(finding('error', 'missing-description', 'Frontmatter description must be a non-empty string')); } else { if (description.length > 1024) { @@ -90,6 +124,35 @@ export async function auditSkillFolder(skillPath, { expectedName = path.basename } } + 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); @@ -118,27 +181,28 @@ export async function auditCatalog({ vaultPath, device }) { 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 detected = device.detected?.[targetName]; - const discovered = Array.isArray(detected) - ? detected.map((skill) => ({ name: skill.name, path: path.resolve(root, skill.path) })) - : await discoverSkillFolders(root).catch(() => []); + 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(skill.name, audited.descriptionLength); - if (!hash || hash !== vaultHashes.get(skill.name)) { - const key = `${skill.name}\0${hash || skill.path}`; + 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, skill.name)) { - if (!copiesByName.has(skill.name)) copiesByName.set(skill.name, []); - copiesByName.get(skill.name).push({ target: targetName, path: skill.path, hash }); + if (!await isManagedProjection(skill.path, vaultPath, skillName)) { + if (!copiesByName.has(skillName)) copiesByName.set(skillName, []); + copiesByName.get(skillName).push({ target: targetName, path: skill.path, hash }); } } } @@ -168,7 +232,7 @@ export async function auditCatalog({ vaultPath, device }) { .filter(([, targetNames]) => Array.isArray(targetNames) && targetNames.includes(targetName)) .map(([name]) => name) .sort(); - const detected = (device.detected?.[targetName] || []).map((skill) => skill.name); + 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); diff --git a/src/core/cleanup.js b/src/core/cleanup.js deleted file mode 100644 index 9ae2e04..0000000 --- a/src/core/cleanup.js +++ /dev/null @@ -1,91 +0,0 @@ -import { lstat, realpath } from 'node:fs/promises'; -import path from 'node:path'; - -import { applyLinks, installSkill, managedProjections, scanTargets } from './device.js'; -import { exists, expandHome, hashDirectory, removePath } from './fs.js'; -import { addSkillToVault } from './registry.js'; - -export async function planDuplicateCleanup({ vaultPath, device, includeUnique = false }) { - const managed = new Set((await managedProjections({ vaultPath, deviceId: device.device_id })) - .filter((projection) => projection.status === 'ok') - .map((projection) => `${projection.targetName}\0${projection.skillName}`)); - const copiesByName = new Map(); - - for (const [targetName, target] of Object.entries(device.targets || {})) { - const root = path.resolve(expandHome(target.scan_path || target.path)); - for (const skill of device.detected?.[targetName] || []) { - const copyPath = path.resolve(root, skill.path); - if (!copiesByName.has(skill.name)) copiesByName.set(skill.name, []); - copiesByName.get(skill.name).push({ target: targetName, path: copyPath }); - } - } - - const actions = []; - const conflicts = []; - for (const [name, copies] of copiesByName) { - if (!includeUnique && copies.length < 2) continue; - const vaultSkill = path.join(vaultPath, 'skills', name); - const vaultExists = await exists(vaultSkill); - const unmanaged = copies.filter((copy) => !managed.has(`${copy.target}\0${name}`)); - if (!unmanaged.length) continue; - - const hashes = new Set(); - if (vaultExists) hashes.add(await hashDirectory(vaultSkill)); - for (const copy of unmanaged) hashes.add(await hashDirectory(copy.path)); - if (hashes.size !== 1) { - conflicts.push({ name, copies }); - continue; - } - - let source = vaultExists ? vaultSkill : null; - if (!source) { - for (const copy of unmanaged) { - const info = await lstat(copy.path); - if (info.isDirectory() && !info.isSymbolicLink()) { - source = copy.path; - break; - } - } - if (!source) source = await realpath(unmanaged[0].path); - } - if (!source) { - conflicts.push({ name, copies, reason: 'No standalone directory is available as the canonical source' }); - continue; - } - - actions.push({ - name, - source, - targets: [...new Set(copies.map((copy) => copy.target))].sort(), - paths: unmanaged.map((copy) => copy.path), - }); - } - - return { - actions: actions.sort((a, b) => a.name.localeCompare(b.name)), - conflicts: conflicts.sort((a, b) => a.name.localeCompare(b.name)), - }; -} - -export async function applyDuplicateCleanup({ vaultPath, deviceId, plan }) { - for (const action of plan.actions) { - await addSkillToVault({ vaultPath, sourcePath: action.source, name: action.name }); - const vaultHash = await hashDirectory(path.join(vaultPath, 'skills', action.name)); - for (const copyPath of action.paths) { - if (await hashDirectory(copyPath) !== vaultHash) { - throw new Error(`Refusing to clean changed skill: ${copyPath}`); - } - } - const paths = await Promise.all(action.paths.map(async (copyPath) => ({ - path: copyPath, - symlink: (await lstat(copyPath)).isSymbolicLink(), - }))); - for (const copy of paths.sort((a, b) => Number(b.symlink) - Number(a.symlink))) { - await removePath(copy.path); - } - await installSkill({ vaultPath, deviceId, skillName: action.name, targets: action.targets }); - } - await applyLinks({ vaultPath, deviceId }); - await scanTargets({ vaultPath, deviceId }); - return plan.actions.map((action) => action.name); -} diff --git a/src/core/device.js b/src/core/device.js index 3f61722..82bbfe9 100644 --- a/src/core/device.js +++ b/src/core/device.js @@ -75,8 +75,8 @@ export function globalInstructionsAssignmentPath(vaultPath, deviceId) { ); } -export async function loadDevice(vaultPath, deviceId = defaultDeviceId()) { - await ensureVault(vaultPath); +export async function loadDevice(vaultPath, deviceId = defaultDeviceId(), { ensure = true } = {}) { + if (ensure) await ensureVault(vaultPath); const desired = await readJson(devicePath(vaultPath, deviceId), null); const reported = await readJson(deviceStatePath(vaultPath, deviceId), null); const instructionSelection = await readJson( @@ -255,8 +255,8 @@ export async function migrateLegacyLocalPathState({ }); } -export async function loadLocalDevice(vaultPath, deviceId = defaultDeviceId()) { - const device = await loadDevice(vaultPath, deviceId); +export async function loadLocalDevice(vaultPath, deviceId = defaultDeviceId(), options) { + const device = await loadDevice(vaultPath, deviceId, options); const local = await readLocalPathState(vaultPath, deviceId); if (!local) { throw new Error(`Local paths for ${deviceId} are not initialized; run SkillSync setup or reconnect this device`); @@ -741,10 +741,8 @@ export async function setSkillTargets({ vaultPath, deviceId = defaultDeviceId(), const registry = await loadRegistry(vaultPath); if (!registry.skills[skillName]) throw new Error(`Skill not found in vault: ${skillName}`); const device = await loadDevice(vaultPath, deviceId); - const requestedTargets = targets === undefined ? Object.keys(device.targets) : targets; - const { selectedTargets, wantsGlobalInstall } = requestedTargets.length - ? await validateRequestedTargets(device, requestedTargets) - : { selectedTargets: [], wantsGlobalInstall: false }; + const requestedTargets = targets?.length ? targets : Object.keys(device.targets); + const { selectedTargets, wantsGlobalInstall } = await validateRequestedTargets(device, requestedTargets); const previousTargets = device.installed[skillName] || []; const previousGlobal = (device.global_installed || []).includes(skillName); const changed = JSON.stringify(previousTargets) !== JSON.stringify(selectedTargets) @@ -1167,7 +1165,7 @@ async function createCopyProjection(source, destination, skillName, vaultPath) { throw new Error(`Refusing to overwrite unmanaged target path: ${destination}`); } } - await cp(source, destination, { recursive: true, force: true, dereference: false, verbatimSymlinks: true }); + await cp(source, destination, { recursive: true, force: true, dereference: false }); await writeFile(path.join(destination, '.skillsync-owned.json'), JSON.stringify({ skill: skillName, vault: vaultPath }, null, 2)); } diff --git a/src/core/find.js b/src/core/find.js deleted file mode 100644 index dc8412f..0000000 --- a/src/core/find.js +++ /dev/null @@ -1,67 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { parseDocument } from 'yaml'; - -import { readJson } from './fs.js'; -import { loadRegistry } from './registry.js'; - -const STOP_WORDS = new Set([ - 'a', 'an', 'and', 'are', 'can', 'create', 'do', 'for', 'from', 'help', 'how', - 'i', 'in', 'is', 'it', 'make', 'me', 'my', 'need', 'of', 'on', 'or', 'the', - 'this', 'to', 'use', 'want', 'with', 'you', -]); - -function words(value) { - return [...new Set(String(value).toLowerCase().match(/[a-z0-9]+/g) || [])] - .filter((word) => word.length > 1 && !STOP_WORDS.has(word)); -} - -async function skillMetadata(vaultPath, skillName) { - const skillPath = path.join(vaultPath, 'skills', skillName, 'SKILL.md'); - const raw = await readFile(skillPath, 'utf8'); - const frontmatter = raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/); - if (!frontmatter) return { name: skillName, description: '', path: skillPath }; - const document = parseDocument(frontmatter[1], { prettyErrors: false, uniqueKeys: true }); - if (document.errors.length) return { name: skillName, description: '', path: skillPath }; - const metadata = document.toJS({ maxAliasCount: 20 }); - return { - name: typeof metadata?.name === 'string' ? metadata.name : skillName, - description: typeof metadata?.description === 'string' ? metadata.description.trim() : '', - path: skillPath, - }; -} - -export async function findSkills({ vaultPath, query, pack = 'cold', limit = 5 }) { - const queryWords = words(query); - if (!queryWords.length) return []; - const registry = await loadRegistry(vaultPath); - const manifest = pack - ? await readJson(path.join(vaultPath, 'packs', `${pack}.json`), null) - : null; - if (pack && !manifest) throw new Error(`Pack not found: ${pack}`); - const names = pack ? manifest.skills : Object.keys(registry.skills); - const candidates = await Promise.all(names.filter((name) => registry.skills[name]).map(async (name) => { - const metadata = await skillMetadata(vaultPath, name); - const nameWords = words(metadata.name); - const descriptionWords = words(metadata.description); - const score = queryWords.reduce((total, word) => { - const exactName = nameWords.includes(word); - return total - + (exactName ? (word.length <= 2 ? 2 : 6) : 0) - + (!exactName && word.length >= 4 - && nameWords.some((candidate) => candidate.length >= 4 && (candidate.startsWith(word) || word.startsWith(candidate))) ? 6 : 0) - + (descriptionWords.includes(word) ? (word.length <= 2 ? 1 : 2) : 0); - }, 0); - return { ...metadata, score, pack }; - })); - return candidates - .filter((candidate) => candidate.score > 0) - .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)) - .slice(0, Math.max(1, Number(limit) || 5)) - .map((candidate) => ({ - ...candidate, - description: candidate.description.length > 280 - ? `${candidate.description.slice(0, 277).trimEnd()}...` - : candidate.description, - })); -} diff --git a/src/core/fs.js b/src/core/fs.js index 174e418..741b822 100644 --- a/src/core/fs.js +++ b/src/core/fs.js @@ -1,9 +1,9 @@ -import { chmod, cp, lstat, mkdir, readFile, readlink, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { chmod, cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import path from 'node:path'; -const RESERVED_OBJECT_KEYS = new Set(['__proto__', 'constructor']); +const RESERVED_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']); export function expandHome(value) { if (!value) return value; @@ -58,7 +58,7 @@ export async function writePrivateJson(filePath, value) { export async function copyDir(source, destination) { await rm(destination, { recursive: true, force: true }); await ensureDir(path.dirname(destination)); - await cp(source, destination, { recursive: true, force: true, dereference: false, verbatimSymlinks: true }); + await cp(source, destination, { recursive: true, force: true, dereference: false }); } export async function removePath(targetPath) { @@ -122,9 +122,7 @@ export async function hashDirectory(dirPath) { for (const relativePath of files) { hash.update(relativePath); hash.update('\0'); - const filePath = path.join(dirPath, relativePath); - const info = await lstat(filePath); - hash.update(info.isSymbolicLink() ? await readlink(filePath) : await readFile(filePath)); + hash.update(await readFile(path.join(dirPath, relativePath))); hash.update('\0'); } return `sha256:${hash.digest('hex')}`; diff --git a/src/core/packs.js b/src/core/packs.js deleted file mode 100644 index bb43130..0000000 --- a/src/core/packs.js +++ /dev/null @@ -1,28 +0,0 @@ -import { skillAssignmentTargets } from './matrix.js'; - -export function planPackApplication({ device, skills, targets, exact = false }) { - const packSkills = new Set(skills); - const selectedTargets = new Set(targets); - const names = new Set([ - ...skills, - ...Object.keys(device.installed || {}), - ...(device.global_installed || []), - ]); - const changes = []; - - for (const skillName of names) { - const before = skillAssignmentTargets(device, skillName); - const after = new Set(before); - if (packSkills.has(skillName)) { - for (const target of selectedTargets) after.add(target); - } else if (exact) { - for (const target of selectedTargets) after.delete(target); - } - const normalizedAfter = [...after].sort(); - if (JSON.stringify(before) !== JSON.stringify(normalizedAfter)) { - changes.push({ skillName, before, after: normalizedAfter }); - } - } - - return changes.sort((a, b) => a.skillName.localeCompare(b.skillName)); -} diff --git a/src/core/registry.js b/src/core/registry.js index 3ae2512..700994b 100644 --- a/src/core/registry.js +++ b/src/core/registry.js @@ -105,20 +105,6 @@ export async function validateSkillFolder(sourcePath) { } } -function normalizeSourceMetadata(source) { - if (!source?.url) return undefined; - const subpath = source.subpath || '.'; - if (path.isAbsolute(subpath) || subpath.split(/[\\/]/).includes('..')) { - throw new Error(`Skill source subpath must stay inside its repository: ${subpath}`); - } - return { - url: String(source.url), - ...(source.ref ? { ref: String(source.ref) } : {}), - ...(source.commit ? { commit: String(source.commit) } : {}), - subpath, - }; -} - export async function compareSkillToVault({ vaultPath, sourcePath, name }) { await ensureVault(vaultPath); await validateSkillFolder(sourcePath); @@ -142,25 +128,22 @@ export async function compareSkillToVault({ vaultPath, sourcePath, name }) { }; } -export async function addSkillToVault({ vaultPath, sourcePath, name, overwrite = false, source }) { +export async function addSkillToVault({ vaultPath, sourcePath, name, overwrite = false }) { const comparison = await compareSkillToVault({ vaultPath, sourcePath, name }); if (comparison.status === 'different' && !overwrite) { throw new Error(`Skill already exists in vault with different content: ${comparison.name}`); } if (comparison.status === 'identical') { const registry = await loadRegistry(vaultPath); - const sourceMetadata = normalizeSourceMetadata(source) || registry.skills[comparison.name]?.source; - if (!registry.skills[comparison.name] - || registry.skills[comparison.name].hash !== comparison.vaultHash - || JSON.stringify(registry.skills[comparison.name].source) !== JSON.stringify(sourceMetadata)) { - registry.skills[comparison.name] = await registryEntryForSkill(vaultPath, comparison.name, { source: sourceMetadata }); + if (!registry.skills[comparison.name] || registry.skills[comparison.name].hash !== comparison.vaultHash) { + registry.skills[comparison.name] = await registryEntryForSkill(vaultPath, comparison.name); await saveRegistry(vaultPath, registry); } return { name: comparison.name, path: comparison.path, status: 'identical' }; } await copyDir(sourcePath, comparison.path); const registry = await loadRegistry(vaultPath); - registry.skills[comparison.name] = await registryEntryForSkill(vaultPath, comparison.name, { source }); + registry.skills[comparison.name] = await registryEntryForSkill(vaultPath, comparison.name); await saveRegistry(vaultPath, registry); return { name: comparison.name, @@ -172,30 +155,24 @@ export async function addSkillToVault({ vaultPath, sourcePath, name, overwrite = export async function ensureSkillInRegistry(vaultPath, skillName) { assertSafePathSegment(skillName, 'Skill name'); const registry = await loadRegistry(vaultPath); - registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName, { - source: registry.skills[skillName]?.source, - }); + registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName); await saveRegistry(vaultPath, registry); return registry.skills[skillName]; } -export async function registryEntryForSkill(vaultPath, skillName, { source } = {}) { +export async function registryEntryForSkill(vaultPath, skillName) { assertSafePathSegment(skillName, 'Skill name'); const skillPath = path.join(vaultPath, 'skills', skillName); await validateSkillFolder(skillPath); - const entry = { + return { path: path.posix.join('skills', skillName), hash: await hashDirectory(skillPath), updated_at: new Date().toISOString(), }; - const sourceMetadata = normalizeSourceMetadata(source); - if (sourceMetadata) entry.source = sourceMetadata; - return entry; } export async function rebuildRegistry(vaultPath) { await ensureVault(vaultPath); - const previous = await loadRegistry(vaultPath); const skillsDir = path.join(vaultPath, 'skills'); const entries = await readdir(skillsDir, { withFileTypes: true }); const registry = emptyRegistry(); @@ -203,9 +180,7 @@ export async function rebuildRegistry(vaultPath) { if (!entry.isDirectory()) continue; const skillName = assertSafePathSegment(entry.name, 'Skill name'); if (!await exists(path.join(skillsDir, skillName, 'SKILL.md'))) continue; - registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName, { - source: previous.skills[skillName]?.source, - }); + registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName); } await saveRegistry(vaultPath, registry); return registry; diff --git a/src/core/source.js b/src/core/source.js index 016e7e7..f69c115 100644 --- a/src/core/source.js +++ b/src/core/source.js @@ -45,17 +45,6 @@ export function cloneUrlForSource(rawSource) { return { cloneUrl, ref }; } -function sanitizedSourceUrl(cloneUrl) { - try { - const url = new URL(cloneUrl); - url.username = ''; - url.password = ''; - return url.toString(); - } catch { - return cloneUrl; - } -} - export async function cloneSkillSource(rawSource) { const { cloneUrl, ref } = cloneUrlForSource(rawSource); const dir = await mkdtemp(path.join(tmpdir(), 'skillsync-source-')); @@ -64,12 +53,8 @@ export async function cloneSkillSource(rawSource) { args.push(cloneUrl, dir); try { await git(args); - const { stdout } = await git(['rev-parse', 'HEAD'], dir); return { path: dir, - sourceUrl: sanitizedSourceUrl(cloneUrl), - ref, - commit: stdout.trim(), cleanup: () => rm(dir, { recursive: true, force: true }), }; } catch (error) { diff --git a/src/core/update.js b/src/core/update.js deleted file mode 100644 index 705b02a..0000000 --- a/src/core/update.js +++ /dev/null @@ -1,56 +0,0 @@ -import path from 'node:path'; - -import { hashDirectory } from './fs.js'; -import { addSkillToVault, loadRegistry, validateSkillFolder } from './registry.js'; -import { cloneSkillSource } from './source.js'; - -function sourceSpecifier(source) { - return `${source.url}${source.ref ? `#${source.ref}` : ''}`; -} - -export async function inspectSkillUpdate({ vaultPath, skillName, apply = false }) { - const registry = await loadRegistry(vaultPath); - const entry = registry.skills[skillName]; - if (!entry) throw new Error(`Skill not found in vault: ${skillName}`); - if (!entry.source?.url) return { skillName, status: 'untracked' }; - - const cloned = await cloneSkillSource(sourceSpecifier(entry.source)); - try { - const sourcePath = path.resolve(cloned.path, entry.source.subpath || '.'); - const cloneRoot = path.resolve(cloned.path); - if (sourcePath !== cloneRoot && !sourcePath.startsWith(`${cloneRoot}${path.sep}`)) { - throw new Error(`Stored source path escapes the repository: ${entry.source.subpath}`); - } - await validateSkillFolder(sourcePath); - const remoteHash = await hashDirectory(sourcePath); - if (remoteHash === entry.hash) { - return { skillName, status: 'current', commit: cloned.commit }; - } - if (!apply) { - return { - skillName, - status: 'available', - currentCommit: entry.source.commit, - availableCommit: cloned.commit, - }; - } - await addSkillToVault({ - vaultPath, - sourcePath, - name: skillName, - overwrite: true, - source: { - ...entry.source, - commit: cloned.commit, - }, - }); - return { - skillName, - status: 'updated', - previousCommit: entry.source.commit, - commit: cloned.commit, - }; - } finally { - await cloned.cleanup(); - } -} diff --git a/test/catalog.test.js b/test/catalog.test.js index 15cd05b..a754b92 100644 --- a/test/catalog.test.js +++ b/test/catalog.test.js @@ -1,17 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; import { auditCatalog, auditSkillFolder } from '../src/core/audit.js'; -import { addTarget, installSkill, loadDevice, setSkillTargets } from '../src/core/device.js'; -import { findSkills } from '../src/core/find.js'; -import { git } from '../src/core/git.js'; -import { planPackApplication } from '../src/core/packs.js'; -import { addSkillToVault, loadRegistry, rebuildRegistry } from '../src/core/registry.js'; -import { inspectSkillUpdate } from '../src/core/update.js'; +import { rebuildRegistry } from '../src/core/registry.js'; async function tempDir() { return mkdtemp(path.join(tmpdir(), 'skillsync-catalog-test-')); @@ -24,7 +18,7 @@ async function makeSkill(root, name, description = 'Use this skill for focused t return dir; } -test('auditSkillFolder validates portable metadata and catalog size', async () => { +test('auditSkillFolder validates required Agent Skills metadata', async () => { const root = await tempDir(); const invalid = path.join(root, 'wrong-directory'); await mkdir(invalid); @@ -37,6 +31,118 @@ test('auditSkillFolder validates portable metadata and catalog size', async () = ); }); +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'); @@ -47,18 +153,13 @@ test('auditCatalog reports conflicting target copies and active description cost await makeSkill(claude, 'review', 'Use a different review workflow.'); await rebuildRegistry(vault); - const device = { - targets: { - codex: { path: codex }, - claude: { path: claude }, - }, - installed: { review: ['codex', 'claude'] }, - detected: { - codex: [{ name: 'review', path: 'review' }], - claude: [{ name: 'review', path: 'review' }], + const result = await auditCatalog({ + vaultPath: vault, + device: { + targets: { codex: { path: codex }, claude: { path: claude } }, + installed: { review: ['codex', 'claude'] }, }, - }; - const result = await auditCatalog({ vaultPath: vault, device }); + }); assert.equal(result.summary.conflictingDuplicates, 1); assert.equal(result.duplicates[0].status, 'conflicting'); @@ -80,102 +181,11 @@ test('auditCatalog does not report managed projections as duplicates', async () device: { targets: { codex: { path: codex } }, installed: { review: ['codex'] }, - detected: { codex: [{ name: 'review', path: 'review' }] }, + detected: {}, }, }); assert.equal(result.summary.identicalDuplicates, 0); assert.equal(result.summary.conflictingDuplicates, 0); -}); - -test('planPackApplication can exactly reconcile selected targets while preserving others', () => { - const device = { - installed: { - review: ['claude'], - legacy: ['claude', 'codex'], - }, - global_installed: ['global-helper'], - }; - - const changes = planPackApplication({ - device, - skills: ['review'], - targets: ['claude'], - exact: true, - }); - - assert.deepEqual(changes, [ - { skillName: 'legacy', before: ['claude', 'codex'], after: ['codex'] }, - ]); -}); - -test('setSkillTargets accepts an empty exact assignment', async () => { - const root = await tempDir(); - const vault = path.join(root, 'vault'); - const target = path.join(root, 'codex'); - await makeSkill(path.join(vault, 'skills'), 'review'); - await rebuildRegistry(vault); - await addTarget({ vaultPath: vault, deviceId: 'test', name: 'codex', targetPath: target }); - await installSkill({ vaultPath: vault, deviceId: 'test', skillName: 'review', targets: ['codex'] }); - - await setSkillTargets({ vaultPath: vault, deviceId: 'test', skillName: 'review', targets: [] }); - - const device = await loadDevice(vault, 'test'); - assert.equal(device.installed.review, undefined); -}); - -test('findSkills ranks matching metadata from the selected pack', async () => { - const root = await tempDir(); - const vault = path.join(root, 'vault'); - await makeSkill(path.join(vault, 'skills'), 'humanizer', 'Humanize AI writing and add a natural voice.'); - await makeSkill(path.join(vault, 'skills'), 'make-pdf', 'Turn markdown into a publication-quality PDF.'); - await rebuildRegistry(vault); - await mkdir(path.join(vault, 'packs')); - await writeFile(path.join(vault, 'packs', 'cold.json'), JSON.stringify({ - name: 'cold', - skills: ['humanizer', 'make-pdf'], - })); - - const results = await findSkills({ vaultPath: vault, query: 'make AI writing sound human', pack: 'cold' }); - - assert.equal(results[0].name, 'humanizer'); - assert.match(results[0].path, /humanizer\/SKILL\.md$/); - assert.ok(results[0].score > 0); -}); - -test('source provenance survives registry rebuild and supports explicit updates', async () => { - const root = await tempDir(); - const sourceRepo = path.join(root, 'source'); - const sourceSkill = await makeSkill(path.join(sourceRepo, 'skills'), 'tracked', 'Track upstream changes.', '# Version one\n'); - await git(['init', '--initial-branch=main'], sourceRepo); - await git(['config', 'user.email', 'test@example.com'], sourceRepo); - await git(['config', 'user.name', 'SkillSync Test'], sourceRepo); - await git(['add', '.'], sourceRepo); - await git(['commit', '-m', 'initial'], sourceRepo); - const firstCommit = (await git(['rev-parse', 'HEAD'], sourceRepo)).stdout.trim(); - const sourceUrl = pathToFileURL(sourceRepo).href; - const vault = path.join(root, 'vault'); - - await addSkillToVault({ - vaultPath: vault, - sourcePath: sourceSkill, - source: { url: sourceUrl, ref: 'main', commit: firstCommit, subpath: 'skills/tracked' }, - }); - await rebuildRegistry(vault); - assert.equal((await loadRegistry(vault)).skills.tracked.source.commit, firstCommit); - assert.equal((await inspectSkillUpdate({ vaultPath: vault, skillName: 'tracked' })).status, 'current'); - - await writeFile(path.join(sourceSkill, 'SKILL.md'), '---\nname: tracked\ndescription: Track upstream changes.\n---\n# Version two\n'); - await git(['add', '.'], sourceRepo); - await git(['commit', '-m', 'update'], sourceRepo); - const secondCommit = (await git(['rev-parse', 'HEAD'], sourceRepo)).stdout.trim(); - - const available = await inspectSkillUpdate({ vaultPath: vault, skillName: 'tracked' }); - assert.equal(available.status, 'available'); - assert.equal(available.availableCommit, secondCommit); - - const updated = await inspectSkillUpdate({ vaultPath: vault, skillName: 'tracked', apply: true }); - assert.equal(updated.status, 'updated'); - assert.equal((await loadRegistry(vault)).skills.tracked.source.commit, secondCommit); - assert.match(await readFile(path.join(vault, 'skills', 'tracked', 'SKILL.md'), 'utf8'), /Version two/); + assert.equal(result.targets.codex.activeSkills, 1); }); diff --git a/test/cli.test.js b/test/cli.test.js index df12c33..015ea5f 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -114,7 +114,7 @@ test('installed command marks missing managed projections', async () => { assert.match(stdout, /codex: .*paper-mcp \(missing; run skillsync sync\)/); }); -test('audit --json reports standards findings and active catalog cost', async () => { +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'); @@ -124,6 +124,13 @@ test('audit --json reports standards findings and active catalog cost', async () 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('.'), @@ -133,79 +140,10 @@ test('audit --json reports standards findings and active catalog cost', async () 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); -}); - -test('cleanup previews then consolidates identical target copies', async () => { - const home = await tempDir(); - const vault = path.join(home, '.skillsync', 'repo'); - const hermesRoot = path.join(home, '.hermes', 'skills'); - const hermes = path.join(hermesRoot, 'personal'); - const agents = path.join(home, '.agents', 'skills'); - const codex = path.join(home, '.codex', 'skills'); - const deviceId = 'test-device'; - await writeConfig(home, vault, deviceId); - await makeSkill(agents, 'review', '# Identical\n'); - await makeSkill(codex, 'review', '# Identical\n'); - await makeSkill(agents, 'draft', '# Agents version\n'); - await makeSkill(codex, 'draft', '# Codex version\n'); - const unique = await makeSkill(path.join(hermesRoot, 'creative'), 'unique', '# Unique\n'); - await mkdir(path.join(unique, 'assets')); - await writeFile(path.join(unique, 'assets', 'example.txt'), 'example\n'); - await symlink('assets', path.join(unique, 'assets-link'), 'dir'); - await addTarget({ vaultPath: vault, deviceId, name: 'hermes', targetPath: hermes, scanPath: hermesRoot }); - await addTarget({ vaultPath: vault, deviceId, name: 'agents', targetPath: agents }); - await addTarget({ vaultPath: vault, deviceId, name: 'codex', targetPath: codex }); - - const preview = await execFileAsync(process.execPath, [path.resolve('src/cli.js'), 'cleanup'], { - cwd: path.resolve('.'), - env: cliEnv(home), - }); - assert.match(preview.stdout, /Preview only/); - assert.match(preview.stdout, /draft: conflicting copies left unchanged/); - assert.equal((await lstat(path.join(agents, 'review'))).isSymbolicLink(), false); - - const applied = await execFileAsync(process.execPath, [path.resolve('src/cli.js'), 'cleanup', '--all', '--apply'], { - cwd: path.resolve('.'), - env: cliEnv(home), - }); - assert.match(applied.stdout, /Canonicalized 2 skills/); - assert.equal((await lstat(path.join(agents, 'review'))).isSymbolicLink(), true); - assert.equal((await lstat(path.join(codex, 'review'))).isSymbolicLink(), true); - assert.equal((await lstat(path.join(agents, 'draft'))).isSymbolicLink(), false); - assert.equal((await lstat(path.join(codex, 'draft'))).isSymbolicLink(), false); - assert.ok((await loadRegistry(vault)).skills.review); - assert.ok((await loadRegistry(vault)).skills.unique); - await assert.rejects(() => lstat(path.join(hermesRoot, 'creative', 'unique'))); - assert.equal((await lstat(path.join(hermes, 'unique'))).isSymbolicLink(), true); -}); - -test('pack apply previews by default and exactly reconciles only with --apply', 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'); - await makeSkill(path.join(vault, 'skills'), 'legacy'); - await rebuildRegistry(vault); - await mkdir(path.join(vault, 'packs'), { recursive: true }); - await writeFile(path.join(vault, 'packs', 'core.json'), JSON.stringify({ name: 'core', skills: ['review'] })); - await addTarget({ vaultPath: vault, deviceId, name: 'codex', targetPath: target }); - await installSkill({ vaultPath: vault, deviceId, skillName: 'legacy', targets: ['codex'] }); - - const preview = await execFileAsync(process.execPath, [ - path.resolve('src/cli.js'), 'pack', 'apply', 'core', '--target', 'codex', '--exact', - ], { cwd: path.resolve('.'), env: cliEnv(home) }); - assert.match(preview.stdout, /Dry run only/); - assert.deepEqual((await loadDevice(vault, deviceId)).installed.legacy, ['codex']); - - await execFileAsync(process.execPath, [ - path.resolve('src/cli.js'), 'pack', 'apply', 'core', '--target', 'codex', '--exact', '--apply', - ], { cwd: path.resolve('.'), env: cliEnv(home) }); - const device = await loadDevice(vault, deviceId); - assert.deepEqual(device.installed.review, ['codex']); - assert.equal(device.installed.legacy, undefined); + 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 () => { diff --git a/test/core.test.js b/test/core.test.js index 3b53bec..e61c080 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -46,7 +46,6 @@ import { uninstallSkillAndPrune, } from '../src/core/device.js'; import { generateGroups } from '../src/core/groups.js'; -import { assertSafePathSegment } from '../src/core/fs.js'; import { applyGlobalInstructions, assignGlobalInstructionsProfile, @@ -1386,10 +1385,6 @@ test('path-like skill names and device IDs are rejected before filesystem access assert.equal(await readFile(path.join(outside, 'keep.txt'), 'utf8'), 'keep'); }); -test('prototype is a valid filesystem-safe skill name', () => { - assert.equal(assertSafePathSegment('prototype', 'Skill name'), 'prototype'); -}); - test('generated pack names cannot escape the vault output folders', async () => { const root = await tempDir(); const vault = path.join(root, 'vault');