hlassiege/panopliepublic Fork
⑂ 8e6675a
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(core): catalog store (items, presets, imports) and git sync wrapper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-23T12:05:33+02:00 Browse files
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'
27
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+ }
6247 }
@@ -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'
225
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
6601 }
@@ -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+})