feat(core): catalog store (items, presets, imports) and git sync wrapper
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8e6675a parent: efb1294 added
packages/core/src/catalog/fsutil.ts +81 -0 | new file mode 100644 | ||
| @@ -0,0 +1,81 @@ | ||
| 1 | +import { randomBytes } from 'node:crypto' | |
| 2 | +import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises' | |
| 3 | +import path from 'node:path' | |
| 4 | + | |
| 5 | +export function tmpSuffix(): string { | |
| 6 | + return `${process.pid}-${randomBytes(4).toString('hex')}` | |
| 7 | +} | |
| 8 | + | |
| 9 | +async function renameRetry(from: string, to: string): Promise<void> { | |
| 10 | + for (let attempt = 0; ; attempt++) { | |
| 11 | + try { | |
| 12 | + await rename(from, to) | |
| 13 | + return | |
| 14 | + } catch (e) { | |
| 15 | + const code = (e as NodeJS.ErrnoException).code | |
| 16 | + if (attempt < 4 && (code === 'EPERM' || code === 'EBUSY' || code === 'EACCES')) { | |
| 17 | + await new Promise((r) => setTimeout(r, 25 * (attempt + 1))) | |
| 18 | + continue | |
| 19 | + } | |
| 20 | + throw e | |
| 21 | + } | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** Writes a file atomically (temp file in the same folder + rename). */ | |
| 26 | +export async function atomicWrite(file: string, data: string | Uint8Array): Promise<void> { | |
| 27 | + await mkdir(path.dirname(file), { recursive: true }) | |
| 28 | + const tmp = `${file}.tmp-${tmpSuffix()}` | |
| 29 | + try { | |
| 30 | + await writeFile(tmp, data) | |
| 31 | + await renameRetry(tmp, file) | |
| 32 | + } catch (e) { | |
| 33 | + await rm(tmp, { force: true }).catch(() => {}) | |
| 34 | + throw e | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +export async function movePath(from: string, to: string): Promise<void> { | |
| 39 | + await mkdir(path.dirname(to), { recursive: true }) | |
| 40 | + await renameRetry(from, to) | |
| 41 | +} | |
| 42 | + | |
| 43 | +const SKIP_DIRS = new Set(['.git', 'node_modules']) | |
| 44 | + | |
| 45 | +/** | |
| 46 | + * Copies a folder, dereferencing symlinks (copies the target content). Skips `.git` and `node_modules`, | |
| 47 | + * and anything for which `exclude(relPath)` is true (relative path, forward slashes). | |
| 48 | + */ | |
| 49 | +export async function copyDir(src: string, dest: string, exclude?: (rel: string) => boolean): Promise<void> { | |
| 50 | + await mkdir(dest, { recursive: true }) | |
| 51 | + await copyInto(src, dest, '', exclude, 0) | |
| 52 | +} | |
| 53 | + | |
| 54 | +async function copyInto( | |
| 55 | + src: string, | |
| 56 | + dest: string, | |
| 57 | + rel: string, | |
| 58 | + exclude: ((rel: string) => boolean) | undefined, | |
| 59 | + depth: number, | |
| 60 | +): Promise<void> { | |
| 61 | + if (depth > 32) throw new Error(`Folder too deep (symlink loop?): ${src}`) | |
| 62 | + const entries = await readdir(path.join(src, rel), { withFileTypes: true }) | |
| 63 | + for (const e of entries) { | |
| 64 | + const childRel = rel ? `${rel}/${e.name}` : e.name | |
| 65 | + if (exclude?.(childRel)) continue | |
| 66 | + const from = path.join(src, childRel) | |
| 67 | + let st | |
| 68 | + try { | |
| 69 | + st = await stat(from) | |
| 70 | + } catch { | |
| 71 | + continue // dangling link | |
| 72 | + } | |
| 73 | + if (st.isDirectory()) { | |
| 74 | + if (SKIP_DIRS.has(e.name)) continue | |
| 75 | + await mkdir(path.join(dest, childRel), { recursive: true }) | |
| 76 | + await copyInto(src, dest, childRel, exclude, depth + 1) | |
| 77 | + } else if (st.isFile()) { | |
| 78 | + await copyFile(from, path.join(dest, childRel)) | |
| 79 | + } | |
| 80 | + } | |
| 81 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,81 @@ | |||
| 1 | +import { randomBytes } from 'node:crypto' | ||
| 2 | +import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises' | ||
| 3 | +import path from 'node:path' | ||
| 4 | + | ||
| 5 | +export function tmpSuffix(): string { | ||
| 6 | + return `${process.pid}-${randomBytes(4).toString('hex')}` | ||
| 7 | +} | ||
| 8 | + | ||
| 9 | +async function renameRetry(from: string, to: string): Promise<void> { | ||
| 10 | + for (let attempt = 0; ; attempt++) { | ||
| 11 | + try { | ||
| 12 | + await rename(from, to) | ||
| 13 | + return | ||
| 14 | + } catch (e) { | ||
| 15 | + const code = (e as NodeJS.ErrnoException).code | ||
| 16 | + if (attempt < 4 && (code === 'EPERM' || code === 'EBUSY' || code === 'EACCES')) { | ||
| 17 | + await new Promise((r) => setTimeout(r, 25 * (attempt + 1))) | ||
| 18 | + continue | ||
| 19 | + } | ||
| 20 | + throw e | ||
| 21 | + } | ||
| 22 | + } | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +/** Writes a file atomically (temp file in the same folder + rename). */ | ||
| 26 | +export async function atomicWrite(file: string, data: string | Uint8Array): Promise<void> { | ||
| 27 | + await mkdir(path.dirname(file), { recursive: true }) | ||
| 28 | + const tmp = `${file}.tmp-${tmpSuffix()}` | ||
| 29 | + try { | ||
| 30 | + await writeFile(tmp, data) | ||
| 31 | + await renameRetry(tmp, file) | ||
| 32 | + } catch (e) { | ||
| 33 | + await rm(tmp, { force: true }).catch(() => {}) | ||
| 34 | + throw e | ||
| 35 | + } | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +export async function movePath(from: string, to: string): Promise<void> { | ||
| 39 | + await mkdir(path.dirname(to), { recursive: true }) | ||
| 40 | + await renameRetry(from, to) | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +const SKIP_DIRS = new Set(['.git', 'node_modules']) | ||
| 44 | + | ||
| 45 | +/** | ||
| 46 | + * Copies a folder, dereferencing symlinks (copies the target content). Skips `.git` and `node_modules`, | ||
| 47 | + * and anything for which `exclude(relPath)` is true (relative path, forward slashes). | ||
| 48 | + */ | ||
| 49 | +export async function copyDir(src: string, dest: string, exclude?: (rel: string) => boolean): Promise<void> { | ||
| 50 | + await mkdir(dest, { recursive: true }) | ||
| 51 | + await copyInto(src, dest, '', exclude, 0) | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +async function copyInto( | ||
| 55 | + src: string, | ||
| 56 | + dest: string, | ||
| 57 | + rel: string, | ||
| 58 | + exclude: ((rel: string) => boolean) | undefined, | ||
| 59 | + depth: number, | ||
| 60 | +): Promise<void> { | ||
| 61 | + if (depth > 32) throw new Error(`Folder too deep (symlink loop?): ${src}`) | ||
| 62 | + const entries = await readdir(path.join(src, rel), { withFileTypes: true }) | ||
| 63 | + for (const e of entries) { | ||
| 64 | + const childRel = rel ? `${rel}/${e.name}` : e.name | ||
| 65 | + if (exclude?.(childRel)) continue | ||
| 66 | + const from = path.join(src, childRel) | ||
| 67 | + let st | ||
| 68 | + try { | ||
| 69 | + st = await stat(from) | ||
| 70 | + } catch { | ||
| 71 | + continue // dangling link | ||
| 72 | + } | ||
| 73 | + if (st.isDirectory()) { | ||
| 74 | + if (SKIP_DIRS.has(e.name)) continue | ||
| 75 | + await mkdir(path.join(dest, childRel), { recursive: true }) | ||
| 76 | + await copyInto(src, dest, childRel, exclude, depth + 1) | ||
| 77 | + } else if (st.isFile()) { | ||
| 78 | + await copyFile(from, path.join(dest, childRel)) | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | +} | ||
modified
packages/core/src/catalog/git.ts +245 -4 | @@ -1,6 +1,247 @@ | ||
| 1 | -import type { OpenCatalogGit } from '../contracts.ts' | |
| 1 | +import { execFile } from 'node:child_process' | |
| 2 | +import { mkdir } from 'node:fs/promises' | |
| 3 | +import path from 'node:path' | |
| 4 | +import type { GitStatus } from '../catalog-model.ts' | |
| 5 | +import type { CatalogGit, OpenCatalogGit } from '../contracts.ts' | |
| 6 | +import { pathExists } from '../util.ts' | |
| 2 | 7 | |
| 3 | -// Placeholder: replaced by the real implementation. | |
| 4 | -export const openCatalogGit: OpenCatalogGit = () => { | |
| 5 | - throw new Error('not implemented') | |
| 8 | +interface GitResult { | |
| 9 | + code: number | |
| 10 | + stdout: string | |
| 11 | + stderr: string | |
| 12 | +} | |
| 13 | + | |
| 14 | +class GitError extends Error { | |
| 15 | + constructor( | |
| 16 | + message: string, | |
| 17 | + readonly result?: GitResult, | |
| 18 | + ) { | |
| 19 | + super(message) | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +function runGit(cwd: string, args: string[]): Promise<GitResult> { | |
| 24 | + return new Promise((resolve, reject) => { | |
| 25 | + execFile( | |
| 26 | + 'git', | |
| 27 | + args, | |
| 28 | + { | |
| 29 | + cwd, | |
| 30 | + env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }, | |
| 31 | + maxBuffer: 64 * 1024 * 1024, | |
| 32 | + windowsHide: true, | |
| 33 | + }, | |
| 34 | + (err, stdout, stderr) => { | |
| 35 | + if (!err) return resolve({ code: 0, stdout, stderr }) | |
| 36 | + const e = err as NodeJS.ErrnoException & { code?: number | string } | |
| 37 | + if (e.code === 'ENOENT') return reject(new Error('git executable not found')) | |
| 38 | + const code = typeof e.code === 'number' ? e.code : 1 | |
| 39 | + resolve({ code, stdout, stderr }) | |
| 40 | + }, | |
| 41 | + ) | |
| 42 | + }) | |
| 43 | +} | |
| 44 | + | |
| 45 | +function failure(args: string[], r: GitResult): GitError { | |
| 46 | + const msg = (r.stderr.trim() || r.stdout.trim() || `git ${args[0]} failed with exit code ${r.code}`).replace(/\r/g, '') | |
| 47 | + return new GitError(msg, r) | |
| 48 | +} | |
| 49 | + | |
| 50 | +export const openCatalogGit: OpenCatalogGit = (rootArg) => new GitCatalog(path.resolve(rootArg)) | |
| 51 | + | |
| 52 | +class GitCatalog implements CatalogGit { | |
| 53 | + constructor(private readonly root: string) {} | |
| 54 | + | |
| 55 | + /** Runs git, throwing an Error with git's message on non-zero exit. */ | |
| 56 | + private async git(args: string[]): Promise<string> { | |
| 57 | + const r = await runGit(this.root, args) | |
| 58 | + if (r.code !== 0) throw failure(args, r) | |
| 59 | + return r.stdout | |
| 60 | + } | |
| 61 | + | |
| 62 | + /** Runs git and returns the result without throwing on non-zero exit. */ | |
| 63 | + private raw(args: string[]): Promise<GitResult> { | |
| 64 | + return runGit(this.root, args) | |
| 65 | + } | |
| 66 | + | |
| 67 | + private async isRepo(): Promise<boolean> { | |
| 68 | + return pathExists(path.join(this.root, '.git')) | |
| 69 | + } | |
| 70 | + | |
| 71 | + private async requireRepo(): Promise<void> { | |
| 72 | + if (!(await this.isRepo())) throw new Error(`Not a git repository: ${this.root}`) | |
| 73 | + } | |
| 74 | + | |
| 75 | + private async identityArgs(): Promise<string[]> { | |
| 76 | + const email = await this.raw(['config', 'user.email']) | |
| 77 | + const name = await this.raw(['config', 'user.name']) | |
| 78 | + const args: string[] = [] | |
| 79 | + if (!name.stdout.trim()) args.push('-c', 'user.name=skilly') | |
| 80 | + if (!email.stdout.trim()) args.push('-c', 'user.email=skilly@localhost') | |
| 81 | + return args | |
| 82 | + } | |
| 83 | + | |
| 84 | + private async remoteName(): Promise<string | undefined> { | |
| 85 | + const r = await this.raw(['remote']) | |
| 86 | + const names = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) | |
| 87 | + if (names.length === 0) return undefined | |
| 88 | + return names.includes('origin') ? 'origin' : names[0] | |
| 89 | + } | |
| 90 | + | |
| 91 | + private async hasCommits(): Promise<boolean> { | |
| 92 | + return (await this.raw(['rev-parse', '--verify', '-q', 'HEAD'])).code === 0 | |
| 93 | + } | |
| 94 | + | |
| 95 | + async status(): Promise<GitStatus> { | |
| 96 | + const st: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] } | |
| 97 | + if (!(await this.isRepo())) return st | |
| 98 | + const r = await this.raw(['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z']) | |
| 99 | + if (r.code !== 0) throw failure(['status'], r) | |
| 100 | + st.isRepo = true | |
| 101 | + parseStatus(r.stdout, st) | |
| 102 | + const remote = await this.remoteName() | |
| 103 | + if (remote) { | |
| 104 | + const url = await this.raw(['remote', 'get-url', remote]) | |
| 105 | + if (url.code === 0 && url.stdout.trim()) st.remote = url.stdout.trim() | |
| 106 | + } | |
| 107 | + const log = await this.raw(['log', '-1', '--format=%H%x1f%s%x1f%aI']) | |
| 108 | + if (log.code === 0 && log.stdout.trim()) { | |
| 109 | + const [hash, message, date] = log.stdout.trim().split('\x1f') | |
| 110 | + if (hash) st.lastCommit = { hash, message: message ?? '', date: date ?? '' } | |
| 111 | + } | |
| 112 | + return st | |
| 113 | + } | |
| 114 | + | |
| 115 | + async init(): Promise<void> { | |
| 116 | + await mkdir(this.root, { recursive: true }) | |
| 117 | + if (await this.isRepo()) return | |
| 118 | + await this.git(['init', '--initial-branch=main']) | |
| 119 | + } | |
| 120 | + | |
| 121 | + async setRemote(url: string): Promise<void> { | |
| 122 | + await this.requireRepo() | |
| 123 | + if (!url.trim()) throw new Error('Remote URL is empty') | |
| 124 | + const existing = await this.remoteName() | |
| 125 | + if (existing) await this.git(['remote', 'set-url', existing, url]) | |
| 126 | + else await this.git(['remote', 'add', 'origin', url]) | |
| 127 | + } | |
| 128 | + | |
| 129 | + async commitAll(message: string): Promise<string | undefined> { | |
| 130 | + await this.requireRepo() | |
| 131 | + await this.git(['add', '-A']) | |
| 132 | + const s = await this.git(['status', '--porcelain']) | |
| 133 | + const merging = (await this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).code === 0 | |
| 134 | + if (!s.trim() && !merging) return undefined | |
| 135 | + await this.git([...(await this.identityArgs()), 'commit', '-m', message || 'skilly sync']) | |
| 136 | + return (await this.currentCommit()) ?? undefined | |
| 137 | + } | |
| 138 | + | |
| 139 | + async currentCommit(): Promise<string | undefined> { | |
| 140 | + if (!(await this.isRepo())) return undefined | |
| 141 | + const r = await this.raw(['rev-parse', 'HEAD']) | |
| 142 | + return r.code === 0 ? r.stdout.trim() || undefined : undefined | |
| 143 | + } | |
| 144 | + | |
| 145 | + async pull(): Promise<GitStatus> { | |
| 146 | + await this.requireRepo() | |
| 147 | + const before = await this.status() | |
| 148 | + if (before.conflicts.length > 0) return before | |
| 149 | + const remote = await this.remoteName() | |
| 150 | + if (!remote) throw new Error('No git remote configured') | |
| 151 | + await this.git(['fetch', remote]) | |
| 152 | + const branch = before.branch && before.branch !== '(detached)' ? before.branch : undefined | |
| 153 | + if (!branch) throw new Error('Cannot pull from a detached HEAD') | |
| 154 | + const ref = `${remote}/${branch}` | |
| 155 | + if ((await this.raw(['rev-parse', '--verify', '-q', `refs/remotes/${ref}`])).code !== 0) return this.status() // remote branch does not exist yet | |
| 156 | + if (!(await this.hasCommits())) { | |
| 157 | + await this.git(['checkout', '-B', branch, '--track', ref]) | |
| 158 | + return this.status() | |
| 159 | + } | |
| 160 | + const args = [...(await this.identityArgs()), 'merge', '--no-edit'] | |
| 161 | + if ((await this.raw(['merge-base', 'HEAD', ref])).code !== 0) args.push('--allow-unrelated-histories') | |
| 162 | + args.push(ref) | |
| 163 | + const r = await this.raw(args) | |
| 164 | + if (r.code !== 0) { | |
| 165 | + const after = await this.status() | |
| 166 | + if (after.conflicts.length > 0) return after | |
| 167 | + throw failure(args, r) | |
| 168 | + } | |
| 169 | + return this.status() | |
| 170 | + } | |
| 171 | + | |
| 172 | + async push(): Promise<GitStatus> { | |
| 173 | + await this.requireRepo() | |
| 174 | + const st = await this.status() | |
| 175 | + if (st.conflicts.length > 0) throw new Error('Resolve conflicts before pushing') | |
| 176 | + const remote = await this.remoteName() | |
| 177 | + if (!remote) throw new Error('No git remote configured') | |
| 178 | + if (!st.branch || st.branch === '(detached)') throw new Error('Cannot push from a detached HEAD') | |
| 179 | + if (!(await this.hasCommits())) throw new Error('Nothing to push: no commits yet') | |
| 180 | + await this.git(['push', '-u', remote, `HEAD:refs/heads/${st.branch}`]) | |
| 181 | + return this.status() | |
| 182 | + } | |
| 183 | + | |
| 184 | + async sync(message = 'skilly sync'): Promise<GitStatus> { | |
| 185 | + await this.requireRepo() | |
| 186 | + await this.commitAll(message) | |
| 187 | + if (!(await this.remoteName())) throw new Error('No git remote configured') | |
| 188 | + const pulled = await this.pull() | |
| 189 | + if (pulled.conflicts.length > 0) return pulled | |
| 190 | + return this.push() | |
| 191 | + } | |
| 192 | + | |
| 193 | + async resolve(file: string, side: 'ours' | 'theirs'): Promise<GitStatus> { | |
| 194 | + await this.requireRepo() | |
| 195 | + if (side !== 'ours' && side !== 'theirs') throw new Error(`Invalid side "${String(side)}"`) | |
| 196 | + const norm = file.replace(/\\/g, '/') | |
| 197 | + if (!norm || path.isAbsolute(file) || /^[A-Za-z]:/.test(norm) || norm.split('/').includes('..')) { | |
| 198 | + throw new Error(`Invalid path: ${file}`) | |
| 199 | + } | |
| 200 | + const co = await this.raw(['checkout', `--${side}`, '--', norm]) | |
| 201 | + if (co.code === 0) { | |
| 202 | + await this.git(['add', '--', norm]) | |
| 203 | + } else { | |
| 204 | + // The chosen side deleted the file: resolve by removing it. | |
| 205 | + const rm = await this.raw(['rm', '-f', '--', norm]) | |
| 206 | + if (rm.code !== 0) throw failure(['checkout'], co) | |
| 207 | + } | |
| 208 | + const st = await this.status() | |
| 209 | + if (st.conflicts.length === 0 && (await this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).code === 0) { | |
| 210 | + await this.git([...(await this.identityArgs()), 'commit', '--no-edit']) | |
| 211 | + return this.status() | |
| 212 | + } | |
| 213 | + return st | |
| 214 | + } | |
| 215 | +} | |
| 216 | + | |
| 217 | +/** Parses `git status --porcelain=v2 --branch -z` output into `st`. */ | |
| 218 | +function parseStatus(out: string, st: GitStatus): void { | |
| 219 | + const parts = out.split('\0') | |
| 220 | + for (let i = 0; i < parts.length; i++) { | |
| 221 | + const line = parts[i] | |
| 222 | + if (!line) continue | |
| 223 | + if (line.startsWith('# ')) { | |
| 224 | + const [key, ...rest] = line.slice(2).split(' ') | |
| 225 | + const val = rest.join(' ') | |
| 226 | + if (key === 'branch.head') st.branch = val | |
| 227 | + else if (key === 'branch.ab') { | |
| 228 | + const m = /^\+(\d+) -(\d+)$/.exec(val) | |
| 229 | + if (m) { | |
| 230 | + st.ahead = Number(m[1]) | |
| 231 | + st.behind = Number(m[2]) | |
| 232 | + } | |
| 233 | + } | |
| 234 | + } else if (line.startsWith('1 ')) { | |
| 235 | + st.dirty.push(line.split(' ').slice(8).join(' ')) | |
| 236 | + } else if (line.startsWith('2 ')) { | |
| 237 | + st.dirty.push(line.split(' ').slice(9).join(' ')) | |
| 238 | + i++ // the original path follows as a separate NUL-terminated field | |
| 239 | + } else if (line.startsWith('u ')) { | |
| 240 | + const p = line.split(' ').slice(10).join(' ') | |
| 241 | + st.dirty.push(p) | |
| 242 | + st.conflicts.push(p) | |
| 243 | + } else if (line.startsWith('? ')) { | |
| 244 | + st.dirty.push(line.slice(2)) | |
| 245 | + } | |
| 246 | + } | |
| 6 | 247 | } |
| @@ -1,6 +1,247 @@ | |||
| 1 | -import type { OpenCatalogGit } from '../contracts.ts' | 1 | +import { execFile } from 'node:child_process' |
| 2 | +import { mkdir } from 'node:fs/promises' | ||
| 3 | +import path from 'node:path' | ||
| 4 | +import type { GitStatus } from '../catalog-model.ts' | ||
| 5 | +import type { CatalogGit, OpenCatalogGit } from '../contracts.ts' | ||
| 6 | +import { pathExists } from '../util.ts' | ||
| 2 | 7 | ||
| 3 | -// Placeholder: replaced by the real implementation. | 8 | +interface GitResult { |
| 4 | -export const openCatalogGit: OpenCatalogGit = () => { | 9 | + code: number |
| 5 | - throw new Error('not implemented') | 10 | + stdout: string |
| 11 | + stderr: string | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +class GitError extends Error { | ||
| 15 | + constructor( | ||
| 16 | + message: string, | ||
| 17 | + readonly result?: GitResult, | ||
| 18 | + ) { | ||
| 19 | + super(message) | ||
| 20 | + } | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +function runGit(cwd: string, args: string[]): Promise<GitResult> { | ||
| 24 | + return new Promise((resolve, reject) => { | ||
| 25 | + execFile( | ||
| 26 | + 'git', | ||
| 27 | + args, | ||
| 28 | + { | ||
| 29 | + cwd, | ||
| 30 | + env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }, | ||
| 31 | + maxBuffer: 64 * 1024 * 1024, | ||
| 32 | + windowsHide: true, | ||
| 33 | + }, | ||
| 34 | + (err, stdout, stderr) => { | ||
| 35 | + if (!err) return resolve({ code: 0, stdout, stderr }) | ||
| 36 | + const e = err as NodeJS.ErrnoException & { code?: number | string } | ||
| 37 | + if (e.code === 'ENOENT') return reject(new Error('git executable not found')) | ||
| 38 | + const code = typeof e.code === 'number' ? e.code : 1 | ||
| 39 | + resolve({ code, stdout, stderr }) | ||
| 40 | + }, | ||
| 41 | + ) | ||
| 42 | + }) | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +function failure(args: string[], r: GitResult): GitError { | ||
| 46 | + const msg = (r.stderr.trim() || r.stdout.trim() || `git ${args[0]} failed with exit code ${r.code}`).replace(/\r/g, '') | ||
| 47 | + return new GitError(msg, r) | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +export const openCatalogGit: OpenCatalogGit = (rootArg) => new GitCatalog(path.resolve(rootArg)) | ||
| 51 | + | ||
| 52 | +class GitCatalog implements CatalogGit { | ||
| 53 | + constructor(private readonly root: string) {} | ||
| 54 | + | ||
| 55 | + /** Runs git, throwing an Error with git's message on non-zero exit. */ | ||
| 56 | + private async git(args: string[]): Promise<string> { | ||
| 57 | + const r = await runGit(this.root, args) | ||
| 58 | + if (r.code !== 0) throw failure(args, r) | ||
| 59 | + return r.stdout | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + /** Runs git and returns the result without throwing on non-zero exit. */ | ||
| 63 | + private raw(args: string[]): Promise<GitResult> { | ||
| 64 | + return runGit(this.root, args) | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + private async isRepo(): Promise<boolean> { | ||
| 68 | + return pathExists(path.join(this.root, '.git')) | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + private async requireRepo(): Promise<void> { | ||
| 72 | + if (!(await this.isRepo())) throw new Error(`Not a git repository: ${this.root}`) | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + private async identityArgs(): Promise<string[]> { | ||
| 76 | + const email = await this.raw(['config', 'user.email']) | ||
| 77 | + const name = await this.raw(['config', 'user.name']) | ||
| 78 | + const args: string[] = [] | ||
| 79 | + if (!name.stdout.trim()) args.push('-c', 'user.name=skilly') | ||
| 80 | + if (!email.stdout.trim()) args.push('-c', 'user.email=skilly@localhost') | ||
| 81 | + return args | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + private async remoteName(): Promise<string | undefined> { | ||
| 85 | + const r = await this.raw(['remote']) | ||
| 86 | + const names = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) | ||
| 87 | + if (names.length === 0) return undefined | ||
| 88 | + return names.includes('origin') ? 'origin' : names[0] | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + private async hasCommits(): Promise<boolean> { | ||
| 92 | + return (await this.raw(['rev-parse', '--verify', '-q', 'HEAD'])).code === 0 | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + async status(): Promise<GitStatus> { | ||
| 96 | + const st: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] } | ||
| 97 | + if (!(await this.isRepo())) return st | ||
| 98 | + const r = await this.raw(['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z']) | ||
| 99 | + if (r.code !== 0) throw failure(['status'], r) | ||
| 100 | + st.isRepo = true | ||
| 101 | + parseStatus(r.stdout, st) | ||
| 102 | + const remote = await this.remoteName() | ||
| 103 | + if (remote) { | ||
| 104 | + const url = await this.raw(['remote', 'get-url', remote]) | ||
| 105 | + if (url.code === 0 && url.stdout.trim()) st.remote = url.stdout.trim() | ||
| 106 | + } | ||
| 107 | + const log = await this.raw(['log', '-1', '--format=%H%x1f%s%x1f%aI']) | ||
| 108 | + if (log.code === 0 && log.stdout.trim()) { | ||
| 109 | + const [hash, message, date] = log.stdout.trim().split('\x1f') | ||
| 110 | + if (hash) st.lastCommit = { hash, message: message ?? '', date: date ?? '' } | ||
| 111 | + } | ||
| 112 | + return st | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + async init(): Promise<void> { | ||
| 116 | + await mkdir(this.root, { recursive: true }) | ||
| 117 | + if (await this.isRepo()) return | ||
| 118 | + await this.git(['init', '--initial-branch=main']) | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + async setRemote(url: string): Promise<void> { | ||
| 122 | + await this.requireRepo() | ||
| 123 | + if (!url.trim()) throw new Error('Remote URL is empty') | ||
| 124 | + const existing = await this.remoteName() | ||
| 125 | + if (existing) await this.git(['remote', 'set-url', existing, url]) | ||
| 126 | + else await this.git(['remote', 'add', 'origin', url]) | ||
| 127 | + } | ||
| 128 | + | ||
| 129 | + async commitAll(message: string): Promise<string | undefined> { | ||
| 130 | + await this.requireRepo() | ||
| 131 | + await this.git(['add', '-A']) | ||
| 132 | + const s = await this.git(['status', '--porcelain']) | ||
| 133 | + const merging = (await this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).code === 0 | ||
| 134 | + if (!s.trim() && !merging) return undefined | ||
| 135 | + await this.git([...(await this.identityArgs()), 'commit', '-m', message || 'skilly sync']) | ||
| 136 | + return (await this.currentCommit()) ?? undefined | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + async currentCommit(): Promise<string | undefined> { | ||
| 140 | + if (!(await this.isRepo())) return undefined | ||
| 141 | + const r = await this.raw(['rev-parse', 'HEAD']) | ||
| 142 | + return r.code === 0 ? r.stdout.trim() || undefined : undefined | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + async pull(): Promise<GitStatus> { | ||
| 146 | + await this.requireRepo() | ||
| 147 | + const before = await this.status() | ||
| 148 | + if (before.conflicts.length > 0) return before | ||
| 149 | + const remote = await this.remoteName() | ||
| 150 | + if (!remote) throw new Error('No git remote configured') | ||
| 151 | + await this.git(['fetch', remote]) | ||
| 152 | + const branch = before.branch && before.branch !== '(detached)' ? before.branch : undefined | ||
| 153 | + if (!branch) throw new Error('Cannot pull from a detached HEAD') | ||
| 154 | + const ref = `${remote}/${branch}` | ||
| 155 | + if ((await this.raw(['rev-parse', '--verify', '-q', `refs/remotes/${ref}`])).code !== 0) return this.status() // remote branch does not exist yet | ||
| 156 | + if (!(await this.hasCommits())) { | ||
| 157 | + await this.git(['checkout', '-B', branch, '--track', ref]) | ||
| 158 | + return this.status() | ||
| 159 | + } | ||
| 160 | + const args = [...(await this.identityArgs()), 'merge', '--no-edit'] | ||
| 161 | + if ((await this.raw(['merge-base', 'HEAD', ref])).code !== 0) args.push('--allow-unrelated-histories') | ||
| 162 | + args.push(ref) | ||
| 163 | + const r = await this.raw(args) | ||
| 164 | + if (r.code !== 0) { | ||
| 165 | + const after = await this.status() | ||
| 166 | + if (after.conflicts.length > 0) return after | ||
| 167 | + throw failure(args, r) | ||
| 168 | + } | ||
| 169 | + return this.status() | ||
| 170 | + } | ||
| 171 | + | ||
| 172 | + async push(): Promise<GitStatus> { | ||
| 173 | + await this.requireRepo() | ||
| 174 | + const st = await this.status() | ||
| 175 | + if (st.conflicts.length > 0) throw new Error('Resolve conflicts before pushing') | ||
| 176 | + const remote = await this.remoteName() | ||
| 177 | + if (!remote) throw new Error('No git remote configured') | ||
| 178 | + if (!st.branch || st.branch === '(detached)') throw new Error('Cannot push from a detached HEAD') | ||
| 179 | + if (!(await this.hasCommits())) throw new Error('Nothing to push: no commits yet') | ||
| 180 | + await this.git(['push', '-u', remote, `HEAD:refs/heads/${st.branch}`]) | ||
| 181 | + return this.status() | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + async sync(message = 'skilly sync'): Promise<GitStatus> { | ||
| 185 | + await this.requireRepo() | ||
| 186 | + await this.commitAll(message) | ||
| 187 | + if (!(await this.remoteName())) throw new Error('No git remote configured') | ||
| 188 | + const pulled = await this.pull() | ||
| 189 | + if (pulled.conflicts.length > 0) return pulled | ||
| 190 | + return this.push() | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + async resolve(file: string, side: 'ours' | 'theirs'): Promise<GitStatus> { | ||
| 194 | + await this.requireRepo() | ||
| 195 | + if (side !== 'ours' && side !== 'theirs') throw new Error(`Invalid side "${String(side)}"`) | ||
| 196 | + const norm = file.replace(/\\/g, '/') | ||
| 197 | + if (!norm || path.isAbsolute(file) || /^[A-Za-z]:/.test(norm) || norm.split('/').includes('..')) { | ||
| 198 | + throw new Error(`Invalid path: ${file}`) | ||
| 199 | + } | ||
| 200 | + const co = await this.raw(['checkout', `--${side}`, '--', norm]) | ||
| 201 | + if (co.code === 0) { | ||
| 202 | + await this.git(['add', '--', norm]) | ||
| 203 | + } else { | ||
| 204 | + // The chosen side deleted the file: resolve by removing it. | ||
| 205 | + const rm = await this.raw(['rm', '-f', '--', norm]) | ||
| 206 | + if (rm.code !== 0) throw failure(['checkout'], co) | ||
| 207 | + } | ||
| 208 | + const st = await this.status() | ||
| 209 | + if (st.conflicts.length === 0 && (await this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).code === 0) { | ||
| 210 | + await this.git([...(await this.identityArgs()), 'commit', '--no-edit']) | ||
| 211 | + return this.status() | ||
| 212 | + } | ||
| 213 | + return st | ||
| 214 | + } | ||
| 215 | +} | ||
| 216 | + | ||
| 217 | +/** Parses `git status --porcelain=v2 --branch -z` output into `st`. */ | ||
| 218 | +function parseStatus(out: string, st: GitStatus): void { | ||
| 219 | + const parts = out.split('\0') | ||
| 220 | + for (let i = 0; i < parts.length; i++) { | ||
| 221 | + const line = parts[i] | ||
| 222 | + if (!line) continue | ||
| 223 | + if (line.startsWith('# ')) { | ||
| 224 | + const [key, ...rest] = line.slice(2).split(' ') | ||
| 225 | + const val = rest.join(' ') | ||
| 226 | + if (key === 'branch.head') st.branch = val | ||
| 227 | + else if (key === 'branch.ab') { | ||
| 228 | + const m = /^\+(\d+) -(\d+)$/.exec(val) | ||
| 229 | + if (m) { | ||
| 230 | + st.ahead = Number(m[1]) | ||
| 231 | + st.behind = Number(m[2]) | ||
| 232 | + } | ||
| 233 | + } | ||
| 234 | + } else if (line.startsWith('1 ')) { | ||
| 235 | + st.dirty.push(line.split(' ').slice(8).join(' ')) | ||
| 236 | + } else if (line.startsWith('2 ')) { | ||
| 237 | + st.dirty.push(line.split(' ').slice(9).join(' ')) | ||
| 238 | + i++ // the original path follows as a separate NUL-terminated field | ||
| 239 | + } else if (line.startsWith('u ')) { | ||
| 240 | + const p = line.split(' ').slice(10).join(' ') | ||
| 241 | + st.dirty.push(p) | ||
| 242 | + st.conflicts.push(p) | ||
| 243 | + } else if (line.startsWith('? ')) { | ||
| 244 | + st.dirty.push(line.slice(2)) | ||
| 245 | + } | ||
| 246 | + } | ||
| 6 | } | 247 | } |
modified
packages/core/src/catalog/index.ts +599 -4 | @@ -1,6 +1,601 @@ | ||
| 1 | -import type { OpenCatalog } from '../contracts.ts' | |
| 1 | +import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import { stringify } from 'yaml' | |
| 4 | +import { | |
| 5 | + CATALOG_ITEM_TYPES, | |
| 6 | + type CatalogFile, | |
| 7 | + type CatalogIndex, | |
| 8 | + type CatalogItem, | |
| 9 | + type CatalogItemDetail, | |
| 10 | + type CatalogItemType, | |
| 11 | + type CatalogRef, | |
| 12 | + type CatalogSnapshot, | |
| 13 | + type HookTemplate, | |
| 14 | + type ItemOrigin, | |
| 15 | + type McpTemplate, | |
| 16 | + type Preset, | |
| 17 | + type SaveItemInput, | |
| 18 | +} from '../catalog-model.ts' | |
| 19 | +import type { CatalogStore, OpenCatalog } from '../contracts.ts' | |
| 20 | +import type { HookDetails, InventoryItem, McpDetails } from '../model.ts' | |
| 21 | +import { hashPath, isDirectory, listDirs, listFilesRecursive, parseFrontmatter, pathExists, readText } from '../util.ts' | |
| 22 | +import { atomicWrite, copyDir, movePath, tmpSuffix } from './fsutil.ts' | |
| 23 | +import { assertValidName, deriveName, isInside, normalizeTags, resolveInside } from './names.ts' | |
| 24 | +import { normalizeSkillMd, parseHookYaml, parseMcpYaml, parseYamlDoc } from './templates.ts' | |
| 2 | 25 | |
| 3 | -// Placeholder: replaced by the real implementation. | |
| 4 | -export const openCatalog: OpenCatalog = () => { | |
| 5 | - throw new Error('not implemented') | |
| 26 | +const TYPE_DIR: Record<CatalogItemType, string> = { | |
| 27 | + skill: 'skills', | |
| 28 | + rule: 'rules', | |
| 29 | + agent: 'agents', | |
| 30 | + mcp: 'mcp', | |
| 31 | + hook: 'hooks', | |
| 32 | +} | |
| 33 | + | |
| 34 | +const PRESET_KEYS: Record<CatalogItemType, string> = { | |
| 35 | + skill: 'skills', | |
| 36 | + rule: 'rules', | |
| 37 | + agent: 'agents', | |
| 38 | + mcp: 'mcp', | |
| 39 | + hook: 'hooks', | |
| 40 | +} | |
| 41 | + | |
| 42 | +const itemKey = (ref: CatalogRef): string => `${ref.type}/${ref.name}` | |
| 43 | + | |
| 44 | +function assertType(type: unknown): asserts type is CatalogItemType { | |
| 45 | + if (!CATALOG_ITEM_TYPES.includes(type as CatalogItemType)) throw new Error(`Unknown item type "${String(type)}"`) | |
| 46 | +} | |
| 47 | + | |
| 48 | +function describeContent(type: CatalogItemType, content: string, name: string): string | undefined { | |
| 49 | + if (type === 'mcp') return parseMcpYaml(content, name).description | |
| 50 | + if (type === 'hook') return parseHookYaml(content, name).description | |
| 51 | + const d = parseFrontmatter(content).frontmatter.description | |
| 52 | + return typeof d === 'string' && d.trim() !== '' ? d : undefined | |
| 53 | +} | |
| 54 | + | |
| 55 | +export const openCatalog: OpenCatalog = (rootArg) => new Catalog(path.resolve(rootArg)) | |
| 56 | + | |
| 57 | +class Catalog implements CatalogStore { | |
| 58 | + readonly root: string | |
| 59 | + private indexLock: Promise<unknown> = Promise.resolve() | |
| 60 | + | |
| 61 | + constructor(root: string) { | |
| 62 | + this.root = root | |
| 63 | + } | |
| 64 | + | |
| 65 | + // ---- paths ---- | |
| 66 | + | |
| 67 | + private typeDir(type: CatalogItemType): string { | |
| 68 | + return path.join(this.root, TYPE_DIR[type]) | |
| 69 | + } | |
| 70 | + | |
| 71 | + private itemPath(ref: CatalogRef): string { | |
| 72 | + assertType(ref.type) | |
| 73 | + assertValidName(ref.name, `${ref.type} name`) | |
| 74 | + const base = this.typeDir(ref.type) | |
| 75 | + const p = | |
| 76 | + ref.type === 'skill' | |
| 77 | + ? path.join(base, ref.name) | |
| 78 | + : path.join(base, ref.name + (ref.type === 'mcp' || ref.type === 'hook' ? '.yaml' : '.md')) | |
| 79 | + if (!isInside(this.root, p)) throw new Error(`Path escapes the catalog: ${p}`) | |
| 80 | + return p | |
| 81 | + } | |
| 82 | + | |
| 83 | + private mainFile(ref: CatalogRef): string { | |
| 84 | + const p = this.itemPath(ref) | |
| 85 | + return ref.type === 'skill' ? path.join(p, 'SKILL.md') : p | |
| 86 | + } | |
| 87 | + | |
| 88 | + private presetPath(name: string): string { | |
| 89 | + assertValidName(name, 'preset name') | |
| 90 | + return path.join(this.root, 'presets', `${name}.yaml`) | |
| 91 | + } | |
| 92 | + | |
| 93 | + private get indexPath(): string { | |
| 94 | + return path.join(this.root, '.skilly', 'index.json') | |
| 95 | + } | |
| 96 | + | |
| 97 | + // ---- index ---- | |
| 98 | + | |
| 99 | + private async readIndex(): Promise<CatalogIndex> { | |
| 100 | + const text = await readText(this.indexPath) | |
| 101 | + if (text === undefined) return { version: 1, items: {} } | |
| 102 | + let raw: unknown | |
| 103 | + try { | |
| 104 | + raw = JSON.parse(text.replace(/^/, '')) | |
| 105 | + } catch (e) { | |
| 106 | + throw new Error(`Invalid .skilly/index.json: ${(e as Error).message}`) | |
| 107 | + } | |
| 108 | + const items = (raw as Partial<CatalogIndex> | null)?.items | |
| 109 | + return { version: 1, items: items && typeof items === 'object' ? items : {} } | |
| 110 | + } | |
| 111 | + | |
| 112 | + private updateIndex(fn: (index: CatalogIndex) => void): Promise<void> { | |
| 113 | + const run = async (): Promise<void> => { | |
| 114 | + const index = await this.readIndex() | |
| 115 | + fn(index) | |
| 116 | + await atomicWrite(this.indexPath, JSON.stringify(index, null, 2) + '\n') | |
| 117 | + } | |
| 118 | + const next = this.indexLock.then(run, run) | |
| 119 | + this.indexLock = next.catch(() => {}) | |
| 120 | + return next | |
| 121 | + } | |
| 122 | + | |
| 123 | + // ---- lifecycle ---- | |
| 124 | + | |
| 125 | + async exists(): Promise<boolean> { | |
| 126 | + return isDirectory(this.root) | |
| 127 | + } | |
| 128 | + | |
| 129 | + async init(): Promise<void> { | |
| 130 | + for (const t of CATALOG_ITEM_TYPES) await mkdir(this.typeDir(t), { recursive: true }) | |
| 131 | + await mkdir(path.join(this.root, 'presets'), { recursive: true }) | |
| 132 | + await mkdir(path.dirname(this.indexPath), { recursive: true }) | |
| 133 | + if (!(await pathExists(this.indexPath))) { | |
| 134 | + await atomicWrite(this.indexPath, JSON.stringify({ version: 1, items: {} }, null, 2) + '\n') | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + // ---- reading ---- | |
| 139 | + | |
| 140 | + private async newestMtime(p: string): Promise<string> { | |
| 141 | + let newest = 0 | |
| 142 | + const files = (await isDirectory(p)) ? (await listFilesRecursive(p, 2000)).map((f) => path.join(p, f)) : [p] | |
| 143 | + for (const f of files) { | |
| 144 | + try { | |
| 145 | + newest = Math.max(newest, (await stat(f)).mtimeMs) | |
| 146 | + } catch { | |
| 147 | + // ignore | |
| 148 | + } | |
| 149 | + } | |
| 150 | + return new Date(newest || Date.now()).toISOString() | |
| 151 | + } | |
| 152 | + | |
| 153 | + private async buildItem( | |
| 154 | + ref: CatalogRef, | |
| 155 | + description: string | undefined, | |
| 156 | + index: CatalogIndex, | |
| 157 | + ): Promise<CatalogItem> { | |
| 158 | + const p = this.itemPath(ref) | |
| 159 | + const entry = index.items[itemKey(ref)] | |
| 160 | + const item: CatalogItem = { | |
| 161 | + type: ref.type, | |
| 162 | + name: ref.name, | |
| 163 | + tags: entry?.tags ?? [], | |
| 164 | + path: p, | |
| 165 | + hash: (await hashPath(p)) ?? '', | |
| 166 | + updatedAt: await this.newestMtime(p), | |
| 167 | + } | |
| 168 | + if (description !== undefined) item.description = description | |
| 169 | + if (entry?.origin) item.origin = entry.origin | |
| 170 | + return item | |
| 171 | + } | |
| 172 | + | |
| 173 | + async snapshot(): Promise<CatalogSnapshot> { | |
| 174 | + const snap: CatalogSnapshot = { root: this.root, exists: await this.exists(), items: [], presets: [], errors: [] } | |
| 175 | + if (!snap.exists) return snap | |
| 176 | + const fail = (p: string, e: unknown): void => { | |
| 177 | + snap.errors.push({ path: p, message: e instanceof Error ? e.message : String(e) }) | |
| 178 | + } | |
| 179 | + let index: CatalogIndex = { version: 1, items: {} } | |
| 180 | + try { | |
| 181 | + index = await this.readIndex() | |
| 182 | + } catch (e) { | |
| 183 | + fail(this.indexPath, e) | |
| 184 | + } | |
| 185 | + for (const type of CATALOG_ITEM_TYPES) { | |
| 186 | + const dir = this.typeDir(type) | |
| 187 | + let names: string[] | |
| 188 | + if (type === 'skill') names = await listDirs(dir) | |
| 189 | + else { | |
| 190 | + const ext = type === 'mcp' || type === 'hook' ? '.yaml' : '.md' | |
| 191 | + try { | |
| 192 | + names = (await readdir(dir, { withFileTypes: true })) | |
| 193 | + .filter((e) => e.isFile() && e.name.endsWith(ext)) | |
| 194 | + .map((e) => e.name.slice(0, -ext.length)) | |
| 195 | + .sort() | |
| 196 | + } catch { | |
| 197 | + names = [] | |
| 198 | + } | |
| 199 | + } | |
| 200 | + for (const name of names) { | |
| 201 | + if (name.startsWith('.')) continue // staging folders, dotfiles | |
| 202 | + const ref = { type, name } | |
| 203 | + try { | |
| 204 | + assertValidName(name, `${type} name`) | |
| 205 | + const content = await readText(this.mainFile(ref)) | |
| 206 | + if (content === undefined) throw new Error(`Missing ${type === 'skill' ? 'SKILL.md' : 'file'}`) | |
| 207 | + snap.items.push(await this.buildItem(ref, describeContent(type, content, name), index)) | |
| 208 | + } catch (e) { | |
| 209 | + fail(path.join(dir, name), e) | |
| 210 | + } | |
| 211 | + } | |
| 212 | + } | |
| 213 | + let presetFiles: string[] = [] | |
| 214 | + try { | |
| 215 | + presetFiles = (await readdir(path.join(this.root, 'presets'))).filter((f) => f.endsWith('.yaml')).sort() | |
| 216 | + } catch { | |
| 217 | + // no presets folder | |
| 218 | + } | |
| 219 | + for (const f of presetFiles) { | |
| 220 | + const file = path.join(this.root, 'presets', f) | |
| 221 | + try { | |
| 222 | + const name = f.slice(0, -'.yaml'.length) | |
| 223 | + if (name.startsWith('.')) continue | |
| 224 | + assertValidName(name, 'preset name') | |
| 225 | + const text = await readFile(file, 'utf8') | |
| 226 | + snap.presets.push(this.parsePreset(text, name)) | |
| 227 | + } catch (e) { | |
| 228 | + fail(file, e) | |
| 229 | + } | |
| 230 | + } | |
| 231 | + snap.items.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name)) | |
| 232 | + return snap | |
| 233 | + } | |
| 234 | + | |
| 235 | + private parsePreset(text: string, name: string): Preset { | |
| 236 | + const doc = parseYamlDoc(text) | |
| 237 | + if (doc.name !== undefined && doc.name !== name) { | |
| 238 | + throw new Error(`Preset name "${String(doc.name)}" does not match file name "${name}"`) | |
| 239 | + } | |
| 240 | + const list = (v: unknown, what: string): string[] => { | |
| 241 | + if (v === undefined || v === null) return [] | |
| 242 | + if (!Array.isArray(v)) throw new Error(`${what} must be a list`) | |
| 243 | + return v.map((x) => String(x)) | |
| 244 | + } | |
| 245 | + const preset: Preset = { name, includes: list(doc.includes, 'includes'), items: {} } | |
| 246 | + if (doc.description !== undefined && doc.description !== null) preset.description = String(doc.description) | |
| 247 | + for (const type of CATALOG_ITEM_TYPES) { | |
| 248 | + const l = list(doc[PRESET_KEYS[type]], PRESET_KEYS[type]) | |
| 249 | + if (l.length > 0) preset.items[type] = l | |
| 250 | + } | |
| 251 | + return preset | |
| 252 | + } | |
| 253 | + | |
| 254 | + async itemFiles(ref: CatalogRef): Promise<{ rel: string; abs: string }[]> { | |
| 255 | + const p = this.itemPath(ref) | |
| 256 | + if (ref.type === 'skill') { | |
| 257 | + if (!(await isDirectory(p))) return [] | |
| 258 | + return (await listFilesRecursive(p, 10_000)).map((rel) => ({ rel, abs: path.join(p, rel) })) | |
| 259 | + } | |
| 260 | + return (await pathExists(p)) ? [{ rel: path.basename(p), abs: p }] : [] | |
| 261 | + } | |
| 262 | + | |
| 263 | + async getItem(ref: CatalogRef): Promise<CatalogItemDetail | undefined> { | |
| 264 | + const main = this.mainFile(ref) | |
| 265 | + const content = await readText(main) | |
| 266 | + if (content === undefined) return undefined | |
| 267 | + let index: CatalogIndex = { version: 1, items: {} } | |
| 268 | + try { | |
| 269 | + index = await this.readIndex() | |
| 270 | + } catch { | |
| 271 | + // tags unavailable | |
| 272 | + } | |
| 273 | + let description: string | undefined | |
| 274 | + let template: McpTemplate | HookTemplate | undefined | |
| 275 | + try { | |
| 276 | + if (ref.type === 'mcp') template = parseMcpYaml(content, ref.name) | |
| 277 | + else if (ref.type === 'hook') template = parseHookYaml(content, ref.name) | |
| 278 | + description = describeContent(ref.type, content, ref.name) | |
| 279 | + } catch { | |
| 280 | + // invalid content: still return it so it can be fixed | |
| 281 | + } | |
| 282 | + const files: CatalogFile[] = [] | |
| 283 | + for (const f of await this.itemFiles(ref)) { | |
| 284 | + try { | |
| 285 | + files.push({ path: f.rel, sizeBytes: (await stat(f.abs)).size }) | |
| 286 | + } catch { | |
| 287 | + // vanished | |
| 288 | + } | |
| 289 | + } | |
| 290 | + const detail: CatalogItemDetail = { item: await this.buildItem(ref, description, index), content, files } | |
| 291 | + if (template) detail.template = template | |
| 292 | + return detail | |
| 293 | + } | |
| 294 | + | |
| 295 | + // ---- writing ---- | |
| 296 | + | |
| 297 | + async saveItem(input: SaveItemInput, origin?: ItemOrigin): Promise<CatalogItem> { | |
| 298 | + assertType(input.type) | |
| 299 | + assertValidName(input.name, `${input.type} name`) | |
| 300 | + const ref = { type: input.type, name: input.name } | |
| 301 | + const prevName = input.previousName && input.previousName !== input.name ? input.previousName : undefined | |
| 302 | + if (prevName) assertValidName(prevName, 'previous name') | |
| 303 | + if (typeof input.content !== 'string') throw new Error('content must be a string') | |
| 304 | + | |
| 305 | + // Validate everything before touching the disk. | |
| 306 | + let content = input.content | |
| 307 | + if (input.type === 'skill') content = normalizeSkillMd(content, input.name) | |
| 308 | + else if (input.type === 'mcp') parseMcpYaml(content, input.name) | |
| 309 | + else if (input.type === 'hook') parseHookYaml(content, input.name) | |
| 310 | + if ((input.files || input.deleteFiles?.length) && input.type !== 'skill') { | |
| 311 | + throw new Error('Extra files are only supported for skills') | |
| 312 | + } | |
| 313 | + const folder = this.itemPath(ref) | |
| 314 | + const extra: [string, string][] = [] | |
| 315 | + const toDelete: string[] = [] | |
| 316 | + if (input.type === 'skill') { | |
| 317 | + for (const [rel, data] of Object.entries(input.files ?? {})) { | |
| 318 | + const abs = resolveInside(folder, rel) | |
| 319 | + if (path.relative(folder, abs).replace(/\\/g, '/').toLowerCase() === 'skill.md') { | |
| 320 | + throw new Error('Use "content" to change SKILL.md') | |
| 321 | + } | |
| 322 | + extra.push([abs, data]) | |
| 323 | + } | |
| 324 | + for (const rel of input.deleteFiles ?? []) { | |
| 325 | + const abs = resolveInside(folder, rel) | |
| 326 | + if (path.relative(folder, abs).replace(/\\/g, '/').toLowerCase() === 'skill.md') { | |
| 327 | + throw new Error('SKILL.md cannot be deleted') | |
| 328 | + } | |
| 329 | + toDelete.push(abs) | |
| 330 | + } | |
| 331 | + } | |
| 332 | + | |
| 333 | + const oldRef = prevName ? { type: input.type, name: prevName } : undefined | |
| 334 | + if (oldRef) { | |
| 335 | + const from = this.itemPath(oldRef) | |
| 336 | + if (!(await pathExists(from))) throw new Error(`Cannot rename: ${input.type} "${prevName}" does not exist`) | |
| 337 | + if (await pathExists(folder)) throw new Error(`Cannot rename: ${input.type} "${input.name}" already exists`) | |
| 338 | + await movePath(from, folder) | |
| 339 | + } | |
| 340 | + const isNew = !oldRef && !(await pathExists(folder)) | |
| 341 | + | |
| 342 | + if (input.type === 'skill') { | |
| 343 | + await atomicWrite(path.join(folder, 'SKILL.md'), content) | |
| 344 | + for (const [abs, data] of extra) await atomicWrite(abs, data) | |
| 345 | + for (const abs of toDelete) await rm(abs, { recursive: true, force: true }) | |
| 346 | + } else { | |
| 347 | + await atomicWrite(folder, content) | |
| 348 | + } | |
| 349 | + | |
| 350 | + const tags = normalizeTags(input.tags) | |
| 351 | + await this.updateIndex((index) => { | |
| 352 | + const key = itemKey(ref) | |
| 353 | + const old = oldRef ? index.items[itemKey(oldRef)] : index.items[key] | |
| 354 | + if (oldRef) delete index.items[itemKey(oldRef)] | |
| 355 | + const entry: CatalogIndex['items'][string] = { tags: tags ?? old?.tags ?? [] } | |
| 356 | + const o = origin ?? old?.origin ?? (isNew ? { kind: 'created' as const, importedAt: new Date().toISOString() } : undefined) | |
| 357 | + if (o) entry.origin = o | |
| 358 | + index.items[key] = entry | |
| 359 | + }) | |
| 360 | + return this.buildItem(ref, describeContent(input.type, content, input.name), await this.readIndex()) | |
| 361 | + } | |
| 362 | + | |
| 363 | + async deleteItem(ref: CatalogRef): Promise<void> { | |
| 364 | + const p = this.itemPath(ref) | |
| 365 | + await rm(p, { recursive: true, force: true }) | |
| 366 | + await this.updateIndex((index) => { | |
| 367 | + delete index.items[itemKey(ref)] | |
| 368 | + }) | |
| 369 | + } | |
| 370 | + | |
| 371 | + async setTags(ref: CatalogRef, tags: string[]): Promise<void> { | |
| 372 | + const p = this.itemPath(ref) | |
| 373 | + if (!(await pathExists(p))) throw new Error(`${ref.type} "${ref.name}" does not exist in the catalog`) | |
| 374 | + const clean = normalizeTags(tags) ?? [] | |
| 375 | + await this.updateIndex((index) => { | |
| 376 | + const key = itemKey(ref) | |
| 377 | + index.items[key] = { ...index.items[key], tags: clean } | |
| 378 | + }) | |
| 379 | + } | |
| 380 | + | |
| 381 | + // ---- imports ---- | |
| 382 | + | |
| 383 | + private async assertCanCreate(ref: CatalogRef, overwrite?: boolean): Promise<void> { | |
| 384 | + if (!overwrite && (await pathExists(this.itemPath(ref)))) { | |
| 385 | + throw new Error(`${ref.type} "${ref.name}" already exists in the catalog`) | |
| 386 | + } | |
| 387 | + } | |
| 388 | + | |
| 389 | + /** Builds a skill folder in a staging dir, then swaps it in. */ | |
| 390 | + private async installSkill( | |
| 391 | + name: string, | |
| 392 | + populate: (stage: string) => Promise<void>, | |
| 393 | + origin: ItemOrigin, | |
| 394 | + opts: { tags?: string[]; overwrite?: boolean }, | |
| 395 | + ): Promise<CatalogItem> { | |
| 396 | + const ref = { type: 'skill' as const, name } | |
| 397 | + await this.assertCanCreate(ref, opts.overwrite) | |
| 398 | + const final = this.itemPath(ref) | |
| 399 | + const stage = path.join(this.typeDir('skill'), `.stage-${name}-${tmpSuffix()}`) | |
| 400 | + try { | |
| 401 | + await mkdir(stage, { recursive: true }) | |
| 402 | + await populate(stage) | |
| 403 | + const skillMd = await readText(path.join(stage, 'SKILL.md')) | |
| 404 | + if (skillMd === undefined) throw new Error('The skill folder has no SKILL.md') | |
| 405 | + const fixed = normalizeSkillMd(skillMd, name, name) | |
| 406 | + if (fixed !== skillMd) await atomicWrite(path.join(stage, 'SKILL.md'), fixed) | |
| 407 | + await rm(final, { recursive: true, force: true }) | |
| 408 | + await movePath(stage, final) | |
| 409 | + } catch (e) { | |
| 410 | + await rm(stage, { recursive: true, force: true }).catch(() => {}) | |
| 411 | + throw e | |
| 412 | + } | |
| 413 | + return this.recordImport(ref, origin, opts.tags, fixedDescription(await readText(path.join(final, 'SKILL.md')))) | |
| 414 | + } | |
| 415 | + | |
| 416 | + private async recordImport(ref: CatalogRef, origin: ItemOrigin, tags: string[] | undefined, description: string | undefined): Promise<CatalogItem> { | |
| 417 | + const clean = normalizeTags(tags) | |
| 418 | + await this.updateIndex((index) => { | |
| 419 | + const old = index.items[itemKey(ref)] | |
| 420 | + index.items[itemKey(ref)] = { tags: clean ?? old?.tags ?? [], origin } | |
| 421 | + }) | |
| 422 | + return this.buildItem(ref, description, await this.readIndex()) | |
| 423 | + } | |
| 424 | + | |
| 425 | + async importSkillDirectory( | |
| 426 | + dir: string, | |
| 427 | + name: string, | |
| 428 | + origin: ItemOrigin, | |
| 429 | + opts: { overwrite?: boolean } = {}, | |
| 430 | + ): Promise<CatalogItem> { | |
| 431 | + assertValidName(name, 'skill name') | |
| 432 | + if (!(await isDirectory(dir))) throw new Error(`Not a folder: ${dir}`) | |
| 433 | + const upstreamHash = origin.upstreamHash ?? (await hashPath(dir)) | |
| 434 | + const full: ItemOrigin = { ...origin } | |
| 435 | + if (upstreamHash) full.upstreamHash = upstreamHash | |
| 436 | + return this.installSkill(name, (stage) => copyDir(dir, stage), full, opts) | |
| 437 | + } | |
| 438 | + | |
| 439 | + async importFromInventory( | |
| 440 | + item: InventoryItem, | |
| 441 | + opts: { name?: string; tags?: string[]; overwrite?: boolean } = {}, | |
| 442 | + ): Promise<CatalogItem> { | |
| 443 | + if (item.type === 'instructions') throw new Error('Instructions files (CLAUDE.md / AGENTS.md) cannot be imported into the catalog') | |
| 444 | + let name = opts.name | |
| 445 | + if (name !== undefined) assertValidName(name, 'name') | |
| 446 | + else { | |
| 447 | + name = deriveName(item.name) | |
| 448 | + if (!name) throw new Error(`Cannot derive a valid catalog name from "${item.name}"; pass an explicit name`) | |
| 449 | + } | |
| 450 | + const origin: ItemOrigin = { kind: 'import', source: item.source.path, importedAt: new Date().toISOString() } | |
| 451 | + const overwrite = opts.overwrite | |
| 452 | + | |
| 453 | + switch (item.type) { | |
| 454 | + case 'skill': { | |
| 455 | + const src = item.source.path | |
| 456 | + const base = path.basename(src) | |
| 457 | + if (item.source.kind === 'file' && base.toLowerCase() !== 'skill.md') { | |
| 458 | + // Legacy Claude command: a single markdown file becomes SKILL.md | |
| 459 | + const text = await readText(src) | |
| 460 | + if (text === undefined) throw new Error(`Cannot read ${src}`) | |
| 461 | + return this.installSkill( | |
| 462 | + name, | |
| 463 | + async (stage) => { | |
| 464 | + await atomicWrite(path.join(stage, 'SKILL.md'), normalizeSkillMd(text, name, item.description ?? name)) | |
| 465 | + }, | |
| 466 | + origin, | |
| 467 | + { tags: opts.tags, overwrite }, | |
| 468 | + ) | |
| 469 | + } | |
| 470 | + const folder = item.source.kind === 'file' ? path.dirname(src) : src | |
| 471 | + if (!(await isDirectory(folder))) throw new Error(`Skill folder not found: ${folder}`) | |
| 472 | + return this.installSkill( | |
| 473 | + name, | |
| 474 | + (stage) => copyDir(folder, stage, (rel) => rel === 'agents/openai.yaml'), | |
| 475 | + origin, | |
| 476 | + { tags: opts.tags, overwrite }, | |
| 477 | + ) | |
| 478 | + } | |
| 479 | + case 'rule': | |
| 480 | + case 'agent': { | |
| 481 | + const ref = { type: item.type, name } | |
| 482 | + await this.assertCanCreate(ref, overwrite) | |
| 483 | + if (item.source.kind !== 'file') throw new Error(`Expected a markdown file for ${item.type} "${item.name}"`) | |
| 484 | + const data = await readFile(item.source.path) | |
| 485 | + await atomicWrite(this.itemPath(ref), data) | |
| 486 | + return this.recordImport(ref, origin, opts.tags, describeContent(item.type, data.toString('utf8'), name)) | |
| 487 | + } | |
| 488 | + case 'mcp': { | |
| 489 | + const ref = { type: 'mcp' as const, name } | |
| 490 | + await this.assertCanCreate(ref, overwrite) | |
| 491 | + const template = mcpTemplateFrom(name, item) | |
| 492 | + const yaml = stringify(template) | |
| 493 | + await atomicWrite(this.itemPath(ref), yaml) | |
| 494 | + return this.recordImport(ref, origin, opts.tags, template.description) | |
| 495 | + } | |
| 496 | + case 'hook': { | |
| 497 | + const ref = { type: 'hook' as const, name } | |
| 498 | + await this.assertCanCreate(ref, overwrite) | |
| 499 | + const template = hookTemplateFrom(name, item) | |
| 500 | + await atomicWrite(this.itemPath(ref), stringify(template)) | |
| 501 | + return this.recordImport(ref, origin, opts.tags, template.description) | |
| 502 | + } | |
| 503 | + } | |
| 504 | + } | |
| 505 | + | |
| 506 | + // ---- presets ---- | |
| 507 | + | |
| 508 | + async savePreset(preset: Preset, previousName?: string): Promise<Preset> { | |
| 509 | + assertValidName(preset.name, 'preset name') | |
| 510 | + const prev = previousName && previousName !== preset.name ? previousName : undefined | |
| 511 | + if (prev) assertValidName(prev, 'previous preset name') | |
| 512 | + const uniq = (l: readonly string[] | undefined, what: string): string[] => { | |
| 513 | + const out = [...new Set(l ?? [])] | |
| 514 | + for (const n of out) assertValidName(n, what) | |
| 515 | + return out | |
| 516 | + } | |
| 517 | + const includes = uniq(preset.includes, 'included preset name') | |
| 518 | + if (includes.includes(preset.name)) throw new Error('A preset cannot include itself') | |
| 519 | + const doc: Record<string, unknown> = { name: preset.name } | |
| 520 | + if (preset.description) doc.description = preset.description | |
| 521 | + doc.includes = includes | |
| 522 | + const items: Preset['items'] = {} | |
| 523 | + for (const type of CATALOG_ITEM_TYPES) { | |
| 524 | + const l = uniq(preset.items?.[type], `${type} name`) | |
| 525 | + doc[PRESET_KEYS[type]] = l | |
| 526 | + if (l.length > 0) items[type] = l | |
| 527 | + } | |
| 528 | + const file = this.presetPath(preset.name) | |
| 529 | + if (prev) { | |
| 530 | + const old = this.presetPath(prev) | |
| 531 | + if (!(await pathExists(old))) throw new Error(`Cannot rename: preset "${prev}" does not exist`) | |
| 532 | + if (await pathExists(file)) throw new Error(`Cannot rename: preset "${preset.name}" already exists`) | |
| 533 | + } | |
| 534 | + await atomicWrite(file, stringify(doc)) | |
| 535 | + if (prev) await rm(this.presetPath(prev), { force: true }) | |
| 536 | + const out: Preset = { name: preset.name, includes, items } | |
| 537 | + if (preset.description) out.description = preset.description | |
| 538 | + return out | |
| 539 | + } | |
| 540 | + | |
| 541 | + async deletePreset(name: string): Promise<void> { | |
| 542 | + await rm(this.presetPath(name), { force: true }) | |
| 543 | + } | |
| 544 | +} | |
| 545 | + | |
| 546 | +function fixedDescription(skillMd: string | undefined): string | undefined { | |
| 547 | + if (skillMd === undefined) return undefined | |
| 548 | + const d = parseFrontmatter(skillMd).frontmatter.description | |
| 549 | + return typeof d === 'string' ? d : undefined | |
| 550 | +} | |
| 551 | + | |
| 552 | +const REDACTED_NOTE = 'Some arguments were redacted on import; edit before use' | |
| 553 | + | |
| 554 | +function mcpTemplateFrom(name: string, item: InventoryItem): McpTemplate { | |
| 555 | + const d = item.details as McpDetails | |
| 556 | + if (d.type !== 'mcp') throw new Error('Not an MCP item') | |
| 557 | + let transport: 'stdio' | 'http' | |
| 558 | + if (d.transport === 'stdio' || d.transport === 'http') transport = d.transport | |
| 559 | + else if (d.transport === 'sse') transport = 'http' | |
| 560 | + else if (d.url) transport = 'http' | |
| 561 | + else if (d.command) transport = 'stdio' | |
| 562 | + else throw new Error(`MCP server "${item.name}" has an unknown transport and no command or url`) | |
| 563 | + | |
| 564 | + const t: McpTemplate = { name, transport } | |
| 565 | + const redacted = (d.args ?? []).some((a) => a.includes('<redacted>')) | |
| 566 | + const notes = [item.description, redacted ? REDACTED_NOTE : undefined].filter(Boolean) | |
| 567 | + if (notes.length > 0) t.description = notes.join('. ') | |
| 568 | + if (transport === 'stdio') { | |
| 569 | + if (!d.command) throw new Error(`MCP server "${item.name}" has no command`) | |
| 570 | + t.command = d.command | |
| 571 | + } else { | |
| 572 | + if (!d.url) throw new Error(`MCP server "${item.name}" has no url`) | |
| 573 | + t.url = d.url | |
| 574 | + } | |
| 575 | + if (d.args && d.args.length > 0) t.args = [...d.args] | |
| 576 | + | |
| 577 | + const env: Record<string, { fromEnv: string }> = {} | |
| 578 | + for (const key of d.literalEnvKeys ?? []) env[key] = { fromEnv: key } | |
| 579 | + let bearer: string | undefined | |
| 580 | + if (transport === 'http') { | |
| 581 | + bearer = d.envVarNames.find((n) => /BEARER|TOKEN|AUTH|API_?KEY|SECRET/i.test(n)) | |
| 582 | + if (bearer) t.auth = { bearerFromEnv: bearer } | |
| 583 | + } | |
| 584 | + for (const n of d.envVarNames ?? []) { | |
| 585 | + if (n === bearer) continue | |
| 586 | + if (!(n in env)) env[n] = { fromEnv: n } | |
| 587 | + } | |
| 588 | + if (Object.keys(env).length > 0) t.env = env | |
| 589 | + return t | |
| 590 | +} | |
| 591 | + | |
| 592 | +function hookTemplateFrom(name: string, item: InventoryItem): HookTemplate { | |
| 593 | + const d = item.details as HookDetails | |
| 594 | + if (d.type !== 'hook') throw new Error('Not a hook item') | |
| 595 | + if (d.handlerType !== 'command') throw new Error(`Only command hooks can be imported (handler type: ${d.handlerType})`) | |
| 596 | + if (!d.command) throw new Error(`Hook "${item.name}" has no command`) | |
| 597 | + const t: HookTemplate = { name, event: d.event, handler: { type: 'command', command: d.command } } | |
| 598 | + if (item.description) t.description = item.description | |
| 599 | + if (d.matcher) t.matcher = d.matcher | |
| 600 | + return t | |
| 6 | 601 | } |
| @@ -1,6 +1,601 @@ | |||
| 1 | -import type { OpenCatalog } from '../contracts.ts' | 1 | +import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' |
| 2 | +import path from 'node:path' | ||
| 3 | +import { stringify } from 'yaml' | ||
| 4 | +import { | ||
| 5 | + CATALOG_ITEM_TYPES, | ||
| 6 | + type CatalogFile, | ||
| 7 | + type CatalogIndex, | ||
| 8 | + type CatalogItem, | ||
| 9 | + type CatalogItemDetail, | ||
| 10 | + type CatalogItemType, | ||
| 11 | + type CatalogRef, | ||
| 12 | + type CatalogSnapshot, | ||
| 13 | + type HookTemplate, | ||
| 14 | + type ItemOrigin, | ||
| 15 | + type McpTemplate, | ||
| 16 | + type Preset, | ||
| 17 | + type SaveItemInput, | ||
| 18 | +} from '../catalog-model.ts' | ||
| 19 | +import type { CatalogStore, OpenCatalog } from '../contracts.ts' | ||
| 20 | +import type { HookDetails, InventoryItem, McpDetails } from '../model.ts' | ||
| 21 | +import { hashPath, isDirectory, listDirs, listFilesRecursive, parseFrontmatter, pathExists, readText } from '../util.ts' | ||
| 22 | +import { atomicWrite, copyDir, movePath, tmpSuffix } from './fsutil.ts' | ||
| 23 | +import { assertValidName, deriveName, isInside, normalizeTags, resolveInside } from './names.ts' | ||
| 24 | +import { normalizeSkillMd, parseHookYaml, parseMcpYaml, parseYamlDoc } from './templates.ts' | ||
| 2 | 25 | ||
| 3 | -// Placeholder: replaced by the real implementation. | 26 | +const TYPE_DIR: Record<CatalogItemType, string> = { |
| 4 | -export const openCatalog: OpenCatalog = () => { | 27 | + skill: 'skills', |
| 5 | - throw new Error('not implemented') | 28 | + rule: 'rules', |
| 29 | + agent: 'agents', | ||
| 30 | + mcp: 'mcp', | ||
| 31 | + hook: 'hooks', | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +const PRESET_KEYS: Record<CatalogItemType, string> = { | ||
| 35 | + skill: 'skills', | ||
| 36 | + rule: 'rules', | ||
| 37 | + agent: 'agents', | ||
| 38 | + mcp: 'mcp', | ||
| 39 | + hook: 'hooks', | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +const itemKey = (ref: CatalogRef): string => `${ref.type}/${ref.name}` | ||
| 43 | + | ||
| 44 | +function assertType(type: unknown): asserts type is CatalogItemType { | ||
| 45 | + if (!CATALOG_ITEM_TYPES.includes(type as CatalogItemType)) throw new Error(`Unknown item type "${String(type)}"`) | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +function describeContent(type: CatalogItemType, content: string, name: string): string | undefined { | ||
| 49 | + if (type === 'mcp') return parseMcpYaml(content, name).description | ||
| 50 | + if (type === 'hook') return parseHookYaml(content, name).description | ||
| 51 | + const d = parseFrontmatter(content).frontmatter.description | ||
| 52 | + return typeof d === 'string' && d.trim() !== '' ? d : undefined | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +export const openCatalog: OpenCatalog = (rootArg) => new Catalog(path.resolve(rootArg)) | ||
| 56 | + | ||
| 57 | +class Catalog implements CatalogStore { | ||
| 58 | + readonly root: string | ||
| 59 | + private indexLock: Promise<unknown> = Promise.resolve() | ||
| 60 | + | ||
| 61 | + constructor(root: string) { | ||
| 62 | + this.root = root | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + // ---- paths ---- | ||
| 66 | + | ||
| 67 | + private typeDir(type: CatalogItemType): string { | ||
| 68 | + return path.join(this.root, TYPE_DIR[type]) | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + private itemPath(ref: CatalogRef): string { | ||
| 72 | + assertType(ref.type) | ||
| 73 | + assertValidName(ref.name, `${ref.type} name`) | ||
| 74 | + const base = this.typeDir(ref.type) | ||
| 75 | + const p = | ||
| 76 | + ref.type === 'skill' | ||
| 77 | + ? path.join(base, ref.name) | ||
| 78 | + : path.join(base, ref.name + (ref.type === 'mcp' || ref.type === 'hook' ? '.yaml' : '.md')) | ||
| 79 | + if (!isInside(this.root, p)) throw new Error(`Path escapes the catalog: ${p}`) | ||
| 80 | + return p | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + private mainFile(ref: CatalogRef): string { | ||
| 84 | + const p = this.itemPath(ref) | ||
| 85 | + return ref.type === 'skill' ? path.join(p, 'SKILL.md') : p | ||
| 86 | + } | ||
| 87 | + | ||
| 88 | + private presetPath(name: string): string { | ||
| 89 | + assertValidName(name, 'preset name') | ||
| 90 | + return path.join(this.root, 'presets', `${name}.yaml`) | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + private get indexPath(): string { | ||
| 94 | + return path.join(this.root, '.skilly', 'index.json') | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + // ---- index ---- | ||
| 98 | + | ||
| 99 | + private async readIndex(): Promise<CatalogIndex> { | ||
| 100 | + const text = await readText(this.indexPath) | ||
| 101 | + if (text === undefined) return { version: 1, items: {} } | ||
| 102 | + let raw: unknown | ||
| 103 | + try { | ||
| 104 | + raw = JSON.parse(text.replace(/^/, '')) | ||
| 105 | + } catch (e) { | ||
| 106 | + throw new Error(`Invalid .skilly/index.json: ${(e as Error).message}`) | ||
| 107 | + } | ||
| 108 | + const items = (raw as Partial<CatalogIndex> | null)?.items | ||
| 109 | + return { version: 1, items: items && typeof items === 'object' ? items : {} } | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + private updateIndex(fn: (index: CatalogIndex) => void): Promise<void> { | ||
| 113 | + const run = async (): Promise<void> => { | ||
| 114 | + const index = await this.readIndex() | ||
| 115 | + fn(index) | ||
| 116 | + await atomicWrite(this.indexPath, JSON.stringify(index, null, 2) + '\n') | ||
| 117 | + } | ||
| 118 | + const next = this.indexLock.then(run, run) | ||
| 119 | + this.indexLock = next.catch(() => {}) | ||
| 120 | + return next | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + // ---- lifecycle ---- | ||
| 124 | + | ||
| 125 | + async exists(): Promise<boolean> { | ||
| 126 | + return isDirectory(this.root) | ||
| 127 | + } | ||
| 128 | + | ||
| 129 | + async init(): Promise<void> { | ||
| 130 | + for (const t of CATALOG_ITEM_TYPES) await mkdir(this.typeDir(t), { recursive: true }) | ||
| 131 | + await mkdir(path.join(this.root, 'presets'), { recursive: true }) | ||
| 132 | + await mkdir(path.dirname(this.indexPath), { recursive: true }) | ||
| 133 | + if (!(await pathExists(this.indexPath))) { | ||
| 134 | + await atomicWrite(this.indexPath, JSON.stringify({ version: 1, items: {} }, null, 2) + '\n') | ||
| 135 | + } | ||
| 136 | + } | ||
| 137 | + | ||
| 138 | + // ---- reading ---- | ||
| 139 | + | ||
| 140 | + private async newestMtime(p: string): Promise<string> { | ||
| 141 | + let newest = 0 | ||
| 142 | + const files = (await isDirectory(p)) ? (await listFilesRecursive(p, 2000)).map((f) => path.join(p, f)) : [p] | ||
| 143 | + for (const f of files) { | ||
| 144 | + try { | ||
| 145 | + newest = Math.max(newest, (await stat(f)).mtimeMs) | ||
| 146 | + } catch { | ||
| 147 | + // ignore | ||
| 148 | + } | ||
| 149 | + } | ||
| 150 | + return new Date(newest || Date.now()).toISOString() | ||
| 151 | + } | ||
| 152 | + | ||
| 153 | + private async buildItem( | ||
| 154 | + ref: CatalogRef, | ||
| 155 | + description: string | undefined, | ||
| 156 | + index: CatalogIndex, | ||
| 157 | + ): Promise<CatalogItem> { | ||
| 158 | + const p = this.itemPath(ref) | ||
| 159 | + const entry = index.items[itemKey(ref)] | ||
| 160 | + const item: CatalogItem = { | ||
| 161 | + type: ref.type, | ||
| 162 | + name: ref.name, | ||
| 163 | + tags: entry?.tags ?? [], | ||
| 164 | + path: p, | ||
| 165 | + hash: (await hashPath(p)) ?? '', | ||
| 166 | + updatedAt: await this.newestMtime(p), | ||
| 167 | + } | ||
| 168 | + if (description !== undefined) item.description = description | ||
| 169 | + if (entry?.origin) item.origin = entry.origin | ||
| 170 | + return item | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + async snapshot(): Promise<CatalogSnapshot> { | ||
| 174 | + const snap: CatalogSnapshot = { root: this.root, exists: await this.exists(), items: [], presets: [], errors: [] } | ||
| 175 | + if (!snap.exists) return snap | ||
| 176 | + const fail = (p: string, e: unknown): void => { | ||
| 177 | + snap.errors.push({ path: p, message: e instanceof Error ? e.message : String(e) }) | ||
| 178 | + } | ||
| 179 | + let index: CatalogIndex = { version: 1, items: {} } | ||
| 180 | + try { | ||
| 181 | + index = await this.readIndex() | ||
| 182 | + } catch (e) { | ||
| 183 | + fail(this.indexPath, e) | ||
| 184 | + } | ||
| 185 | + for (const type of CATALOG_ITEM_TYPES) { | ||
| 186 | + const dir = this.typeDir(type) | ||
| 187 | + let names: string[] | ||
| 188 | + if (type === 'skill') names = await listDirs(dir) | ||
| 189 | + else { | ||
| 190 | + const ext = type === 'mcp' || type === 'hook' ? '.yaml' : '.md' | ||
| 191 | + try { | ||
| 192 | + names = (await readdir(dir, { withFileTypes: true })) | ||
| 193 | + .filter((e) => e.isFile() && e.name.endsWith(ext)) | ||
| 194 | + .map((e) => e.name.slice(0, -ext.length)) | ||
| 195 | + .sort() | ||
| 196 | + } catch { | ||
| 197 | + names = [] | ||
| 198 | + } | ||
| 199 | + } | ||
| 200 | + for (const name of names) { | ||
| 201 | + if (name.startsWith('.')) continue // staging folders, dotfiles | ||
| 202 | + const ref = { type, name } | ||
| 203 | + try { | ||
| 204 | + assertValidName(name, `${type} name`) | ||
| 205 | + const content = await readText(this.mainFile(ref)) | ||
| 206 | + if (content === undefined) throw new Error(`Missing ${type === 'skill' ? 'SKILL.md' : 'file'}`) | ||
| 207 | + snap.items.push(await this.buildItem(ref, describeContent(type, content, name), index)) | ||
| 208 | + } catch (e) { | ||
| 209 | + fail(path.join(dir, name), e) | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + } | ||
| 213 | + let presetFiles: string[] = [] | ||
| 214 | + try { | ||
| 215 | + presetFiles = (await readdir(path.join(this.root, 'presets'))).filter((f) => f.endsWith('.yaml')).sort() | ||
| 216 | + } catch { | ||
| 217 | + // no presets folder | ||
| 218 | + } | ||
| 219 | + for (const f of presetFiles) { | ||
| 220 | + const file = path.join(this.root, 'presets', f) | ||
| 221 | + try { | ||
| 222 | + const name = f.slice(0, -'.yaml'.length) | ||
| 223 | + if (name.startsWith('.')) continue | ||
| 224 | + assertValidName(name, 'preset name') | ||
| 225 | + const text = await readFile(file, 'utf8') | ||
| 226 | + snap.presets.push(this.parsePreset(text, name)) | ||
| 227 | + } catch (e) { | ||
| 228 | + fail(file, e) | ||
| 229 | + } | ||
| 230 | + } | ||
| 231 | + snap.items.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name)) | ||
| 232 | + return snap | ||
| 233 | + } | ||
| 234 | + | ||
| 235 | + private parsePreset(text: string, name: string): Preset { | ||
| 236 | + const doc = parseYamlDoc(text) | ||
| 237 | + if (doc.name !== undefined && doc.name !== name) { | ||
| 238 | + throw new Error(`Preset name "${String(doc.name)}" does not match file name "${name}"`) | ||
| 239 | + } | ||
| 240 | + const list = (v: unknown, what: string): string[] => { | ||
| 241 | + if (v === undefined || v === null) return [] | ||
| 242 | + if (!Array.isArray(v)) throw new Error(`${what} must be a list`) | ||
| 243 | + return v.map((x) => String(x)) | ||
| 244 | + } | ||
| 245 | + const preset: Preset = { name, includes: list(doc.includes, 'includes'), items: {} } | ||
| 246 | + if (doc.description !== undefined && doc.description !== null) preset.description = String(doc.description) | ||
| 247 | + for (const type of CATALOG_ITEM_TYPES) { | ||
| 248 | + const l = list(doc[PRESET_KEYS[type]], PRESET_KEYS[type]) | ||
| 249 | + if (l.length > 0) preset.items[type] = l | ||
| 250 | + } | ||
| 251 | + return preset | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + async itemFiles(ref: CatalogRef): Promise<{ rel: string; abs: string }[]> { | ||
| 255 | + const p = this.itemPath(ref) | ||
| 256 | + if (ref.type === 'skill') { | ||
| 257 | + if (!(await isDirectory(p))) return [] | ||
| 258 | + return (await listFilesRecursive(p, 10_000)).map((rel) => ({ rel, abs: path.join(p, rel) })) | ||
| 259 | + } | ||
| 260 | + return (await pathExists(p)) ? [{ rel: path.basename(p), abs: p }] : [] | ||
| 261 | + } | ||
| 262 | + | ||
| 263 | + async getItem(ref: CatalogRef): Promise<CatalogItemDetail | undefined> { | ||
| 264 | + const main = this.mainFile(ref) | ||
| 265 | + const content = await readText(main) | ||
| 266 | + if (content === undefined) return undefined | ||
| 267 | + let index: CatalogIndex = { version: 1, items: {} } | ||
| 268 | + try { | ||
| 269 | + index = await this.readIndex() | ||
| 270 | + } catch { | ||
| 271 | + // tags unavailable | ||
| 272 | + } | ||
| 273 | + let description: string | undefined | ||
| 274 | + let template: McpTemplate | HookTemplate | undefined | ||
| 275 | + try { | ||
| 276 | + if (ref.type === 'mcp') template = parseMcpYaml(content, ref.name) | ||
| 277 | + else if (ref.type === 'hook') template = parseHookYaml(content, ref.name) | ||
| 278 | + description = describeContent(ref.type, content, ref.name) | ||
| 279 | + } catch { | ||
| 280 | + // invalid content: still return it so it can be fixed | ||
| 281 | + } | ||
| 282 | + const files: CatalogFile[] = [] | ||
| 283 | + for (const f of await this.itemFiles(ref)) { | ||
| 284 | + try { | ||
| 285 | + files.push({ path: f.rel, sizeBytes: (await stat(f.abs)).size }) | ||
| 286 | + } catch { | ||
| 287 | + // vanished | ||
| 288 | + } | ||
| 289 | + } | ||
| 290 | + const detail: CatalogItemDetail = { item: await this.buildItem(ref, description, index), content, files } | ||
| 291 | + if (template) detail.template = template | ||
| 292 | + return detail | ||
| 293 | + } | ||
| 294 | + | ||
| 295 | + // ---- writing ---- | ||
| 296 | + | ||
| 297 | + async saveItem(input: SaveItemInput, origin?: ItemOrigin): Promise<CatalogItem> { | ||
| 298 | + assertType(input.type) | ||
| 299 | + assertValidName(input.name, `${input.type} name`) | ||
| 300 | + const ref = { type: input.type, name: input.name } | ||
| 301 | + const prevName = input.previousName && input.previousName !== input.name ? input.previousName : undefined | ||
| 302 | + if (prevName) assertValidName(prevName, 'previous name') | ||
| 303 | + if (typeof input.content !== 'string') throw new Error('content must be a string') | ||
| 304 | + | ||
| 305 | + // Validate everything before touching the disk. | ||
| 306 | + let content = input.content | ||
| 307 | + if (input.type === 'skill') content = normalizeSkillMd(content, input.name) | ||
| 308 | + else if (input.type === 'mcp') parseMcpYaml(content, input.name) | ||
| 309 | + else if (input.type === 'hook') parseHookYaml(content, input.name) | ||
| 310 | + if ((input.files || input.deleteFiles?.length) && input.type !== 'skill') { | ||
| 311 | + throw new Error('Extra files are only supported for skills') | ||
| 312 | + } | ||
| 313 | + const folder = this.itemPath(ref) | ||
| 314 | + const extra: [string, string][] = [] | ||
| 315 | + const toDelete: string[] = [] | ||
| 316 | + if (input.type === 'skill') { | ||
| 317 | + for (const [rel, data] of Object.entries(input.files ?? {})) { | ||
| 318 | + const abs = resolveInside(folder, rel) | ||
| 319 | + if (path.relative(folder, abs).replace(/\\/g, '/').toLowerCase() === 'skill.md') { | ||
| 320 | + throw new Error('Use "content" to change SKILL.md') | ||
| 321 | + } | ||
| 322 | + extra.push([abs, data]) | ||
| 323 | + } | ||
| 324 | + for (const rel of input.deleteFiles ?? []) { | ||
| 325 | + const abs = resolveInside(folder, rel) | ||
| 326 | + if (path.relative(folder, abs).replace(/\\/g, '/').toLowerCase() === 'skill.md') { | ||
| 327 | + throw new Error('SKILL.md cannot be deleted') | ||
| 328 | + } | ||
| 329 | + toDelete.push(abs) | ||
| 330 | + } | ||
| 331 | + } | ||
| 332 | + | ||
| 333 | + const oldRef = prevName ? { type: input.type, name: prevName } : undefined | ||
| 334 | + if (oldRef) { | ||
| 335 | + const from = this.itemPath(oldRef) | ||
| 336 | + if (!(await pathExists(from))) throw new Error(`Cannot rename: ${input.type} "${prevName}" does not exist`) | ||
| 337 | + if (await pathExists(folder)) throw new Error(`Cannot rename: ${input.type} "${input.name}" already exists`) | ||
| 338 | + await movePath(from, folder) | ||
| 339 | + } | ||
| 340 | + const isNew = !oldRef && !(await pathExists(folder)) | ||
| 341 | + | ||
| 342 | + if (input.type === 'skill') { | ||
| 343 | + await atomicWrite(path.join(folder, 'SKILL.md'), content) | ||
| 344 | + for (const [abs, data] of extra) await atomicWrite(abs, data) | ||
| 345 | + for (const abs of toDelete) await rm(abs, { recursive: true, force: true }) | ||
| 346 | + } else { | ||
| 347 | + await atomicWrite(folder, content) | ||
| 348 | + } | ||
| 349 | + | ||
| 350 | + const tags = normalizeTags(input.tags) | ||
| 351 | + await this.updateIndex((index) => { | ||
| 352 | + const key = itemKey(ref) | ||
| 353 | + const old = oldRef ? index.items[itemKey(oldRef)] : index.items[key] | ||
| 354 | + if (oldRef) delete index.items[itemKey(oldRef)] | ||
| 355 | + const entry: CatalogIndex['items'][string] = { tags: tags ?? old?.tags ?? [] } | ||
| 356 | + const o = origin ?? old?.origin ?? (isNew ? { kind: 'created' as const, importedAt: new Date().toISOString() } : undefined) | ||
| 357 | + if (o) entry.origin = o | ||
| 358 | + index.items[key] = entry | ||
| 359 | + }) | ||
| 360 | + return this.buildItem(ref, describeContent(input.type, content, input.name), await this.readIndex()) | ||
| 361 | + } | ||
| 362 | + | ||
| 363 | + async deleteItem(ref: CatalogRef): Promise<void> { | ||
| 364 | + const p = this.itemPath(ref) | ||
| 365 | + await rm(p, { recursive: true, force: true }) | ||
| 366 | + await this.updateIndex((index) => { | ||
| 367 | + delete index.items[itemKey(ref)] | ||
| 368 | + }) | ||
| 369 | + } | ||
| 370 | + | ||
| 371 | + async setTags(ref: CatalogRef, tags: string[]): Promise<void> { | ||
| 372 | + const p = this.itemPath(ref) | ||
| 373 | + if (!(await pathExists(p))) throw new Error(`${ref.type} "${ref.name}" does not exist in the catalog`) | ||
| 374 | + const clean = normalizeTags(tags) ?? [] | ||
| 375 | + await this.updateIndex((index) => { | ||
| 376 | + const key = itemKey(ref) | ||
| 377 | + index.items[key] = { ...index.items[key], tags: clean } | ||
| 378 | + }) | ||
| 379 | + } | ||
| 380 | + | ||
| 381 | + // ---- imports ---- | ||
| 382 | + | ||
| 383 | + private async assertCanCreate(ref: CatalogRef, overwrite?: boolean): Promise<void> { | ||
| 384 | + if (!overwrite && (await pathExists(this.itemPath(ref)))) { | ||
| 385 | + throw new Error(`${ref.type} "${ref.name}" already exists in the catalog`) | ||
| 386 | + } | ||
| 387 | + } | ||
| 388 | + | ||
| 389 | + /** Builds a skill folder in a staging dir, then swaps it in. */ | ||
| 390 | + private async installSkill( | ||
| 391 | + name: string, | ||
| 392 | + populate: (stage: string) => Promise<void>, | ||
| 393 | + origin: ItemOrigin, | ||
| 394 | + opts: { tags?: string[]; overwrite?: boolean }, | ||
| 395 | + ): Promise<CatalogItem> { | ||
| 396 | + const ref = { type: 'skill' as const, name } | ||
| 397 | + await this.assertCanCreate(ref, opts.overwrite) | ||
| 398 | + const final = this.itemPath(ref) | ||
| 399 | + const stage = path.join(this.typeDir('skill'), `.stage-${name}-${tmpSuffix()}`) | ||
| 400 | + try { | ||
| 401 | + await mkdir(stage, { recursive: true }) | ||
| 402 | + await populate(stage) | ||
| 403 | + const skillMd = await readText(path.join(stage, 'SKILL.md')) | ||
| 404 | + if (skillMd === undefined) throw new Error('The skill folder has no SKILL.md') | ||
| 405 | + const fixed = normalizeSkillMd(skillMd, name, name) | ||
| 406 | + if (fixed !== skillMd) await atomicWrite(path.join(stage, 'SKILL.md'), fixed) | ||
| 407 | + await rm(final, { recursive: true, force: true }) | ||
| 408 | + await movePath(stage, final) | ||
| 409 | + } catch (e) { | ||
| 410 | + await rm(stage, { recursive: true, force: true }).catch(() => {}) | ||
| 411 | + throw e | ||
| 412 | + } | ||
| 413 | + return this.recordImport(ref, origin, opts.tags, fixedDescription(await readText(path.join(final, 'SKILL.md')))) | ||
| 414 | + } | ||
| 415 | + | ||
| 416 | + private async recordImport(ref: CatalogRef, origin: ItemOrigin, tags: string[] | undefined, description: string | undefined): Promise<CatalogItem> { | ||
| 417 | + const clean = normalizeTags(tags) | ||
| 418 | + await this.updateIndex((index) => { | ||
| 419 | + const old = index.items[itemKey(ref)] | ||
| 420 | + index.items[itemKey(ref)] = { tags: clean ?? old?.tags ?? [], origin } | ||
| 421 | + }) | ||
| 422 | + return this.buildItem(ref, description, await this.readIndex()) | ||
| 423 | + } | ||
| 424 | + | ||
| 425 | + async importSkillDirectory( | ||
| 426 | + dir: string, | ||
| 427 | + name: string, | ||
| 428 | + origin: ItemOrigin, | ||
| 429 | + opts: { overwrite?: boolean } = {}, | ||
| 430 | + ): Promise<CatalogItem> { | ||
| 431 | + assertValidName(name, 'skill name') | ||
| 432 | + if (!(await isDirectory(dir))) throw new Error(`Not a folder: ${dir}`) | ||
| 433 | + const upstreamHash = origin.upstreamHash ?? (await hashPath(dir)) | ||
| 434 | + const full: ItemOrigin = { ...origin } | ||
| 435 | + if (upstreamHash) full.upstreamHash = upstreamHash | ||
| 436 | + return this.installSkill(name, (stage) => copyDir(dir, stage), full, opts) | ||
| 437 | + } | ||
| 438 | + | ||
| 439 | + async importFromInventory( | ||
| 440 | + item: InventoryItem, | ||
| 441 | + opts: { name?: string; tags?: string[]; overwrite?: boolean } = {}, | ||
| 442 | + ): Promise<CatalogItem> { | ||
| 443 | + if (item.type === 'instructions') throw new Error('Instructions files (CLAUDE.md / AGENTS.md) cannot be imported into the catalog') | ||
| 444 | + let name = opts.name | ||
| 445 | + if (name !== undefined) assertValidName(name, 'name') | ||
| 446 | + else { | ||
| 447 | + name = deriveName(item.name) | ||
| 448 | + if (!name) throw new Error(`Cannot derive a valid catalog name from "${item.name}"; pass an explicit name`) | ||
| 449 | + } | ||
| 450 | + const origin: ItemOrigin = { kind: 'import', source: item.source.path, importedAt: new Date().toISOString() } | ||
| 451 | + const overwrite = opts.overwrite | ||
| 452 | + | ||
| 453 | + switch (item.type) { | ||
| 454 | + case 'skill': { | ||
| 455 | + const src = item.source.path | ||
| 456 | + const base = path.basename(src) | ||
| 457 | + if (item.source.kind === 'file' && base.toLowerCase() !== 'skill.md') { | ||
| 458 | + // Legacy Claude command: a single markdown file becomes SKILL.md | ||
| 459 | + const text = await readText(src) | ||
| 460 | + if (text === undefined) throw new Error(`Cannot read ${src}`) | ||
| 461 | + return this.installSkill( | ||
| 462 | + name, | ||
| 463 | + async (stage) => { | ||
| 464 | + await atomicWrite(path.join(stage, 'SKILL.md'), normalizeSkillMd(text, name, item.description ?? name)) | ||
| 465 | + }, | ||
| 466 | + origin, | ||
| 467 | + { tags: opts.tags, overwrite }, | ||
| 468 | + ) | ||
| 469 | + } | ||
| 470 | + const folder = item.source.kind === 'file' ? path.dirname(src) : src | ||
| 471 | + if (!(await isDirectory(folder))) throw new Error(`Skill folder not found: ${folder}`) | ||
| 472 | + return this.installSkill( | ||
| 473 | + name, | ||
| 474 | + (stage) => copyDir(folder, stage, (rel) => rel === 'agents/openai.yaml'), | ||
| 475 | + origin, | ||
| 476 | + { tags: opts.tags, overwrite }, | ||
| 477 | + ) | ||
| 478 | + } | ||
| 479 | + case 'rule': | ||
| 480 | + case 'agent': { | ||
| 481 | + const ref = { type: item.type, name } | ||
| 482 | + await this.assertCanCreate(ref, overwrite) | ||
| 483 | + if (item.source.kind !== 'file') throw new Error(`Expected a markdown file for ${item.type} "${item.name}"`) | ||
| 484 | + const data = await readFile(item.source.path) | ||
| 485 | + await atomicWrite(this.itemPath(ref), data) | ||
| 486 | + return this.recordImport(ref, origin, opts.tags, describeContent(item.type, data.toString('utf8'), name)) | ||
| 487 | + } | ||
| 488 | + case 'mcp': { | ||
| 489 | + const ref = { type: 'mcp' as const, name } | ||
| 490 | + await this.assertCanCreate(ref, overwrite) | ||
| 491 | + const template = mcpTemplateFrom(name, item) | ||
| 492 | + const yaml = stringify(template) | ||
| 493 | + await atomicWrite(this.itemPath(ref), yaml) | ||
| 494 | + return this.recordImport(ref, origin, opts.tags, template.description) | ||
| 495 | + } | ||
| 496 | + case 'hook': { | ||
| 497 | + const ref = { type: 'hook' as const, name } | ||
| 498 | + await this.assertCanCreate(ref, overwrite) | ||
| 499 | + const template = hookTemplateFrom(name, item) | ||
| 500 | + await atomicWrite(this.itemPath(ref), stringify(template)) | ||
| 501 | + return this.recordImport(ref, origin, opts.tags, template.description) | ||
| 502 | + } | ||
| 503 | + } | ||
| 504 | + } | ||
| 505 | + | ||
| 506 | + // ---- presets ---- | ||
| 507 | + | ||
| 508 | + async savePreset(preset: Preset, previousName?: string): Promise<Preset> { | ||
| 509 | + assertValidName(preset.name, 'preset name') | ||
| 510 | + const prev = previousName && previousName !== preset.name ? previousName : undefined | ||
| 511 | + if (prev) assertValidName(prev, 'previous preset name') | ||
| 512 | + const uniq = (l: readonly string[] | undefined, what: string): string[] => { | ||
| 513 | + const out = [...new Set(l ?? [])] | ||
| 514 | + for (const n of out) assertValidName(n, what) | ||
| 515 | + return out | ||
| 516 | + } | ||
| 517 | + const includes = uniq(preset.includes, 'included preset name') | ||
| 518 | + if (includes.includes(preset.name)) throw new Error('A preset cannot include itself') | ||
| 519 | + const doc: Record<string, unknown> = { name: preset.name } | ||
| 520 | + if (preset.description) doc.description = preset.description | ||
| 521 | + doc.includes = includes | ||
| 522 | + const items: Preset['items'] = {} | ||
| 523 | + for (const type of CATALOG_ITEM_TYPES) { | ||
| 524 | + const l = uniq(preset.items?.[type], `${type} name`) | ||
| 525 | + doc[PRESET_KEYS[type]] = l | ||
| 526 | + if (l.length > 0) items[type] = l | ||
| 527 | + } | ||
| 528 | + const file = this.presetPath(preset.name) | ||
| 529 | + if (prev) { | ||
| 530 | + const old = this.presetPath(prev) | ||
| 531 | + if (!(await pathExists(old))) throw new Error(`Cannot rename: preset "${prev}" does not exist`) | ||
| 532 | + if (await pathExists(file)) throw new Error(`Cannot rename: preset "${preset.name}" already exists`) | ||
| 533 | + } | ||
| 534 | + await atomicWrite(file, stringify(doc)) | ||
| 535 | + if (prev) await rm(this.presetPath(prev), { force: true }) | ||
| 536 | + const out: Preset = { name: preset.name, includes, items } | ||
| 537 | + if (preset.description) out.description = preset.description | ||
| 538 | + return out | ||
| 539 | + } | ||
| 540 | + | ||
| 541 | + async deletePreset(name: string): Promise<void> { | ||
| 542 | + await rm(this.presetPath(name), { force: true }) | ||
| 543 | + } | ||
| 544 | +} | ||
| 545 | + | ||
| 546 | +function fixedDescription(skillMd: string | undefined): string | undefined { | ||
| 547 | + if (skillMd === undefined) return undefined | ||
| 548 | + const d = parseFrontmatter(skillMd).frontmatter.description | ||
| 549 | + return typeof d === 'string' ? d : undefined | ||
| 550 | +} | ||
| 551 | + | ||
| 552 | +const REDACTED_NOTE = 'Some arguments were redacted on import; edit before use' | ||
| 553 | + | ||
| 554 | +function mcpTemplateFrom(name: string, item: InventoryItem): McpTemplate { | ||
| 555 | + const d = item.details as McpDetails | ||
| 556 | + if (d.type !== 'mcp') throw new Error('Not an MCP item') | ||
| 557 | + let transport: 'stdio' | 'http' | ||
| 558 | + if (d.transport === 'stdio' || d.transport === 'http') transport = d.transport | ||
| 559 | + else if (d.transport === 'sse') transport = 'http' | ||
| 560 | + else if (d.url) transport = 'http' | ||
| 561 | + else if (d.command) transport = 'stdio' | ||
| 562 | + else throw new Error(`MCP server "${item.name}" has an unknown transport and no command or url`) | ||
| 563 | + | ||
| 564 | + const t: McpTemplate = { name, transport } | ||
| 565 | + const redacted = (d.args ?? []).some((a) => a.includes('<redacted>')) | ||
| 566 | + const notes = [item.description, redacted ? REDACTED_NOTE : undefined].filter(Boolean) | ||
| 567 | + if (notes.length > 0) t.description = notes.join('. ') | ||
| 568 | + if (transport === 'stdio') { | ||
| 569 | + if (!d.command) throw new Error(`MCP server "${item.name}" has no command`) | ||
| 570 | + t.command = d.command | ||
| 571 | + } else { | ||
| 572 | + if (!d.url) throw new Error(`MCP server "${item.name}" has no url`) | ||
| 573 | + t.url = d.url | ||
| 574 | + } | ||
| 575 | + if (d.args && d.args.length > 0) t.args = [...d.args] | ||
| 576 | + | ||
| 577 | + const env: Record<string, { fromEnv: string }> = {} | ||
| 578 | + for (const key of d.literalEnvKeys ?? []) env[key] = { fromEnv: key } | ||
| 579 | + let bearer: string | undefined | ||
| 580 | + if (transport === 'http') { | ||
| 581 | + bearer = d.envVarNames.find((n) => /BEARER|TOKEN|AUTH|API_?KEY|SECRET/i.test(n)) | ||
| 582 | + if (bearer) t.auth = { bearerFromEnv: bearer } | ||
| 583 | + } | ||
| 584 | + for (const n of d.envVarNames ?? []) { | ||
| 585 | + if (n === bearer) continue | ||
| 586 | + if (!(n in env)) env[n] = { fromEnv: n } | ||
| 587 | + } | ||
| 588 | + if (Object.keys(env).length > 0) t.env = env | ||
| 589 | + return t | ||
| 590 | +} | ||
| 591 | + | ||
| 592 | +function hookTemplateFrom(name: string, item: InventoryItem): HookTemplate { | ||
| 593 | + const d = item.details as HookDetails | ||
| 594 | + if (d.type !== 'hook') throw new Error('Not a hook item') | ||
| 595 | + if (d.handlerType !== 'command') throw new Error(`Only command hooks can be imported (handler type: ${d.handlerType})`) | ||
| 596 | + if (!d.command) throw new Error(`Hook "${item.name}" has no command`) | ||
| 597 | + const t: HookTemplate = { name, event: d.event, handler: { type: 'command', command: d.command } } | ||
| 598 | + if (item.description) t.description = item.description | ||
| 599 | + if (d.matcher) t.matcher = d.matcher | ||
| 600 | + return t | ||
| 6 | } | 601 | } |
added
packages/core/src/catalog/names.ts +47 -0 | new file mode 100644 | ||
| @@ -0,0 +1,47 @@ | ||
| 1 | +import path from 'node:path' | |
| 2 | + | |
| 3 | +export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/ | |
| 4 | + | |
| 5 | +export function assertValidName(name: unknown, what = 'name'): asserts name is string { | |
| 6 | + if (typeof name !== 'string' || !NAME_RE.test(name) || name.endsWith('.') || name.includes('..')) { | |
| 7 | + throw new Error( | |
| 8 | + `Invalid ${what} "${String(name)}": use lower-case letters, digits, ".", "_" or "-", starting with a letter or digit`, | |
| 9 | + ) | |
| 10 | + } | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** Derives a valid catalog name from an inventory name (`plugin:skill` -> `skill`). Returns undefined when nothing usable remains. */ | |
| 14 | +export function deriveName(raw: string): string | undefined { | |
| 15 | + const last = raw.split(/[:/\\]/).filter(Boolean).pop() ?? '' | |
| 16 | + const cleaned = last | |
| 17 | + .toLowerCase() | |
| 18 | + .replace(/[^a-z0-9._-]+/g, '-') | |
| 19 | + .replace(/\.{2,}/g, '.') | |
| 20 | + .replace(/^[^a-z0-9]+/, '') | |
| 21 | + .replace(/[-.]+$/, '') | |
| 22 | + return cleaned && NAME_RE.test(cleaned) ? cleaned : undefined | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** True when `child` is `base` or inside it. */ | |
| 26 | +export function isInside(base: string, child: string): boolean { | |
| 27 | + const rel = path.relative(path.resolve(base), path.resolve(child)) | |
| 28 | + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** Resolves a relative path inside `base`, rejecting absolute paths and `..` segments. */ | |
| 32 | +export function resolveInside(base: string, rel: string): string { | |
| 33 | + if (typeof rel !== 'string' || rel.trim() === '') throw new Error('Empty file path') | |
| 34 | + const norm = rel.replace(/\\/g, '/') | |
| 35 | + if (norm.startsWith('/') || /^[A-Za-z]:/.test(norm) || path.isAbsolute(rel)) { | |
| 36 | + throw new Error(`Path must be relative: ${rel}`) | |
| 37 | + } | |
| 38 | + if (norm.split('/').some((s) => s === '..')) throw new Error(`Path escapes the folder: ${rel}`) | |
| 39 | + const abs = path.resolve(base, norm) | |
| 40 | + if (abs === path.resolve(base) || !isInside(base, abs)) throw new Error(`Path escapes the folder: ${rel}`) | |
| 41 | + return abs | |
| 42 | +} | |
| 43 | + | |
| 44 | +export function normalizeTags(tags: readonly string[] | undefined): string[] | undefined { | |
| 45 | + if (!tags) return undefined | |
| 46 | + return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))] | |
| 47 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | +import path from 'node:path' | ||
| 2 | + | ||
| 3 | +export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/ | ||
| 4 | + | ||
| 5 | +export function assertValidName(name: unknown, what = 'name'): asserts name is string { | ||
| 6 | + if (typeof name !== 'string' || !NAME_RE.test(name) || name.endsWith('.') || name.includes('..')) { | ||
| 7 | + throw new Error( | ||
| 8 | + `Invalid ${what} "${String(name)}": use lower-case letters, digits, ".", "_" or "-", starting with a letter or digit`, | ||
| 9 | + ) | ||
| 10 | + } | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +/** Derives a valid catalog name from an inventory name (`plugin:skill` -> `skill`). Returns undefined when nothing usable remains. */ | ||
| 14 | +export function deriveName(raw: string): string | undefined { | ||
| 15 | + const last = raw.split(/[:/\\]/).filter(Boolean).pop() ?? '' | ||
| 16 | + const cleaned = last | ||
| 17 | + .toLowerCase() | ||
| 18 | + .replace(/[^a-z0-9._-]+/g, '-') | ||
| 19 | + .replace(/\.{2,}/g, '.') | ||
| 20 | + .replace(/^[^a-z0-9]+/, '') | ||
| 21 | + .replace(/[-.]+$/, '') | ||
| 22 | + return cleaned && NAME_RE.test(cleaned) ? cleaned : undefined | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +/** True when `child` is `base` or inside it. */ | ||
| 26 | +export function isInside(base: string, child: string): boolean { | ||
| 27 | + const rel = path.relative(path.resolve(base), path.resolve(child)) | ||
| 28 | + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +/** Resolves a relative path inside `base`, rejecting absolute paths and `..` segments. */ | ||
| 32 | +export function resolveInside(base: string, rel: string): string { | ||
| 33 | + if (typeof rel !== 'string' || rel.trim() === '') throw new Error('Empty file path') | ||
| 34 | + const norm = rel.replace(/\\/g, '/') | ||
| 35 | + if (norm.startsWith('/') || /^[A-Za-z]:/.test(norm) || path.isAbsolute(rel)) { | ||
| 36 | + throw new Error(`Path must be relative: ${rel}`) | ||
| 37 | + } | ||
| 38 | + if (norm.split('/').some((s) => s === '..')) throw new Error(`Path escapes the folder: ${rel}`) | ||
| 39 | + const abs = path.resolve(base, norm) | ||
| 40 | + if (abs === path.resolve(base) || !isInside(base, abs)) throw new Error(`Path escapes the folder: ${rel}`) | ||
| 41 | + return abs | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +export function normalizeTags(tags: readonly string[] | undefined): string[] | undefined { | ||
| 45 | + if (!tags) return undefined | ||
| 46 | + return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))] | ||
| 47 | +} | ||
added
packages/core/src/catalog/templates.ts +122 -0 | new file mode 100644 | ||
| @@ -0,0 +1,122 @@ | ||
| 1 | +import matter from 'gray-matter' | |
| 2 | +import { parse } from 'yaml' | |
| 3 | +import type { HookTemplate, McpEnvValue, McpTemplate } from '../catalog-model.ts' | |
| 4 | +import { AGENTS, type AgentId } from '../model.ts' | |
| 5 | +import { stripBom } from '../util.ts' | |
| 6 | + | |
| 7 | +function isObj(v: unknown): v is Record<string, unknown> { | |
| 8 | + return typeof v === 'object' && v !== null && !Array.isArray(v) | |
| 9 | +} | |
| 10 | + | |
| 11 | +function str(v: unknown, what: string): string { | |
| 12 | + if (typeof v !== 'string' || v.trim() === '') throw new Error(`${what} must be a non-empty string`) | |
| 13 | + return v | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function parseYamlDoc(content: string): Record<string, unknown> { | |
| 17 | + let doc: unknown | |
| 18 | + try { | |
| 19 | + doc = parse(stripBom(content)) | |
| 20 | + } catch (e) { | |
| 21 | + throw new Error(`Invalid YAML: ${(e as Error).message}`) | |
| 22 | + } | |
| 23 | + if (!isObj(doc)) throw new Error('YAML content must be a mapping') | |
| 24 | + return doc | |
| 25 | +} | |
| 26 | + | |
| 27 | +function envMap(v: unknown, what: string): Record<string, McpEnvValue> | undefined { | |
| 28 | + if (v === undefined || v === null) return undefined | |
| 29 | + if (!isObj(v)) throw new Error(`${what} must be a mapping`) | |
| 30 | + const out: Record<string, McpEnvValue> = {} | |
| 31 | + for (const [k, raw] of Object.entries(v)) { | |
| 32 | + if (!isObj(raw)) throw new Error(`${what}.${k} must be a mapping with fromEnv or value`) | |
| 33 | + const entry: McpEnvValue = {} | |
| 34 | + if (raw.fromEnv !== undefined) entry.fromEnv = str(raw.fromEnv, `${what}.${k}.fromEnv`) | |
| 35 | + if (raw.value !== undefined) entry.value = String(raw.value) | |
| 36 | + if (entry.fromEnv === undefined && entry.value === undefined) throw new Error(`${what}.${k} needs fromEnv or value`) | |
| 37 | + out[k] = entry | |
| 38 | + } | |
| 39 | + return out | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** Validates an object against McpTemplate and returns a cleaned copy. `expectedName` must match `name`. */ | |
| 43 | +export function validateMcpTemplate(doc: unknown, expectedName: string): McpTemplate { | |
| 44 | + if (!isObj(doc)) throw new Error('MCP template must be a mapping') | |
| 45 | + const name = str(doc.name, 'name') | |
| 46 | + if (name !== expectedName) throw new Error(`Template name "${name}" does not match item name "${expectedName}"`) | |
| 47 | + const transport = doc.transport | |
| 48 | + if (transport !== 'stdio' && transport !== 'http') throw new Error('transport must be "stdio" or "http"') | |
| 49 | + const t: McpTemplate = { name, transport } | |
| 50 | + if (doc.description !== undefined && doc.description !== null) t.description = String(doc.description) | |
| 51 | + if (transport === 'stdio') t.command = str(doc.command, 'command (required for stdio)') | |
| 52 | + else t.url = str(doc.url, 'url (required for http)') | |
| 53 | + if (transport === 'http' && doc.command != null) t.command = str(doc.command, 'command') | |
| 54 | + if (transport === 'stdio' && doc.url != null) t.url = str(doc.url, 'url') | |
| 55 | + if (doc.args !== undefined && doc.args !== null) { | |
| 56 | + if (!Array.isArray(doc.args)) throw new Error('args must be a list') | |
| 57 | + t.args = doc.args.map((a) => String(a)) | |
| 58 | + } | |
| 59 | + const env = envMap(doc.env, 'env') | |
| 60 | + if (env) t.env = env | |
| 61 | + const headers = envMap(doc.headers, 'headers') | |
| 62 | + if (headers) t.headers = headers | |
| 63 | + if (doc.auth !== undefined && doc.auth !== null) { | |
| 64 | + if (!isObj(doc.auth)) throw new Error('auth must be a mapping') | |
| 65 | + t.auth = { bearerFromEnv: str(doc.auth.bearerFromEnv, 'auth.bearerFromEnv') } | |
| 66 | + } | |
| 67 | + return t | |
| 68 | +} | |
| 69 | + | |
| 70 | +export function validateHookTemplate(doc: unknown, expectedName: string): HookTemplate { | |
| 71 | + if (!isObj(doc)) throw new Error('Hook template must be a mapping') | |
| 72 | + const name = str(doc.name, 'name') | |
| 73 | + if (name !== expectedName) throw new Error(`Template name "${name}" does not match item name "${expectedName}"`) | |
| 74 | + const event = str(doc.event, 'event') | |
| 75 | + if (!isObj(doc.handler)) throw new Error('handler is required (handler.type: command, handler.command)') | |
| 76 | + if (doc.handler.type !== undefined && doc.handler.type !== 'command') throw new Error('handler.type must be "command"') | |
| 77 | + const handler: HookTemplate['handler'] = { type: 'command', command: str(doc.handler.command, 'handler.command') } | |
| 78 | + if (doc.handler.timeout !== undefined && doc.handler.timeout !== null) { | |
| 79 | + const n = Number(doc.handler.timeout) | |
| 80 | + if (!Number.isFinite(n) || n <= 0) throw new Error('handler.timeout must be a positive number') | |
| 81 | + handler.timeout = n | |
| 82 | + } | |
| 83 | + const t: HookTemplate = { name, event, handler } | |
| 84 | + if (doc.description !== undefined && doc.description !== null) t.description = String(doc.description) | |
| 85 | + if (doc.matcher !== undefined && doc.matcher !== null) t.matcher = String(doc.matcher) | |
| 86 | + if (doc.agents !== undefined && doc.agents !== null) { | |
| 87 | + if (!Array.isArray(doc.agents)) throw new Error('agents must be a list') | |
| 88 | + for (const a of doc.agents) { | |
| 89 | + if (!AGENTS.includes(a as AgentId)) throw new Error(`Unknown agent "${String(a)}" (expected ${AGENTS.join(', ')})`) | |
| 90 | + } | |
| 91 | + t.agents = doc.agents as AgentId[] | |
| 92 | + } | |
| 93 | + return t | |
| 94 | +} | |
| 95 | + | |
| 96 | +export function parseMcpYaml(content: string, name: string): McpTemplate { | |
| 97 | + return validateMcpTemplate(parseYamlDoc(content), name) | |
| 98 | +} | |
| 99 | + | |
| 100 | +export function parseHookYaml(content: string, name: string): HookTemplate { | |
| 101 | + return validateHookTemplate(parseYamlDoc(content), name) | |
| 102 | +} | |
| 103 | + | |
| 104 | +/** | |
| 105 | + * Returns SKILL.md content with `name` (= item name) and a `description` in its frontmatter. | |
| 106 | + * Content is returned untouched when it already complies. Throws when no description is available. | |
| 107 | + */ | |
| 108 | +export function normalizeSkillMd(content: string, name: string, fallbackDescription?: string): string { | |
| 109 | + let parsed | |
| 110 | + try { | |
| 111 | + parsed = matter(stripBom(content), {}) | |
| 112 | + } catch (e) { | |
| 113 | + throw new Error(`Invalid SKILL.md frontmatter: ${(e as Error).message}`) | |
| 114 | + } | |
| 115 | + const data = (parsed.data ?? {}) as Record<string, unknown> | |
| 116 | + const desc = typeof data.description === 'string' && data.description.trim() !== '' ? data.description : undefined | |
| 117 | + const finalDesc = desc ?? fallbackDescription | |
| 118 | + if (!finalDesc) throw new Error('SKILL.md needs a "description" in its frontmatter') | |
| 119 | + if (data.name === name && desc) return content | |
| 120 | + const { name: _n, description: _d, ...rest } = data | |
| 121 | + return matter.stringify(parsed.content, { name, description: finalDesc, ...rest }) | |
| 122 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,122 @@ | |||
| 1 | +import matter from 'gray-matter' | ||
| 2 | +import { parse } from 'yaml' | ||
| 3 | +import type { HookTemplate, McpEnvValue, McpTemplate } from '../catalog-model.ts' | ||
| 4 | +import { AGENTS, type AgentId } from '../model.ts' | ||
| 5 | +import { stripBom } from '../util.ts' | ||
| 6 | + | ||
| 7 | +function isObj(v: unknown): v is Record<string, unknown> { | ||
| 8 | + return typeof v === 'object' && v !== null && !Array.isArray(v) | ||
| 9 | +} | ||
| 10 | + | ||
| 11 | +function str(v: unknown, what: string): string { | ||
| 12 | + if (typeof v !== 'string' || v.trim() === '') throw new Error(`${what} must be a non-empty string`) | ||
| 13 | + return v | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +export function parseYamlDoc(content: string): Record<string, unknown> { | ||
| 17 | + let doc: unknown | ||
| 18 | + try { | ||
| 19 | + doc = parse(stripBom(content)) | ||
| 20 | + } catch (e) { | ||
| 21 | + throw new Error(`Invalid YAML: ${(e as Error).message}`) | ||
| 22 | + } | ||
| 23 | + if (!isObj(doc)) throw new Error('YAML content must be a mapping') | ||
| 24 | + return doc | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +function envMap(v: unknown, what: string): Record<string, McpEnvValue> | undefined { | ||
| 28 | + if (v === undefined || v === null) return undefined | ||
| 29 | + if (!isObj(v)) throw new Error(`${what} must be a mapping`) | ||
| 30 | + const out: Record<string, McpEnvValue> = {} | ||
| 31 | + for (const [k, raw] of Object.entries(v)) { | ||
| 32 | + if (!isObj(raw)) throw new Error(`${what}.${k} must be a mapping with fromEnv or value`) | ||
| 33 | + const entry: McpEnvValue = {} | ||
| 34 | + if (raw.fromEnv !== undefined) entry.fromEnv = str(raw.fromEnv, `${what}.${k}.fromEnv`) | ||
| 35 | + if (raw.value !== undefined) entry.value = String(raw.value) | ||
| 36 | + if (entry.fromEnv === undefined && entry.value === undefined) throw new Error(`${what}.${k} needs fromEnv or value`) | ||
| 37 | + out[k] = entry | ||
| 38 | + } | ||
| 39 | + return out | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +/** Validates an object against McpTemplate and returns a cleaned copy. `expectedName` must match `name`. */ | ||
| 43 | +export function validateMcpTemplate(doc: unknown, expectedName: string): McpTemplate { | ||
| 44 | + if (!isObj(doc)) throw new Error('MCP template must be a mapping') | ||
| 45 | + const name = str(doc.name, 'name') | ||
| 46 | + if (name !== expectedName) throw new Error(`Template name "${name}" does not match item name "${expectedName}"`) | ||
| 47 | + const transport = doc.transport | ||
| 48 | + if (transport !== 'stdio' && transport !== 'http') throw new Error('transport must be "stdio" or "http"') | ||
| 49 | + const t: McpTemplate = { name, transport } | ||
| 50 | + if (doc.description !== undefined && doc.description !== null) t.description = String(doc.description) | ||
| 51 | + if (transport === 'stdio') t.command = str(doc.command, 'command (required for stdio)') | ||
| 52 | + else t.url = str(doc.url, 'url (required for http)') | ||
| 53 | + if (transport === 'http' && doc.command != null) t.command = str(doc.command, 'command') | ||
| 54 | + if (transport === 'stdio' && doc.url != null) t.url = str(doc.url, 'url') | ||
| 55 | + if (doc.args !== undefined && doc.args !== null) { | ||
| 56 | + if (!Array.isArray(doc.args)) throw new Error('args must be a list') | ||
| 57 | + t.args = doc.args.map((a) => String(a)) | ||
| 58 | + } | ||
| 59 | + const env = envMap(doc.env, 'env') | ||
| 60 | + if (env) t.env = env | ||
| 61 | + const headers = envMap(doc.headers, 'headers') | ||
| 62 | + if (headers) t.headers = headers | ||
| 63 | + if (doc.auth !== undefined && doc.auth !== null) { | ||
| 64 | + if (!isObj(doc.auth)) throw new Error('auth must be a mapping') | ||
| 65 | + t.auth = { bearerFromEnv: str(doc.auth.bearerFromEnv, 'auth.bearerFromEnv') } | ||
| 66 | + } | ||
| 67 | + return t | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +export function validateHookTemplate(doc: unknown, expectedName: string): HookTemplate { | ||
| 71 | + if (!isObj(doc)) throw new Error('Hook template must be a mapping') | ||
| 72 | + const name = str(doc.name, 'name') | ||
| 73 | + if (name !== expectedName) throw new Error(`Template name "${name}" does not match item name "${expectedName}"`) | ||
| 74 | + const event = str(doc.event, 'event') | ||
| 75 | + if (!isObj(doc.handler)) throw new Error('handler is required (handler.type: command, handler.command)') | ||
| 76 | + if (doc.handler.type !== undefined && doc.handler.type !== 'command') throw new Error('handler.type must be "command"') | ||
| 77 | + const handler: HookTemplate['handler'] = { type: 'command', command: str(doc.handler.command, 'handler.command') } | ||
| 78 | + if (doc.handler.timeout !== undefined && doc.handler.timeout !== null) { | ||
| 79 | + const n = Number(doc.handler.timeout) | ||
| 80 | + if (!Number.isFinite(n) || n <= 0) throw new Error('handler.timeout must be a positive number') | ||
| 81 | + handler.timeout = n | ||
| 82 | + } | ||
| 83 | + const t: HookTemplate = { name, event, handler } | ||
| 84 | + if (doc.description !== undefined && doc.description !== null) t.description = String(doc.description) | ||
| 85 | + if (doc.matcher !== undefined && doc.matcher !== null) t.matcher = String(doc.matcher) | ||
| 86 | + if (doc.agents !== undefined && doc.agents !== null) { | ||
| 87 | + if (!Array.isArray(doc.agents)) throw new Error('agents must be a list') | ||
| 88 | + for (const a of doc.agents) { | ||
| 89 | + if (!AGENTS.includes(a as AgentId)) throw new Error(`Unknown agent "${String(a)}" (expected ${AGENTS.join(', ')})`) | ||
| 90 | + } | ||
| 91 | + t.agents = doc.agents as AgentId[] | ||
| 92 | + } | ||
| 93 | + return t | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +export function parseMcpYaml(content: string, name: string): McpTemplate { | ||
| 97 | + return validateMcpTemplate(parseYamlDoc(content), name) | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +export function parseHookYaml(content: string, name: string): HookTemplate { | ||
| 101 | + return validateHookTemplate(parseYamlDoc(content), name) | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +/** | ||
| 105 | + * Returns SKILL.md content with `name` (= item name) and a `description` in its frontmatter. | ||
| 106 | + * Content is returned untouched when it already complies. Throws when no description is available. | ||
| 107 | + */ | ||
| 108 | +export function normalizeSkillMd(content: string, name: string, fallbackDescription?: string): string { | ||
| 109 | + let parsed | ||
| 110 | + try { | ||
| 111 | + parsed = matter(stripBom(content), {}) | ||
| 112 | + } catch (e) { | ||
| 113 | + throw new Error(`Invalid SKILL.md frontmatter: ${(e as Error).message}`) | ||
| 114 | + } | ||
| 115 | + const data = (parsed.data ?? {}) as Record<string, unknown> | ||
| 116 | + const desc = typeof data.description === 'string' && data.description.trim() !== '' ? data.description : undefined | ||
| 117 | + const finalDesc = desc ?? fallbackDescription | ||
| 118 | + if (!finalDesc) throw new Error('SKILL.md needs a "description" in its frontmatter') | ||
| 119 | + if (data.name === name && desc) return content | ||
| 120 | + const { name: _n, description: _d, ...rest } = data | ||
| 121 | + return matter.stringify(parsed.content, { name, description: finalDesc, ...rest }) | ||
| 122 | +} | ||
added
packages/core/test/catalog-git.test.ts +124 -0 | new file mode 100644 | ||
| @@ -0,0 +1,124 @@ | ||
| 1 | +import { execFileSync } from 'node:child_process' | |
| 2 | +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 3 | +import os from 'node:os' | |
| 4 | +import path from 'node:path' | |
| 5 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' | |
| 6 | +import { openCatalogGit } from '../src/catalog/git.ts' | |
| 7 | + | |
| 8 | +let tmp: string | |
| 9 | +let remote: string | |
| 10 | +let a: string | |
| 11 | +let b: string | |
| 12 | + | |
| 13 | +const git = (cwd: string, ...args: string[]): string => | |
| 14 | + execFileSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }) | |
| 15 | + | |
| 16 | +async function setup(dir: string): Promise<void> { | |
| 17 | + await mkdir(dir, { recursive: true }) | |
| 18 | + const g = openCatalogGit(dir) | |
| 19 | + await g.init() | |
| 20 | + git(dir, 'config', 'user.name', 'Test') | |
| 21 | + git(dir, 'config', 'user.email', 'test@example.com') | |
| 22 | + git(dir, 'config', 'core.autocrlf', 'false') | |
| 23 | + await g.setRemote(remote) | |
| 24 | +} | |
| 25 | + | |
| 26 | +beforeAll(async () => { | |
| 27 | + tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-git-')) | |
| 28 | + remote = path.join(tmp, 'remote.git') | |
| 29 | + git(tmp, 'init', '--bare', '--initial-branch=main', remote) | |
| 30 | + a = path.join(tmp, 'a') | |
| 31 | + b = path.join(tmp, 'b') | |
| 32 | + await setup(a) | |
| 33 | + await setup(b) | |
| 34 | +}) | |
| 35 | + | |
| 36 | +afterAll(async () => { | |
| 37 | + await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch(() => {}) | |
| 38 | +}) | |
| 39 | + | |
| 40 | +describe('catalog git', { timeout: 60_000 }, () => { | |
| 41 | + it('reports non-repos', async () => { | |
| 42 | + const st = await openCatalogGit(path.join(tmp, 'nothing')).status() | |
| 43 | + expect(st.isRepo).toBe(false) | |
| 44 | + expect(st.dirty).toEqual([]) | |
| 45 | + }) | |
| 46 | + | |
| 47 | + it('commits, pushes and reports status', async () => { | |
| 48 | + const g = openCatalogGit(a) | |
| 49 | + expect(await g.commitAll('empty')).toBeUndefined() | |
| 50 | + await mkdir(path.join(a, 'skills', 'x'), { recursive: true }) | |
| 51 | + await writeFile(path.join(a, 'skills', 'x', 'SKILL.md'), 'one\n') | |
| 52 | + await writeFile(path.join(a, 'note.txt'), 'n\n') | |
| 53 | + const dirty = await g.status() | |
| 54 | + expect(dirty.isRepo).toBe(true) | |
| 55 | + expect(dirty.branch).toBe('main') | |
| 56 | + expect(dirty.dirty.sort()).toEqual(['note.txt', 'skills/x/SKILL.md']) | |
| 57 | + expect(dirty.remote).toBe(remote) | |
| 58 | + const hash = await g.commitAll('first') | |
| 59 | + expect(hash).toMatch(/^[0-9a-f]{40}$/) | |
| 60 | + expect(await g.currentCommit()).toBe(hash) | |
| 61 | + let st = await g.status() | |
| 62 | + expect(st.dirty).toEqual([]) | |
| 63 | + expect(st.lastCommit?.message).toBe('first') | |
| 64 | + await g.push() | |
| 65 | + st = await g.status() | |
| 66 | + expect(st.ahead).toBe(0) | |
| 67 | + expect(st.behind).toBe(0) | |
| 68 | + }) | |
| 69 | + | |
| 70 | + it('pulls into an empty local repo, then sees remote changes', async () => { | |
| 71 | + const gb = openCatalogGit(b) | |
| 72 | + let st = await gb.pull() | |
| 73 | + expect(st.conflicts).toEqual([]) | |
| 74 | + expect(await readFile(path.join(b, 'skills', 'x', 'SKILL.md'), 'utf8')).toBe('one\n') | |
| 75 | + | |
| 76 | + const ga = openCatalogGit(a) | |
| 77 | + await writeFile(path.join(a, 'note.txt'), 'n2\n') | |
| 78 | + await ga.sync('second') | |
| 79 | + await git(b, 'fetch') | |
| 80 | + st = await gb.status() | |
| 81 | + expect(st.behind).toBe(1) | |
| 82 | + st = await gb.pull() | |
| 83 | + expect(st.behind).toBe(0) | |
| 84 | + expect(await readFile(path.join(b, 'note.txt'), 'utf8')).toBe('n2\n') | |
| 85 | + }) | |
| 86 | + | |
| 87 | + it('returns conflicts on pull and resolves them', async () => { | |
| 88 | + const ga = openCatalogGit(a) | |
| 89 | + const gb = openCatalogGit(b) | |
| 90 | + await writeFile(path.join(a, 'note.txt'), 'from a\n') | |
| 91 | + await ga.sync('a edit') | |
| 92 | + await writeFile(path.join(b, 'note.txt'), 'from b\n') | |
| 93 | + await gb.commitAll('b edit') | |
| 94 | + const st = await gb.pull() | |
| 95 | + expect(st.conflicts).toEqual(['note.txt']) | |
| 96 | + // pushing is refused while conflicted | |
| 97 | + await expect(gb.push()).rejects.toThrow(/conflict/i) | |
| 98 | + const res = await gb.resolve('note.txt', 'theirs') | |
| 99 | + expect(res.conflicts).toEqual([]) | |
| 100 | + expect(await readFile(path.join(b, 'note.txt'), 'utf8')).toBe('from a\n') | |
| 101 | + expect(res.ahead).toBeGreaterThan(0) | |
| 102 | + const pushed = await gb.push() | |
| 103 | + expect(pushed.ahead).toBe(0) | |
| 104 | + | |
| 105 | + // 'ours' side | |
| 106 | + await writeFile(path.join(a, 'note.txt'), 'a again\n') | |
| 107 | + await ga.sync('a edit 2') | |
| 108 | + await writeFile(path.join(b, 'note.txt'), 'b again\n') | |
| 109 | + await gb.commitAll('b edit 2') | |
| 110 | + expect((await gb.pull()).conflicts).toEqual(['note.txt']) | |
| 111 | + await gb.resolve('note.txt', 'ours') | |
| 112 | + expect(await readFile(path.join(b, 'note.txt'), 'utf8')).toBe('b again\n') | |
| 113 | + }) | |
| 114 | + | |
| 115 | + it('surfaces git errors as Errors', async () => { | |
| 116 | + const dir = path.join(tmp, 'norepo') | |
| 117 | + await mkdir(dir) | |
| 118 | + await expect(openCatalogGit(dir).push()).rejects.toThrow(/Not a git repository/) | |
| 119 | + const g = openCatalogGit(a) | |
| 120 | + await g.setRemote(path.join(tmp, 'does-not-exist.git')) | |
| 121 | + await expect(g.pull()).rejects.toThrow(/does-not-exist|repository/i) | |
| 122 | + await g.setRemote(remote) | |
| 123 | + }) | |
| 124 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,124 @@ | |||
| 1 | +import { execFileSync } from 'node:child_process' | ||
| 2 | +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | ||
| 3 | +import os from 'node:os' | ||
| 4 | +import path from 'node:path' | ||
| 5 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' | ||
| 6 | +import { openCatalogGit } from '../src/catalog/git.ts' | ||
| 7 | + | ||
| 8 | +let tmp: string | ||
| 9 | +let remote: string | ||
| 10 | +let a: string | ||
| 11 | +let b: string | ||
| 12 | + | ||
| 13 | +const git = (cwd: string, ...args: string[]): string => | ||
| 14 | + execFileSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }) | ||
| 15 | + | ||
| 16 | +async function setup(dir: string): Promise<void> { | ||
| 17 | + await mkdir(dir, { recursive: true }) | ||
| 18 | + const g = openCatalogGit(dir) | ||
| 19 | + await g.init() | ||
| 20 | + git(dir, 'config', 'user.name', 'Test') | ||
| 21 | + git(dir, 'config', 'user.email', 'test@example.com') | ||
| 22 | + git(dir, 'config', 'core.autocrlf', 'false') | ||
| 23 | + await g.setRemote(remote) | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +beforeAll(async () => { | ||
| 27 | + tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-git-')) | ||
| 28 | + remote = path.join(tmp, 'remote.git') | ||
| 29 | + git(tmp, 'init', '--bare', '--initial-branch=main', remote) | ||
| 30 | + a = path.join(tmp, 'a') | ||
| 31 | + b = path.join(tmp, 'b') | ||
| 32 | + await setup(a) | ||
| 33 | + await setup(b) | ||
| 34 | +}) | ||
| 35 | + | ||
| 36 | +afterAll(async () => { | ||
| 37 | + await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch(() => {}) | ||
| 38 | +}) | ||
| 39 | + | ||
| 40 | +describe('catalog git', { timeout: 60_000 }, () => { | ||
| 41 | + it('reports non-repos', async () => { | ||
| 42 | + const st = await openCatalogGit(path.join(tmp, 'nothing')).status() | ||
| 43 | + expect(st.isRepo).toBe(false) | ||
| 44 | + expect(st.dirty).toEqual([]) | ||
| 45 | + }) | ||
| 46 | + | ||
| 47 | + it('commits, pushes and reports status', async () => { | ||
| 48 | + const g = openCatalogGit(a) | ||
| 49 | + expect(await g.commitAll('empty')).toBeUndefined() | ||
| 50 | + await mkdir(path.join(a, 'skills', 'x'), { recursive: true }) | ||
| 51 | + await writeFile(path.join(a, 'skills', 'x', 'SKILL.md'), 'one\n') | ||
| 52 | + await writeFile(path.join(a, 'note.txt'), 'n\n') | ||
| 53 | + const dirty = await g.status() | ||
| 54 | + expect(dirty.isRepo).toBe(true) | ||
| 55 | + expect(dirty.branch).toBe('main') | ||
| 56 | + expect(dirty.dirty.sort()).toEqual(['note.txt', 'skills/x/SKILL.md']) | ||
| 57 | + expect(dirty.remote).toBe(remote) | ||
| 58 | + const hash = await g.commitAll('first') | ||
| 59 | + expect(hash).toMatch(/^[0-9a-f]{40}$/) | ||
| 60 | + expect(await g.currentCommit()).toBe(hash) | ||
| 61 | + let st = await g.status() | ||
| 62 | + expect(st.dirty).toEqual([]) | ||
| 63 | + expect(st.lastCommit?.message).toBe('first') | ||
| 64 | + await g.push() | ||
| 65 | + st = await g.status() | ||
| 66 | + expect(st.ahead).toBe(0) | ||
| 67 | + expect(st.behind).toBe(0) | ||
| 68 | + }) | ||
| 69 | + | ||
| 70 | + it('pulls into an empty local repo, then sees remote changes', async () => { | ||
| 71 | + const gb = openCatalogGit(b) | ||
| 72 | + let st = await gb.pull() | ||
| 73 | + expect(st.conflicts).toEqual([]) | ||
| 74 | + expect(await readFile(path.join(b, 'skills', 'x', 'SKILL.md'), 'utf8')).toBe('one\n') | ||
| 75 | + | ||
| 76 | + const ga = openCatalogGit(a) | ||
| 77 | + await writeFile(path.join(a, 'note.txt'), 'n2\n') | ||
| 78 | + await ga.sync('second') | ||
| 79 | + await git(b, 'fetch') | ||
| 80 | + st = await gb.status() | ||
| 81 | + expect(st.behind).toBe(1) | ||
| 82 | + st = await gb.pull() | ||
| 83 | + expect(st.behind).toBe(0) | ||
| 84 | + expect(await readFile(path.join(b, 'note.txt'), 'utf8')).toBe('n2\n') | ||
| 85 | + }) | ||
| 86 | + | ||
| 87 | + it('returns conflicts on pull and resolves them', async () => { | ||
| 88 | + const ga = openCatalogGit(a) | ||
| 89 | + const gb = openCatalogGit(b) | ||
| 90 | + await writeFile(path.join(a, 'note.txt'), 'from a\n') | ||
| 91 | + await ga.sync('a edit') | ||
| 92 | + await writeFile(path.join(b, 'note.txt'), 'from b\n') | ||
| 93 | + await gb.commitAll('b edit') | ||
| 94 | + const st = await gb.pull() | ||
| 95 | + expect(st.conflicts).toEqual(['note.txt']) | ||
| 96 | + // pushing is refused while conflicted | ||
| 97 | + await expect(gb.push()).rejects.toThrow(/conflict/i) | ||
| 98 | + const res = await gb.resolve('note.txt', 'theirs') | ||
| 99 | + expect(res.conflicts).toEqual([]) | ||
| 100 | + expect(await readFile(path.join(b, 'note.txt'), 'utf8')).toBe('from a\n') | ||
| 101 | + expect(res.ahead).toBeGreaterThan(0) | ||
| 102 | + const pushed = await gb.push() | ||
| 103 | + expect(pushed.ahead).toBe(0) | ||
| 104 | + | ||
| 105 | + // 'ours' side | ||
| 106 | + await writeFile(path.join(a, 'note.txt'), 'a again\n') | ||
| 107 | + await ga.sync('a edit 2') | ||
| 108 | + await writeFile(path.join(b, 'note.txt'), 'b again\n') | ||
| 109 | + await gb.commitAll('b edit 2') | ||
| 110 | + expect((await gb.pull()).conflicts).toEqual(['note.txt']) | ||
| 111 | + await gb.resolve('note.txt', 'ours') | ||
| 112 | + expect(await readFile(path.join(b, 'note.txt'), 'utf8')).toBe('b again\n') | ||
| 113 | + }) | ||
| 114 | + | ||
| 115 | + it('surfaces git errors as Errors', async () => { | ||
| 116 | + const dir = path.join(tmp, 'norepo') | ||
| 117 | + await mkdir(dir) | ||
| 118 | + await expect(openCatalogGit(dir).push()).rejects.toThrow(/Not a git repository/) | ||
| 119 | + const g = openCatalogGit(a) | ||
| 120 | + await g.setRemote(path.join(tmp, 'does-not-exist.git')) | ||
| 121 | + await expect(g.pull()).rejects.toThrow(/does-not-exist|repository/i) | ||
| 122 | + await g.setRemote(remote) | ||
| 123 | + }) | ||
| 124 | +}) | ||
added
packages/core/test/catalog.test.ts +332 -0 | new file mode 100644 | ||
| @@ -0,0 +1,332 @@ | ||
| 1 | +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' | |
| 2 | +import os from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { afterAll, beforeEach, describe, expect, it } from 'vitest' | |
| 5 | +import { openCatalog } from '../src/catalog/index.ts' | |
| 6 | +import type { CatalogStore } from '../src/contracts.ts' | |
| 7 | +import type { InventoryItem } from '../src/model.ts' | |
| 8 | +import { hashPath } from '../src/util.ts' | |
| 9 | + | |
| 10 | +let tmp: string | |
| 11 | +let n = 0 | |
| 12 | +let root: string | |
| 13 | +let cat: CatalogStore | |
| 14 | + | |
| 15 | +const roots: string[] = [] | |
| 16 | +beforeEach(async () => { | |
| 17 | + tmp ??= await mkdtemp(path.join(os.tmpdir(), 'skilly-catalog-')) | |
| 18 | + roots.push(tmp) | |
| 19 | + root = path.join(tmp, `cat${n++}`) | |
| 20 | + cat = openCatalog(root) | |
| 21 | +}) | |
| 22 | +afterAll(async () => { | |
| 23 | + if (tmp) await rm(tmp, { recursive: true, force: true }) | |
| 24 | +}) | |
| 25 | + | |
| 26 | +async function put(file: string, content: string): Promise<void> { | |
| 27 | + await mkdir(path.dirname(file), { recursive: true }) | |
| 28 | + await writeFile(file, content) | |
| 29 | +} | |
| 30 | + | |
| 31 | +const SKILL = '---\nname: demo\ndescription: A demo\n---\nBody\n' | |
| 32 | + | |
| 33 | +function inv(partial: Partial<InventoryItem> & Pick<InventoryItem, 'type' | 'name' | 'details' | 'source'>): InventoryItem { | |
| 34 | + return { id: `claude:${partial.type}:${partial.name}`, agent: 'claude', warnings: [], ...partial } | |
| 35 | +} | |
| 36 | + | |
| 37 | +describe('catalog store', () => { | |
| 38 | + it('handles a missing root', async () => { | |
| 39 | + expect(await cat.exists()).toBe(false) | |
| 40 | + const s = await cat.snapshot() | |
| 41 | + expect(s).toMatchObject({ exists: false, items: [], presets: [], errors: [] }) | |
| 42 | + expect(await cat.getItem({ type: 'skill', name: 'x' })).toBeUndefined() | |
| 43 | + }) | |
| 44 | + | |
| 45 | + it('init is idempotent and creates the layout', async () => { | |
| 46 | + await cat.init() | |
| 47 | + await cat.init() | |
| 48 | + expect(await cat.exists()).toBe(true) | |
| 49 | + expect((await readdir(root)).sort()).toEqual(['.skilly', 'agents', 'hooks', 'mcp', 'presets', 'rules', 'skills']) | |
| 50 | + expect(JSON.parse(await readFile(path.join(root, '.skilly', 'index.json'), 'utf8'))).toEqual({ version: 1, items: {} }) | |
| 51 | + }) | |
| 52 | + | |
| 53 | + it('validates names', async () => { | |
| 54 | + for (const bad of ['Bad', '../x', 'a/b', '', '-x', 'a b']) { | |
| 55 | + await expect(cat.saveItem({ type: 'rule', name: bad, content: 'x' })).rejects.toThrow(/Invalid/) | |
| 56 | + } | |
| 57 | + await expect(cat.getItem({ type: 'skill', name: '..' })).rejects.toThrow() | |
| 58 | + }) | |
| 59 | + | |
| 60 | + it('saves, lists and reads skills', async () => { | |
| 61 | + const item = await cat.saveItem( | |
| 62 | + { type: 'skill', name: 'demo', content: SKILL, files: { 'refs/a.md': 'A' }, tags: ['x', 'x', ' y '] }, | |
| 63 | + ) | |
| 64 | + expect(item.tags).toEqual(['x', 'y']) | |
| 65 | + expect(item.description).toBe('A demo') | |
| 66 | + expect(item.origin?.kind).toBe('created') | |
| 67 | + expect(item.hash).toBe(await hashPath(path.join(root, 'skills', 'demo'))) | |
| 68 | + const detail = await cat.getItem({ type: 'skill', name: 'demo' }) | |
| 69 | + expect(detail?.files.map((f) => f.path).sort()).toEqual(['SKILL.md', 'refs/a.md']) | |
| 70 | + expect(detail?.content).toBe(SKILL) | |
| 71 | + const snap = await cat.snapshot() | |
| 72 | + expect(snap.items.map((i) => `${i.type}/${i.name}`)).toEqual(['skill/demo']) | |
| 73 | + expect(snap.errors).toEqual([]) | |
| 74 | + const files = await cat.itemFiles({ type: 'skill', name: 'demo' }) | |
| 75 | + expect(files.map((f) => f.rel).sort()).toEqual(['SKILL.md', 'refs/a.md']) | |
| 76 | + }) | |
| 77 | + | |
| 78 | + it('fixes skill frontmatter name and requires a description', async () => { | |
| 79 | + await cat.saveItem({ type: 'skill', name: 'demo', content: '---\nname: other\ndescription: D\nextra: 1\n---\nB\n' }) | |
| 80 | + const d = await cat.getItem({ type: 'skill', name: 'demo' }) | |
| 81 | + expect(d?.content).toContain('name: demo') | |
| 82 | + expect(d?.content).toContain('extra: 1') | |
| 83 | + await expect(cat.saveItem({ type: 'skill', name: 'nodesc', content: '---\nname: nodesc\n---\nB' })).rejects.toThrow(/description/) | |
| 84 | + await expect(cat.saveItem({ type: 'skill', name: 'nofm', content: 'just text' })).rejects.toThrow(/description/) | |
| 85 | + }) | |
| 86 | + | |
| 87 | + it('rejects escaping file paths and keeps/deletes extra files', async () => { | |
| 88 | + for (const bad of ['../x', '/abs', 'C:/abs', 'a/../../x', 'SKILL.md']) { | |
| 89 | + await expect(cat.saveItem({ type: 'skill', name: 'demo', content: SKILL, files: { [bad]: 'x' } })).rejects.toThrow() | |
| 90 | + } | |
| 91 | + await expect(cat.saveItem({ type: 'skill', name: 'demo', content: SKILL, deleteFiles: ['../x'] })).rejects.toThrow() | |
| 92 | + expect(await cat.getItem({ type: 'skill', name: 'demo' })).toBeUndefined() | |
| 93 | + await cat.saveItem({ type: 'skill', name: 'demo', content: SKILL, files: { 'a.txt': '1', 'b.txt': '2' } }) | |
| 94 | + await cat.saveItem({ type: 'skill', name: 'demo', content: SKILL, files: { 'c.txt': '3' }, deleteFiles: ['a.txt'] }) | |
| 95 | + const files = (await cat.itemFiles({ type: 'skill', name: 'demo' })).map((f) => f.rel) | |
| 96 | + expect(files.sort()).toEqual(['SKILL.md', 'b.txt', 'c.txt']) | |
| 97 | + }) | |
| 98 | + | |
| 99 | + it('renames items and moves index entries', async () => { | |
| 100 | + await cat.saveItem({ type: 'skill', name: 'demo', content: SKILL, tags: ['t'] }, { kind: 'import', source: '/x', importedAt: 'now' }) | |
| 101 | + const renamed = await cat.saveItem({ type: 'skill', name: 'demo2', previousName: 'demo', content: SKILL }) | |
| 102 | + expect(renamed.tags).toEqual(['t']) | |
| 103 | + expect(renamed.origin?.source).toBe('/x') | |
| 104 | + expect(await cat.getItem({ type: 'skill', name: 'demo' })).toBeUndefined() | |
| 105 | + const index = JSON.parse(await readFile(path.join(root, '.skilly', 'index.json'), 'utf8')) | |
| 106 | + expect(Object.keys(index.items)).toEqual(['skill/demo2']) | |
| 107 | + await cat.saveItem({ type: 'rule', name: 'r1', content: '---\ndescription: R\n---\nx' }) | |
| 108 | + await cat.saveItem({ type: 'rule', name: 'r2', content: 'y' }) | |
| 109 | + await expect(cat.saveItem({ type: 'rule', name: 'r2', previousName: 'r1', content: 'z' })).rejects.toThrow(/already exists/) | |
| 110 | + await cat.saveItem({ type: 'rule', name: 'r3', previousName: 'r1', content: 'z' }) | |
| 111 | + expect((await cat.snapshot()).items.map((i) => i.name)).toEqual(['r2', 'r3', 'demo2']) | |
| 112 | + }) | |
| 113 | + | |
| 114 | + it('validates mcp and hook YAML', async () => { | |
| 115 | + const mcp = 'name: gh\ndescription: GitHub\ntransport: stdio\ncommand: npx\nargs: [-y, srv]\nenv:\n TOKEN: { fromEnv: GH }\n' | |
| 116 | + const saved = await cat.saveItem({ type: 'mcp', name: 'gh', content: mcp }) | |
| 117 | + expect(saved.description).toBe('GitHub') | |
| 118 | + const d = await cat.getItem({ type: 'mcp', name: 'gh' }) | |
| 119 | + expect(d?.template).toMatchObject({ transport: 'stdio', command: 'npx', env: { TOKEN: { fromEnv: 'GH' } } }) | |
| 120 | + const bad = (c: string) => cat.saveItem({ type: 'mcp', name: 'gh', content: c }) | |
| 121 | + await expect(bad('name: gh\ntransport: stdio\n')).rejects.toThrow(/command/) | |
| 122 | + await expect(bad('name: gh\ntransport: http\n')).rejects.toThrow(/url/) | |
| 123 | + await expect(bad('name: other\ntransport: http\nurl: http://x\n')).rejects.toThrow(/does not match/) | |
| 124 | + await expect(bad('name: gh\ntransport: ws\n')).rejects.toThrow(/transport/) | |
| 125 | + await expect(bad(': : :')).rejects.toThrow() | |
| 126 | + await bad('name: gh\ntransport: http\nurl: http://x\nauth: { bearerFromEnv: T }\n') | |
| 127 | + | |
| 128 | + const hook = 'name: fmt\nevent: PostToolUse\nmatcher: Edit\nhandler:\n type: command\n command: prettier --write\n' | |
| 129 | + await cat.saveItem({ type: 'hook', name: 'fmt', content: hook }) | |
| 130 | + expect((await cat.getItem({ type: 'hook', name: 'fmt' }))?.template).toMatchObject({ event: 'PostToolUse' }) | |
| 131 | + await expect(cat.saveItem({ type: 'hook', name: 'fmt', content: 'name: fmt\nevent: Stop\n' })).rejects.toThrow(/handler/) | |
| 132 | + await expect(cat.saveItem({ type: 'hook', name: 'fmt', content: 'name: fmt\nhandler: { command: x }\n' })).rejects.toThrow(/event/) | |
| 133 | + }) | |
| 134 | + | |
| 135 | + it('reports per-file errors in the snapshot without throwing', async () => { | |
| 136 | + await cat.init() | |
| 137 | + await put(path.join(root, 'mcp', 'broken.yaml'), 'name: broken\ntransport: stdio\n') | |
| 138 | + await put(path.join(root, 'skills', 'nofile', 'README.md'), 'x') | |
| 139 | + await put(path.join(root, 'presets', 'bad.yaml'), 'includes: nope\n') | |
| 140 | + await put(path.join(root, 'rules', 'ok.md'), '---\ndescription: fine\n---\n') | |
| 141 | + await put(path.join(root, '.skilly', 'index.json'), '{not json') | |
| 142 | + const snap = await cat.snapshot() | |
| 143 | + expect(snap.items.map((i) => i.name)).toEqual(['ok']) | |
| 144 | + expect(snap.items[0]?.description).toBe('fine') | |
| 145 | + expect(snap.errors.length).toBe(4) | |
| 146 | + }) | |
| 147 | + | |
| 148 | + it('sets tags and deletes items', async () => { | |
| 149 | + await cat.saveItem({ type: 'rule', name: 'r', content: 'x' }) | |
| 150 | + await cat.setTags({ type: 'rule', name: 'r' }, ['a', 'b']) | |
| 151 | + expect((await cat.snapshot()).items[0]?.tags).toEqual(['a', 'b']) | |
| 152 | + await expect(cat.setTags({ type: 'rule', name: 'missing' }, ['a'])).rejects.toThrow() | |
| 153 | + await cat.deleteItem({ type: 'rule', name: 'r' }) | |
| 154 | + await cat.deleteItem({ type: 'rule', name: 'r' }) | |
| 155 | + expect((await cat.snapshot()).items).toEqual([]) | |
| 156 | + expect(await cat.itemFiles({ type: 'rule', name: 'r' })).toEqual([]) | |
| 157 | + }) | |
| 158 | + | |
| 159 | + it('saves, renames and deletes presets', async () => { | |
| 160 | + const p = await cat.savePreset({ | |
| 161 | + name: 'base', | |
| 162 | + description: 'Base', | |
| 163 | + includes: ['other', 'other'], | |
| 164 | + items: { skill: ['demo'], mcp: ['gh'] }, | |
| 165 | + }) | |
| 166 | + expect(p.includes).toEqual(['other']) | |
| 167 | + const text = await readFile(path.join(root, 'presets', 'base.yaml'), 'utf8') | |
| 168 | + for (const key of ['name:', 'description:', 'includes:', 'skills:', 'rules:', 'agents:', 'mcp:', 'hooks:']) { | |
| 169 | + expect(text).toContain(key) | |
| 170 | + } | |
| 171 | + expect((await cat.snapshot()).presets).toEqual([ | |
| 172 | + { name: 'base', description: 'Base', includes: ['other'], items: { skill: ['demo'], mcp: ['gh'] } }, | |
| 173 | + ]) | |
| 174 | + await expect(cat.savePreset({ name: 'Bad', includes: [], items: {} })).rejects.toThrow() | |
| 175 | + await expect(cat.savePreset({ name: 'x', includes: ['x'], items: {} })).rejects.toThrow(/itself/) | |
| 176 | + await expect(cat.savePreset({ name: 'x', includes: [], items: { rule: ['../y'] } })).rejects.toThrow(/Invalid/) | |
| 177 | + await cat.savePreset({ name: 'renamed', includes: [], items: {} }, 'base') | |
| 178 | + expect((await cat.snapshot()).presets.map((x) => x.name)).toEqual(['renamed']) | |
| 179 | + await cat.deletePreset('renamed') | |
| 180 | + expect((await cat.snapshot()).presets).toEqual([]) | |
| 181 | + }) | |
| 182 | +}) | |
| 183 | + | |
| 184 | +describe('catalog imports', () => { | |
| 185 | + it('imports a skill folder (through a symlink), excluding generated files', async () => { | |
| 186 | + const real = path.join(tmp, 'real-skill') | |
| 187 | + await put(path.join(real, 'SKILL.md'), '---\nname: Fancy Skill\ndescription: Fancy\n---\nBody') | |
| 188 | + await put(path.join(real, 'agents', 'openai.yaml'), 'x: 1') | |
| 189 | + await put(path.join(real, 'agents', 'keep.yaml'), 'k: 1') | |
| 190 | + await put(path.join(real, 'node_modules', 'm', 'i.js'), '1') | |
| 191 | + await put(path.join(real, '.git', 'HEAD'), 'ref') | |
| 192 | + await put(path.join(real, 'sub', 'f.txt'), 'f') | |
| 193 | + const link = path.join(tmp, 'link-skill') | |
| 194 | + let src = real | |
| 195 | + try { | |
| 196 | + await symlink(real, link, 'junction') | |
| 197 | + src = link | |
| 198 | + } catch { | |
| 199 | + // no symlink permission: fall back to the real folder | |
| 200 | + } | |
| 201 | + const item = await cat.importFromInventory( | |
| 202 | + inv({ | |
| 203 | + type: 'skill', | |
| 204 | + name: 'plugin:Fancy Skill', | |
| 205 | + source: { path: src, kind: 'dir' }, | |
| 206 | + details: { type: 'skill', frontmatter: {}, files: [], visibility: 'auto' }, | |
| 207 | + }), | |
| 208 | + { tags: ['imp'] }, | |
| 209 | + ) | |
| 210 | + expect(item.name).toBe('fancy-skill') | |
| 211 | + expect(item.tags).toEqual(['imp']) | |
| 212 | + expect(item.origin).toMatchObject({ kind: 'import', source: src }) | |
| 213 | + const files = (await cat.itemFiles({ type: 'skill', name: 'fancy-skill' })).map((f) => f.rel) | |
| 214 | + expect(files.sort()).toEqual(['SKILL.md', 'agents/keep.yaml', 'sub/f.txt']) | |
| 215 | + expect((await cat.getItem({ type: 'skill', name: 'fancy-skill' }))?.content).toContain('name: fancy-skill') | |
| 216 | + await expect( | |
| 217 | + cat.importFromInventory( | |
| 218 | + inv({ type: 'skill', name: 'fancy', source: { path: src, kind: 'dir' }, details: { type: 'skill', frontmatter: {}, files: [], visibility: 'auto' } }), | |
| 219 | + { name: 'fancy-skill' }, | |
| 220 | + ), | |
| 221 | + ).rejects.toThrow(/already exists/) | |
| 222 | + const again = await cat.importFromInventory( | |
| 223 | + inv({ type: 'skill', name: 'fancy', source: { path: src, kind: 'dir' }, details: { type: 'skill', frontmatter: {}, files: [], visibility: 'auto' } }), | |
| 224 | + { name: 'fancy-skill', overwrite: true }, | |
| 225 | + ) | |
| 226 | + expect(again.tags).toEqual(['imp']) | |
| 227 | + expect((await readdir(path.join(root, 'skills'))).filter((n) => n.startsWith('.'))).toEqual([]) | |
| 228 | + }) | |
| 229 | + | |
| 230 | + it('imports a legacy command as a skill', async () => { | |
| 231 | + const file = path.join(tmp, 'cmds', 'deploy.md') | |
| 232 | + await put(file, '---\ndescription: Deploy it\nallowed-tools: Bash\n---\nrun') | |
| 233 | + const item = await cat.importFromInventory( | |
| 234 | + inv({ type: 'skill', name: 'deploy', source: { path: file, kind: 'file' }, details: { type: 'skill', frontmatter: {}, files: [], visibility: 'auto' } }), | |
| 235 | + ) | |
| 236 | + expect(item.description).toBe('Deploy it') | |
| 237 | + const d = await cat.getItem({ type: 'skill', name: 'deploy' }) | |
| 238 | + expect(d?.content).toContain('name: deploy') | |
| 239 | + expect(d?.content).toContain('allowed-tools: Bash') | |
| 240 | + expect(d?.files.map((f) => f.path)).toEqual(['SKILL.md']) | |
| 241 | + }) | |
| 242 | + | |
| 243 | + it('imports rules and agents verbatim', async () => { | |
| 244 | + const file = path.join(tmp, 'rules', 'style.md') | |
| 245 | + await put(file, '---\ndescription: Style\n---\nUse tabs\n') | |
| 246 | + const item = await cat.importFromInventory( | |
| 247 | + inv({ type: 'rule', name: 'style', source: { path: file, kind: 'file' }, details: { type: 'rule', frontmatter: {}, paths: [] } }), | |
| 248 | + ) | |
| 249 | + expect(item.hash).toBe(await hashPath(file)) | |
| 250 | + expect(item.description).toBe('Style') | |
| 251 | + const ag = path.join(tmp, 'agents', 'rev.md') | |
| 252 | + await put(ag, '---\nname: rev\ndescription: Reviewer\n---\nprompt') | |
| 253 | + const a = await cat.importFromInventory( | |
| 254 | + inv({ type: 'agent', name: 'rev', source: { path: ag, kind: 'file' }, details: { type: 'agent', frontmatter: {} } }), | |
| 255 | + ) | |
| 256 | + expect(a.type).toBe('agent') | |
| 257 | + }) | |
| 258 | + | |
| 259 | + it('converts mcp entries without copying secrets', async () => { | |
| 260 | + const stdio = await cat.importFromInventory( | |
| 261 | + inv({ | |
| 262 | + type: 'mcp', | |
| 263 | + name: 'GitHub', | |
| 264 | + description: 'GH server', | |
| 265 | + source: { path: '/home/.claude.json', kind: 'config-entry', configKey: 'mcpServers.GitHub' }, | |
| 266 | + details: { | |
| 267 | + type: 'mcp', | |
| 268 | + transport: 'stdio', | |
| 269 | + command: 'npx', | |
| 270 | + args: ['-y', 'srv', '--token', '<redacted>'], | |
| 271 | + envVarNames: ['OTHER'], | |
| 272 | + literalEnvKeys: ['API_KEY'], | |
| 273 | + enabled: true, | |
| 274 | + }, | |
| 275 | + }), | |
| 276 | + ) | |
| 277 | + const d = await cat.getItem({ type: 'mcp', name: 'github' }) | |
| 278 | + expect(stdio.name).toBe('github') | |
| 279 | + expect(d?.template).toMatchObject({ | |
| 280 | + transport: 'stdio', | |
| 281 | + command: 'npx', | |
| 282 | + env: { API_KEY: { fromEnv: 'API_KEY' }, OTHER: { fromEnv: 'OTHER' } }, | |
| 283 | + }) | |
| 284 | + expect(d?.item.description).toMatch(/redacted/) | |
| 285 | + const http = await cat.importFromInventory( | |
| 286 | + inv({ | |
| 287 | + type: 'mcp', | |
| 288 | + name: 'remote', | |
| 289 | + source: { path: '/x', kind: 'config-entry' }, | |
| 290 | + details: { type: 'mcp', transport: 'http', url: 'https://x/mcp', envVarNames: ['REMOTE_TOKEN'], literalEnvKeys: [], enabled: true }, | |
| 291 | + }), | |
| 292 | + ) | |
| 293 | + expect((await cat.getItem({ type: 'mcp', name: http.name }))?.template).toMatchObject({ | |
| 294 | + transport: 'http', | |
| 295 | + url: 'https://x/mcp', | |
| 296 | + auth: { bearerFromEnv: 'REMOTE_TOKEN' }, | |
| 297 | + }) | |
| 298 | + }) | |
| 299 | + | |
| 300 | + it('converts command hooks and refuses others', async () => { | |
| 301 | + const base = { type: 'hook' as const, name: 'fmt', source: { path: '/s.json', kind: 'config-entry' as const } } | |
| 302 | + const h = await cat.importFromInventory( | |
| 303 | + inv({ ...base, details: { type: 'hook', event: 'PostToolUse', matcher: 'Edit', handlerType: 'command', command: 'prettier' } }), | |
| 304 | + ) | |
| 305 | + expect((await cat.getItem({ type: 'hook', name: h.name }))?.template).toMatchObject({ | |
| 306 | + event: 'PostToolUse', | |
| 307 | + matcher: 'Edit', | |
| 308 | + handler: { type: 'command', command: 'prettier' }, | |
| 309 | + }) | |
| 310 | + await expect( | |
| 311 | + cat.importFromInventory(inv({ ...base, name: 'p', details: { type: 'hook', event: 'Stop', handlerType: 'prompt' } })), | |
| 312 | + ).rejects.toThrow(/command/) | |
| 313 | + await expect( | |
| 314 | + cat.importFromInventory( | |
| 315 | + inv({ type: 'instructions', name: 'CLAUDE.md', source: { path: '/c', kind: 'file' }, details: { type: 'instructions', fileName: 'CLAUDE.md', sizeBytes: 1, imports: [], loaded: true } }), | |
| 316 | + ), | |
| 317 | + ).rejects.toThrow(/Instructions/) | |
| 318 | + }) | |
| 319 | + | |
| 320 | + it('imports a marketplace skill directory with upstream hash', async () => { | |
| 321 | + const dir = path.join(tmp, 'market', 'cool') | |
| 322 | + await put(path.join(dir, 'SKILL.md'), '---\nname: cool\ndescription: Cool\n---\n') | |
| 323 | + await put(path.join(dir, 'x.txt'), 'x') | |
| 324 | + const upstream = await hashPath(dir) | |
| 325 | + const item = await cat.importSkillDirectory(dir, 'cool', { kind: 'marketplace', source: 'src1', sourcePath: 'cool', importedAt: 'now' }) | |
| 326 | + expect(item.origin).toMatchObject({ kind: 'marketplace', source: 'src1', upstreamHash: upstream }) | |
| 327 | + expect(item.hash).toBe(upstream) | |
| 328 | + await expect(cat.importSkillDirectory(dir, 'cool', { kind: 'marketplace', importedAt: 'now' })).rejects.toThrow(/already exists/) | |
| 329 | + await expect(cat.importSkillDirectory(path.join(tmp, 'nope'), 'zzz', { kind: 'marketplace', importedAt: 'now' })).rejects.toThrow() | |
| 330 | + expect((await cat.snapshot()).items.map((i) => i.name)).toEqual(['cool']) | |
| 331 | + }) | |
| 332 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,332 @@ | |||
| 1 | +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' | ||
| 2 | +import os from 'node:os' | ||
| 3 | +import path from 'node:path' | ||
| 4 | +import { afterAll, beforeEach, describe, expect, it } from 'vitest' | ||
| 5 | +import { openCatalog } from '../src/catalog/index.ts' | ||
| 6 | +import type { CatalogStore } from '../src/contracts.ts' | ||
| 7 | +import type { InventoryItem } from '../src/model.ts' | ||
| 8 | +import { hashPath } from '../src/util.ts' | ||
| 9 | + | ||
| 10 | +let tmp: string | ||
| 11 | +let n = 0 | ||
| 12 | +let root: string | ||
| 13 | +let cat: CatalogStore | ||
| 14 | + | ||
| 15 | +const roots: string[] = [] | ||
| 16 | +beforeEach(async () => { | ||
| 17 | + tmp ??= await mkdtemp(path.join(os.tmpdir(), 'skilly-catalog-')) | ||
| 18 | + roots.push(tmp) | ||
| 19 | + root = path.join(tmp, `cat${n++}`) | ||
| 20 | + cat = openCatalog(root) | ||
| 21 | +}) | ||
| 22 | +afterAll(async () => { | ||
| 23 | + if (tmp) await rm(tmp, { recursive: true, force: true }) | ||
| 24 | +}) | ||
| 25 | + | ||
| 26 | +async function put(file: string, content: string): Promise<void> { | ||
| 27 | + await mkdir(path.dirname(file), { recursive: true }) | ||
| 28 | + await writeFile(file, content) | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +const SKILL = '---\nname: demo\ndescription: A demo\n---\nBody\n' | ||
| 32 | + | ||
| 33 | +function inv(partial: Partial<InventoryItem> & Pick<InventoryItem, 'type' | 'name' | 'details' | 'source'>): InventoryItem { | ||
| 34 | + return { id: `claude:${partial.type}:${partial.name}`, agent: 'claude', warnings: [], ...partial } | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +describe('catalog store', () => { | ||
| 38 | + it('handles a missing root', async () => { | ||
| 39 | + expect(await cat.exists()).toBe(false) | ||
| 40 | + const s = await cat.snapshot() | ||
| 41 | + expect(s).toMatchObject({ exists: false, items: [], presets: [], errors: [] }) | ||
| 42 | + expect(await cat.getItem({ type: 'skill', name: 'x' })).toBeUndefined() | ||
| 43 | + }) | ||
| 44 | + | ||
| 45 | + it('init is idempotent and creates the layout', async () => { | ||
| 46 | + await cat.init() | ||
| 47 | + await cat.init() | ||
| 48 | + expect(await cat.exists()).toBe(true) | ||
| 49 | + expect((await readdir(root)).sort()).toEqual(['.skilly', 'agents', 'hooks', 'mcp', 'presets', 'rules', 'skills']) | ||
| 50 | + expect(JSON.parse(await readFile(path.join(root, '.skilly', 'index.json'), 'utf8'))).toEqual({ version: 1, items: {} }) | ||
| 51 | + }) | ||
| 52 | + | ||
| 53 | + it('validates names', async () => { | ||
| 54 | + for (const bad of ['Bad', '../x', 'a/b', '', '-x', 'a b']) { | ||
| 55 | + await expect(cat.saveItem({ type: 'rule', name: bad, content: 'x' })).rejects.toThrow(/Invalid/) | ||
| 56 | + } | ||
| 57 | + await expect(cat.getItem({ type: 'skill', name: '..' })).rejects.toThrow() | ||
| 58 | + }) | ||
| 59 | + | ||
| 60 | + it('saves, lists and reads skills', async () => { | ||
| 61 | + const item = await cat.saveItem( | ||
| 62 | + { type: 'skill', name: 'demo', content: SKILL, files: { 'refs/a.md': 'A' }, tags: ['x', 'x', ' y '] }, | ||
| 63 | + ) | ||
| 64 | + expect(item.tags).toEqual(['x', 'y']) | ||
| 65 | + expect(item.description).toBe('A demo') | ||
| 66 | + expect(item.origin?.kind).toBe('created') | ||
| 67 | + expect(item.hash).toBe(await hashPath(path.join(root, 'skills', 'demo'))) | ||
| 68 | + const detail = await cat.getItem({ type: 'skill', name: 'demo' }) | ||
| 69 | + expect(detail?.files.map((f) => f.path).sort()).toEqual(['SKILL.md', 'refs/a.md']) | ||
| 70 | + expect(detail?.content).toBe(SKILL) | ||
| 71 | + const snap = await cat.snapshot() | ||
| 72 | + expect(snap.items.map((i) => `${i.type}/${i.name}`)).toEqual(['skill/demo']) | ||
| 73 | + expect(snap.errors).toEqual([]) | ||
| 74 | + const files = await cat.itemFiles({ type: 'skill', name: 'demo' }) | ||
| 75 | + expect(files.map((f) => f.rel).sort()).toEqual(['SKILL.md', 'refs/a.md']) | ||
| 76 | + }) | ||
| 77 | + | ||
| 78 | + it('fixes skill frontmatter name and requires a description', async () => { | ||
| 79 | + await cat.saveItem({ type: 'skill', name: 'demo', content: '---\nname: other\ndescription: D\nextra: 1\n---\nB\n' }) | ||
| 80 | + const d = await cat.getItem({ type: 'skill', name: 'demo' }) | ||
| 81 | + expect(d?.content).toContain('name: demo') | ||
| 82 | + expect(d?.content).toContain('extra: 1') | ||
| 83 | + await expect(cat.saveItem({ type: 'skill', name: 'nodesc', content: '---\nname: nodesc\n---\nB' })).rejects.toThrow(/description/) | ||
| 84 | + await expect(cat.saveItem({ type: 'skill', name: 'nofm', content: 'just text' })).rejects.toThrow(/description/) | ||
| 85 | + }) | ||
| 86 | + | ||
| 87 | + it('rejects escaping file paths and keeps/deletes extra files', async () => { | ||
| 88 | + for (const bad of ['../x', '/abs', 'C:/abs', 'a/../../x', 'SKILL.md']) { | ||
| 89 | + await expect(cat.saveItem({ type: 'skill', name: 'demo', content: SKILL, files: { [bad]: 'x' } })).rejects.toThrow() | ||
| 90 | + } | ||
| 91 | + await expect(cat.saveItem({ type: 'skill', name: 'demo', content: SKILL, deleteFiles: ['../x'] })).rejects.toThrow() | ||
| 92 | + expect(await cat.getItem({ type: 'skill', name: 'demo' })).toBeUndefined() | ||
| 93 | + await cat.saveItem({ type: 'skill', name: 'demo', content: SKILL, files: { 'a.txt': '1', 'b.txt': '2' } }) | ||
| 94 | + await cat.saveItem({ type: 'skill', name: 'demo', content: SKILL, files: { 'c.txt': '3' }, deleteFiles: ['a.txt'] }) | ||
| 95 | + const files = (await cat.itemFiles({ type: 'skill', name: 'demo' })).map((f) => f.rel) | ||
| 96 | + expect(files.sort()).toEqual(['SKILL.md', 'b.txt', 'c.txt']) | ||
| 97 | + }) | ||
| 98 | + | ||
| 99 | + it('renames items and moves index entries', async () => { | ||
| 100 | + await cat.saveItem({ type: 'skill', name: 'demo', content: SKILL, tags: ['t'] }, { kind: 'import', source: '/x', importedAt: 'now' }) | ||
| 101 | + const renamed = await cat.saveItem({ type: 'skill', name: 'demo2', previousName: 'demo', content: SKILL }) | ||
| 102 | + expect(renamed.tags).toEqual(['t']) | ||
| 103 | + expect(renamed.origin?.source).toBe('/x') | ||
| 104 | + expect(await cat.getItem({ type: 'skill', name: 'demo' })).toBeUndefined() | ||
| 105 | + const index = JSON.parse(await readFile(path.join(root, '.skilly', 'index.json'), 'utf8')) | ||
| 106 | + expect(Object.keys(index.items)).toEqual(['skill/demo2']) | ||
| 107 | + await cat.saveItem({ type: 'rule', name: 'r1', content: '---\ndescription: R\n---\nx' }) | ||
| 108 | + await cat.saveItem({ type: 'rule', name: 'r2', content: 'y' }) | ||
| 109 | + await expect(cat.saveItem({ type: 'rule', name: 'r2', previousName: 'r1', content: 'z' })).rejects.toThrow(/already exists/) | ||
| 110 | + await cat.saveItem({ type: 'rule', name: 'r3', previousName: 'r1', content: 'z' }) | ||
| 111 | + expect((await cat.snapshot()).items.map((i) => i.name)).toEqual(['r2', 'r3', 'demo2']) | ||
| 112 | + }) | ||
| 113 | + | ||
| 114 | + it('validates mcp and hook YAML', async () => { | ||
| 115 | + const mcp = 'name: gh\ndescription: GitHub\ntransport: stdio\ncommand: npx\nargs: [-y, srv]\nenv:\n TOKEN: { fromEnv: GH }\n' | ||
| 116 | + const saved = await cat.saveItem({ type: 'mcp', name: 'gh', content: mcp }) | ||
| 117 | + expect(saved.description).toBe('GitHub') | ||
| 118 | + const d = await cat.getItem({ type: 'mcp', name: 'gh' }) | ||
| 119 | + expect(d?.template).toMatchObject({ transport: 'stdio', command: 'npx', env: { TOKEN: { fromEnv: 'GH' } } }) | ||
| 120 | + const bad = (c: string) => cat.saveItem({ type: 'mcp', name: 'gh', content: c }) | ||
| 121 | + await expect(bad('name: gh\ntransport: stdio\n')).rejects.toThrow(/command/) | ||
| 122 | + await expect(bad('name: gh\ntransport: http\n')).rejects.toThrow(/url/) | ||
| 123 | + await expect(bad('name: other\ntransport: http\nurl: http://x\n')).rejects.toThrow(/does not match/) | ||
| 124 | + await expect(bad('name: gh\ntransport: ws\n')).rejects.toThrow(/transport/) | ||
| 125 | + await expect(bad(': : :')).rejects.toThrow() | ||
| 126 | + await bad('name: gh\ntransport: http\nurl: http://x\nauth: { bearerFromEnv: T }\n') | ||
| 127 | + | ||
| 128 | + const hook = 'name: fmt\nevent: PostToolUse\nmatcher: Edit\nhandler:\n type: command\n command: prettier --write\n' | ||
| 129 | + await cat.saveItem({ type: 'hook', name: 'fmt', content: hook }) | ||
| 130 | + expect((await cat.getItem({ type: 'hook', name: 'fmt' }))?.template).toMatchObject({ event: 'PostToolUse' }) | ||
| 131 | + await expect(cat.saveItem({ type: 'hook', name: 'fmt', content: 'name: fmt\nevent: Stop\n' })).rejects.toThrow(/handler/) | ||
| 132 | + await expect(cat.saveItem({ type: 'hook', name: 'fmt', content: 'name: fmt\nhandler: { command: x }\n' })).rejects.toThrow(/event/) | ||
| 133 | + }) | ||
| 134 | + | ||
| 135 | + it('reports per-file errors in the snapshot without throwing', async () => { | ||
| 136 | + await cat.init() | ||
| 137 | + await put(path.join(root, 'mcp', 'broken.yaml'), 'name: broken\ntransport: stdio\n') | ||
| 138 | + await put(path.join(root, 'skills', 'nofile', 'README.md'), 'x') | ||
| 139 | + await put(path.join(root, 'presets', 'bad.yaml'), 'includes: nope\n') | ||
| 140 | + await put(path.join(root, 'rules', 'ok.md'), '---\ndescription: fine\n---\n') | ||
| 141 | + await put(path.join(root, '.skilly', 'index.json'), '{not json') | ||
| 142 | + const snap = await cat.snapshot() | ||
| 143 | + expect(snap.items.map((i) => i.name)).toEqual(['ok']) | ||
| 144 | + expect(snap.items[0]?.description).toBe('fine') | ||
| 145 | + expect(snap.errors.length).toBe(4) | ||
| 146 | + }) | ||
| 147 | + | ||
| 148 | + it('sets tags and deletes items', async () => { | ||
| 149 | + await cat.saveItem({ type: 'rule', name: 'r', content: 'x' }) | ||
| 150 | + await cat.setTags({ type: 'rule', name: 'r' }, ['a', 'b']) | ||
| 151 | + expect((await cat.snapshot()).items[0]?.tags).toEqual(['a', 'b']) | ||
| 152 | + await expect(cat.setTags({ type: 'rule', name: 'missing' }, ['a'])).rejects.toThrow() | ||
| 153 | + await cat.deleteItem({ type: 'rule', name: 'r' }) | ||
| 154 | + await cat.deleteItem({ type: 'rule', name: 'r' }) | ||
| 155 | + expect((await cat.snapshot()).items).toEqual([]) | ||
| 156 | + expect(await cat.itemFiles({ type: 'rule', name: 'r' })).toEqual([]) | ||
| 157 | + }) | ||
| 158 | + | ||
| 159 | + it('saves, renames and deletes presets', async () => { | ||
| 160 | + const p = await cat.savePreset({ | ||
| 161 | + name: 'base', | ||
| 162 | + description: 'Base', | ||
| 163 | + includes: ['other', 'other'], | ||
| 164 | + items: { skill: ['demo'], mcp: ['gh'] }, | ||
| 165 | + }) | ||
| 166 | + expect(p.includes).toEqual(['other']) | ||
| 167 | + const text = await readFile(path.join(root, 'presets', 'base.yaml'), 'utf8') | ||
| 168 | + for (const key of ['name:', 'description:', 'includes:', 'skills:', 'rules:', 'agents:', 'mcp:', 'hooks:']) { | ||
| 169 | + expect(text).toContain(key) | ||
| 170 | + } | ||
| 171 | + expect((await cat.snapshot()).presets).toEqual([ | ||
| 172 | + { name: 'base', description: 'Base', includes: ['other'], items: { skill: ['demo'], mcp: ['gh'] } }, | ||
| 173 | + ]) | ||
| 174 | + await expect(cat.savePreset({ name: 'Bad', includes: [], items: {} })).rejects.toThrow() | ||
| 175 | + await expect(cat.savePreset({ name: 'x', includes: ['x'], items: {} })).rejects.toThrow(/itself/) | ||
| 176 | + await expect(cat.savePreset({ name: 'x', includes: [], items: { rule: ['../y'] } })).rejects.toThrow(/Invalid/) | ||
| 177 | + await cat.savePreset({ name: 'renamed', includes: [], items: {} }, 'base') | ||
| 178 | + expect((await cat.snapshot()).presets.map((x) => x.name)).toEqual(['renamed']) | ||
| 179 | + await cat.deletePreset('renamed') | ||
| 180 | + expect((await cat.snapshot()).presets).toEqual([]) | ||
| 181 | + }) | ||
| 182 | +}) | ||
| 183 | + | ||
| 184 | +describe('catalog imports', () => { | ||
| 185 | + it('imports a skill folder (through a symlink), excluding generated files', async () => { | ||
| 186 | + const real = path.join(tmp, 'real-skill') | ||
| 187 | + await put(path.join(real, 'SKILL.md'), '---\nname: Fancy Skill\ndescription: Fancy\n---\nBody') | ||
| 188 | + await put(path.join(real, 'agents', 'openai.yaml'), 'x: 1') | ||
| 189 | + await put(path.join(real, 'agents', 'keep.yaml'), 'k: 1') | ||
| 190 | + await put(path.join(real, 'node_modules', 'm', 'i.js'), '1') | ||
| 191 | + await put(path.join(real, '.git', 'HEAD'), 'ref') | ||
| 192 | + await put(path.join(real, 'sub', 'f.txt'), 'f') | ||
| 193 | + const link = path.join(tmp, 'link-skill') | ||
| 194 | + let src = real | ||
| 195 | + try { | ||
| 196 | + await symlink(real, link, 'junction') | ||
| 197 | + src = link | ||
| 198 | + } catch { | ||
| 199 | + // no symlink permission: fall back to the real folder | ||
| 200 | + } | ||
| 201 | + const item = await cat.importFromInventory( | ||
| 202 | + inv({ | ||
| 203 | + type: 'skill', | ||
| 204 | + name: 'plugin:Fancy Skill', | ||
| 205 | + source: { path: src, kind: 'dir' }, | ||
| 206 | + details: { type: 'skill', frontmatter: {}, files: [], visibility: 'auto' }, | ||
| 207 | + }), | ||
| 208 | + { tags: ['imp'] }, | ||
| 209 | + ) | ||
| 210 | + expect(item.name).toBe('fancy-skill') | ||
| 211 | + expect(item.tags).toEqual(['imp']) | ||
| 212 | + expect(item.origin).toMatchObject({ kind: 'import', source: src }) | ||
| 213 | + const files = (await cat.itemFiles({ type: 'skill', name: 'fancy-skill' })).map((f) => f.rel) | ||
| 214 | + expect(files.sort()).toEqual(['SKILL.md', 'agents/keep.yaml', 'sub/f.txt']) | ||
| 215 | + expect((await cat.getItem({ type: 'skill', name: 'fancy-skill' }))?.content).toContain('name: fancy-skill') | ||
| 216 | + await expect( | ||
| 217 | + cat.importFromInventory( | ||
| 218 | + inv({ type: 'skill', name: 'fancy', source: { path: src, kind: 'dir' }, details: { type: 'skill', frontmatter: {}, files: [], visibility: 'auto' } }), | ||
| 219 | + { name: 'fancy-skill' }, | ||
| 220 | + ), | ||
| 221 | + ).rejects.toThrow(/already exists/) | ||
| 222 | + const again = await cat.importFromInventory( | ||
| 223 | + inv({ type: 'skill', name: 'fancy', source: { path: src, kind: 'dir' }, details: { type: 'skill', frontmatter: {}, files: [], visibility: 'auto' } }), | ||
| 224 | + { name: 'fancy-skill', overwrite: true }, | ||
| 225 | + ) | ||
| 226 | + expect(again.tags).toEqual(['imp']) | ||
| 227 | + expect((await readdir(path.join(root, 'skills'))).filter((n) => n.startsWith('.'))).toEqual([]) | ||
| 228 | + }) | ||
| 229 | + | ||
| 230 | + it('imports a legacy command as a skill', async () => { | ||
| 231 | + const file = path.join(tmp, 'cmds', 'deploy.md') | ||
| 232 | + await put(file, '---\ndescription: Deploy it\nallowed-tools: Bash\n---\nrun') | ||
| 233 | + const item = await cat.importFromInventory( | ||
| 234 | + inv({ type: 'skill', name: 'deploy', source: { path: file, kind: 'file' }, details: { type: 'skill', frontmatter: {}, files: [], visibility: 'auto' } }), | ||
| 235 | + ) | ||
| 236 | + expect(item.description).toBe('Deploy it') | ||
| 237 | + const d = await cat.getItem({ type: 'skill', name: 'deploy' }) | ||
| 238 | + expect(d?.content).toContain('name: deploy') | ||
| 239 | + expect(d?.content).toContain('allowed-tools: Bash') | ||
| 240 | + expect(d?.files.map((f) => f.path)).toEqual(['SKILL.md']) | ||
| 241 | + }) | ||
| 242 | + | ||
| 243 | + it('imports rules and agents verbatim', async () => { | ||
| 244 | + const file = path.join(tmp, 'rules', 'style.md') | ||
| 245 | + await put(file, '---\ndescription: Style\n---\nUse tabs\n') | ||
| 246 | + const item = await cat.importFromInventory( | ||
| 247 | + inv({ type: 'rule', name: 'style', source: { path: file, kind: 'file' }, details: { type: 'rule', frontmatter: {}, paths: [] } }), | ||
| 248 | + ) | ||
| 249 | + expect(item.hash).toBe(await hashPath(file)) | ||
| 250 | + expect(item.description).toBe('Style') | ||
| 251 | + const ag = path.join(tmp, 'agents', 'rev.md') | ||
| 252 | + await put(ag, '---\nname: rev\ndescription: Reviewer\n---\nprompt') | ||
| 253 | + const a = await cat.importFromInventory( | ||
| 254 | + inv({ type: 'agent', name: 'rev', source: { path: ag, kind: 'file' }, details: { type: 'agent', frontmatter: {} } }), | ||
| 255 | + ) | ||
| 256 | + expect(a.type).toBe('agent') | ||
| 257 | + }) | ||
| 258 | + | ||
| 259 | + it('converts mcp entries without copying secrets', async () => { | ||
| 260 | + const stdio = await cat.importFromInventory( | ||
| 261 | + inv({ | ||
| 262 | + type: 'mcp', | ||
| 263 | + name: 'GitHub', | ||
| 264 | + description: 'GH server', | ||
| 265 | + source: { path: '/home/.claude.json', kind: 'config-entry', configKey: 'mcpServers.GitHub' }, | ||
| 266 | + details: { | ||
| 267 | + type: 'mcp', | ||
| 268 | + transport: 'stdio', | ||
| 269 | + command: 'npx', | ||
| 270 | + args: ['-y', 'srv', '--token', '<redacted>'], | ||
| 271 | + envVarNames: ['OTHER'], | ||
| 272 | + literalEnvKeys: ['API_KEY'], | ||
| 273 | + enabled: true, | ||
| 274 | + }, | ||
| 275 | + }), | ||
| 276 | + ) | ||
| 277 | + const d = await cat.getItem({ type: 'mcp', name: 'github' }) | ||
| 278 | + expect(stdio.name).toBe('github') | ||
| 279 | + expect(d?.template).toMatchObject({ | ||
| 280 | + transport: 'stdio', | ||
| 281 | + command: 'npx', | ||
| 282 | + env: { API_KEY: { fromEnv: 'API_KEY' }, OTHER: { fromEnv: 'OTHER' } }, | ||
| 283 | + }) | ||
| 284 | + expect(d?.item.description).toMatch(/redacted/) | ||
| 285 | + const http = await cat.importFromInventory( | ||
| 286 | + inv({ | ||
| 287 | + type: 'mcp', | ||
| 288 | + name: 'remote', | ||
| 289 | + source: { path: '/x', kind: 'config-entry' }, | ||
| 290 | + details: { type: 'mcp', transport: 'http', url: 'https://x/mcp', envVarNames: ['REMOTE_TOKEN'], literalEnvKeys: [], enabled: true }, | ||
| 291 | + }), | ||
| 292 | + ) | ||
| 293 | + expect((await cat.getItem({ type: 'mcp', name: http.name }))?.template).toMatchObject({ | ||
| 294 | + transport: 'http', | ||
| 295 | + url: 'https://x/mcp', | ||
| 296 | + auth: { bearerFromEnv: 'REMOTE_TOKEN' }, | ||
| 297 | + }) | ||
| 298 | + }) | ||
| 299 | + | ||
| 300 | + it('converts command hooks and refuses others', async () => { | ||
| 301 | + const base = { type: 'hook' as const, name: 'fmt', source: { path: '/s.json', kind: 'config-entry' as const } } | ||
| 302 | + const h = await cat.importFromInventory( | ||
| 303 | + inv({ ...base, details: { type: 'hook', event: 'PostToolUse', matcher: 'Edit', handlerType: 'command', command: 'prettier' } }), | ||
| 304 | + ) | ||
| 305 | + expect((await cat.getItem({ type: 'hook', name: h.name }))?.template).toMatchObject({ | ||
| 306 | + event: 'PostToolUse', | ||
| 307 | + matcher: 'Edit', | ||
| 308 | + handler: { type: 'command', command: 'prettier' }, | ||
| 309 | + }) | ||
| 310 | + await expect( | ||
| 311 | + cat.importFromInventory(inv({ ...base, name: 'p', details: { type: 'hook', event: 'Stop', handlerType: 'prompt' } })), | ||
| 312 | + ).rejects.toThrow(/command/) | ||
| 313 | + await expect( | ||
| 314 | + cat.importFromInventory( | ||
| 315 | + inv({ type: 'instructions', name: 'CLAUDE.md', source: { path: '/c', kind: 'file' }, details: { type: 'instructions', fileName: 'CLAUDE.md', sizeBytes: 1, imports: [], loaded: true } }), | ||
| 316 | + ), | ||
| 317 | + ).rejects.toThrow(/Instructions/) | ||
| 318 | + }) | ||
| 319 | + | ||
| 320 | + it('imports a marketplace skill directory with upstream hash', async () => { | ||
| 321 | + const dir = path.join(tmp, 'market', 'cool') | ||
| 322 | + await put(path.join(dir, 'SKILL.md'), '---\nname: cool\ndescription: Cool\n---\n') | ||
| 323 | + await put(path.join(dir, 'x.txt'), 'x') | ||
| 324 | + const upstream = await hashPath(dir) | ||
| 325 | + const item = await cat.importSkillDirectory(dir, 'cool', { kind: 'marketplace', source: 'src1', sourcePath: 'cool', importedAt: 'now' }) | ||
| 326 | + expect(item.origin).toMatchObject({ kind: 'marketplace', source: 'src1', upstreamHash: upstream }) | ||
| 327 | + expect(item.hash).toBe(upstream) | ||
| 328 | + await expect(cat.importSkillDirectory(dir, 'cool', { kind: 'marketplace', importedAt: 'now' })).rejects.toThrow(/already exists/) | ||
| 329 | + await expect(cat.importSkillDirectory(path.join(tmp, 'nope'), 'zzz', { kind: 'marketplace', importedAt: 'now' })).rejects.toThrow() | ||
| 330 | + expect((await cat.snapshot()).items.map((i) => i.name)).toEqual(['cool']) | ||
| 331 | + }) | ||
| 332 | +}) | ||