@skilldeck/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kitasid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @skilldeck/core
2
+
3
+ The installer library behind [`@skilldeck/cli`](https://www.npmjs.com/package/@skilldeck/cli) and every SkillDeck pack. Use it to ship your own SKILL.md pack as an npm package.
4
+
5
+ ```js
6
+ import { install, uninstall, status, runPackCli } from '@skilldeck/core'
7
+
8
+ const pack = {
9
+ name: 'my-pack',
10
+ version: '1.0.0',
11
+ root: '/abs/path/to/package', // contains skills/<name>/SKILL.md
12
+ skills: ['my-skill'],
13
+ hook: { // optional, Claude Code only
14
+ event: 'SessionStart',
15
+ matcher: 'startup|clear|compact',
16
+ script: 'hooks/session-start',
17
+ include: ['hooks/session-start'], // copied to ~/.claude/skilldeck/my-pack/
18
+ },
19
+ }
20
+
21
+ install(pack, { agent: 'claude', project: false }) // or agent: 'all'
22
+ uninstall('my-pack', { agent: 'claude' })
23
+ status()
24
+ ```
25
+
26
+ For a ready-made `install | uninstall | status` CLI, call `await runPackCli(pack, process.argv.slice(2))` from your package's bin.
27
+
28
+ Behaviour:
29
+ - Existing folders SkillDeck didn't create are never overwritten without `force`.
30
+ - Hooks are merged into `settings.json` idempotently, and the file is backed up first.
31
+ - Hook and script files are made executable after copying.
32
+ - Options `home` and `cwd` override the target locations (useful in tests).
33
+
34
+ MIT licensed. Node 20+.
package/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ export { install, uninstall, status } from './lib/install.mjs'
2
+ export { AGENTS, expandAgents, resolveTarget } from './lib/targets.mjs'
3
+ export { addHook, removeHook } from './lib/settings.mjs'
4
+ export { readManifest, MANIFEST } from './lib/manifest.mjs'
5
+ export { runPackCli, parse, toOpts, printInstall, printUninstall, printStatus, OPTIONS_HELP, bold, dim, green, red, yellow } from './lib/cli.mjs'
package/lib/cli.mjs ADDED
@@ -0,0 +1,100 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+ import { parseArgs } from 'node:util'
4
+ import { install, status, uninstall } from './install.mjs'
5
+
6
+ export const COMMON_OPTIONS = {
7
+ agent: { type: 'string', short: 'a', default: 'claude' },
8
+ project: { type: 'boolean', short: 'p', default: false },
9
+ force: { type: 'boolean', short: 'f', default: false },
10
+ 'dry-run': { type: 'boolean', default: false },
11
+ help: { type: 'boolean', short: 'h', default: false },
12
+ version: { type: 'boolean', short: 'v', default: false },
13
+ }
14
+
15
+ export const OPTIONS_HELP = `Options:
16
+ -a, --agent <name> claude (default), codex, agents, all, or a comma list
17
+ -p, --project install into ./.claude (or ./.codex, ./.agents) instead of your home
18
+ -f, --force replace skill folders that SkillDeck didn't install
19
+ --dry-run show what would happen without writing anything
20
+ -h, --help show help
21
+ -v, --version show version`
22
+
23
+ const c = process.stdout.isTTY && !process.env.NO_COLOR
24
+ const paint = (code) => (s) => (c ? `\x1b[${code}m${s}\x1b[0m` : String(s))
25
+ export const dim = paint(2)
26
+ export const bold = paint(1)
27
+ export const green = paint(32)
28
+ export const yellow = paint(33)
29
+ export const red = paint(31)
30
+
31
+ const tilde = (p) => (p.startsWith(os.homedir()) ? '~' + p.slice(os.homedir().length) : p)
32
+
33
+ export function parse(argv, extra = {}) {
34
+ return parseArgs({ args: argv, options: { ...COMMON_OPTIONS, ...extra }, allowPositionals: true, strict: true })
35
+ }
36
+
37
+ export function toOpts(values) {
38
+ return { agent: values.agent, project: values.project, force: values.force, dryRun: values['dry-run'] }
39
+ }
40
+
41
+ export function printInstall(pack, results) {
42
+ for (const r of results) {
43
+ const verb = r.dryRun ? 'would install' : r.upgraded ? 'updated' : 'installed'
44
+ console.log(`${green('✓')} ${bold(pack.name)}@${pack.version} ${verb} for ${r.label} ${dim('→ ' + tilde(r.skillsDir))}`)
45
+ console.log(dim(` skills: ${r.skills.join(', ')}`))
46
+ if (r.hook) console.log(dim(` hook: ${r.hook.event} in ${tilde(r.hook.settingsFile)}`))
47
+ if (r.note) console.log(yellow(` note: ${r.note}`))
48
+ }
49
+ }
50
+
51
+ export function printUninstall(name, results) {
52
+ for (const r of results) {
53
+ if (r.removed) console.log(`${green('✓')} removed ${bold(name)} from ${r.label}${r.hook ? dim(' (and its hook)') : ''}`)
54
+ else console.log(dim(`- ${name} is not installed for ${r.label}`))
55
+ }
56
+ }
57
+
58
+ export function printStatus(rows) {
59
+ if (rows.length === 0) {
60
+ console.log(dim('No SkillDeck packs installed yet.'))
61
+ return
62
+ }
63
+ for (const r of rows) {
64
+ if (r.error) console.log(red(`! ${r.label}: ${r.error}`))
65
+ else console.log(`${bold(r.pack)}@${r.version} ${r.label}${r.hook ? ' + hook' : ''} ${dim(tilde(r.skillsDir))}`)
66
+ }
67
+ }
68
+
69
+ /** Standard `install | uninstall | status` CLI for a single pack. `extra` adds pack-specific commands. */
70
+ export async function runPackCli(pack, argv, { extraCommands = {}, extraHelp = '' } = {}) {
71
+ const [cmd, ...rest] = argv
72
+ const bin = path.basename(process.argv[1] ?? pack.name).replace(/\.mjs$/, '')
73
+ const help = `${bold(pack.name)} ${pack.version}
74
+
75
+ Usage:
76
+ npx @skilldeck/${pack.name} install [options] install the skills for your agent
77
+ npx @skilldeck/${pack.name} uninstall [options] remove them again
78
+ npx @skilldeck/${pack.name} status show where they're installed
79
+ ${extraHelp}
80
+ ${OPTIONS_HELP}`
81
+
82
+ try {
83
+ if (extraCommands[cmd]) return await extraCommands[cmd](rest)
84
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return void console.log(help)
85
+ if (cmd === '--version' || cmd === '-v') return void console.log(pack.version)
86
+ const { values } = parse(rest)
87
+ if (values.help) return void console.log(help)
88
+ const opts = toOpts(values)
89
+ if (cmd === 'install') printInstall(pack, install(pack, opts))
90
+ else if (cmd === 'uninstall' || cmd === 'remove') printUninstall(pack.name, uninstall(pack.name, opts))
91
+ else if (cmd === 'status') printStatus(status(opts).filter((r) => r.pack === pack.name || r.error))
92
+ else {
93
+ console.error(red(`Unknown command "${cmd}".`) + ` Run ${bin} --help.`)
94
+ process.exitCode = 1
95
+ }
96
+ } catch (e) {
97
+ console.error(red('✗ ') + e.message)
98
+ process.exitCode = 1
99
+ }
100
+ }
package/lib/copy.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ const EXECUTABLE = (rel) => /(^|\/)hooks\/[^/.]+$/.test(rel) || /(^|\/)(scripts|bin)\/[^/]+\.(mjs|js|sh)$/.test(rel)
5
+
6
+ /** Recursive copy that also restores the executable bit on hooks and scripts (tarballs can drop it). */
7
+ export function copyTree(src, dst) {
8
+ fs.cpSync(src, dst, { recursive: true, force: true })
9
+ const walk = (dir) => {
10
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
11
+ const p = path.join(dir, e.name)
12
+ if (e.isDirectory()) walk(p)
13
+ else if (EXECUTABLE(path.relative(path.dirname(dst), p).split(path.sep).join('/'))) fs.chmodSync(p, 0o755)
14
+ }
15
+ }
16
+ if (fs.statSync(dst).isDirectory()) walk(dst)
17
+ else if (EXECUTABLE(src.split(path.sep).join('/'))) fs.chmodSync(dst, 0o755)
18
+ }
@@ -0,0 +1,128 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { copyTree } from './copy.mjs'
4
+ import { ownerOf, readManifest, writeManifest } from './manifest.mjs'
5
+ import { addHook, removeHook } from './settings.mjs'
6
+ import { AGENTS, expandAgents, resolveTarget } from './targets.mjs'
7
+
8
+ /**
9
+ * A pack describes itself as:
10
+ * { name, version, root, skills: string[],
11
+ * hook?: { event, matcher, script, include: string[] } } // paths relative to root
12
+ */
13
+
14
+ function hookCommandFor(target, pack) {
15
+ return target.hookCommand(`${pack.name}/${pack.hook.script}`)
16
+ }
17
+
18
+ /** Install a pack for each requested agent. Throws before writing anything if a conflict is found. */
19
+ export function install(pack, opts = {}) {
20
+ const { force = false, dryRun = false } = opts
21
+ const agents = expandAgents(opts.agent)
22
+ const plans = agents.map((agent) => {
23
+ const target = resolveTarget(agent, opts)
24
+ const manifest = readManifest(target.skillsDir)
25
+ const conflicts = pack.skills.filter((s) => {
26
+ const owner = ownerOf(manifest, s)
27
+ return owner !== pack.name && (owner || fs.existsSync(path.join(target.skillsDir, s)))
28
+ })
29
+ return { target, manifest, conflicts }
30
+ })
31
+
32
+ const blocked = plans.filter((p) => p.conflicts.length)
33
+ if (blocked.length && !force) {
34
+ const lines = blocked.map((p) => ` ${p.target.label}: ${p.conflicts.map((c) => path.join(p.target.skillsDir, c)).join(', ')}`)
35
+ throw new Error(`These skill folders already exist and weren't installed by ${pack.name}:\n${lines.join('\n')}\nRe-run with --force to replace them.`)
36
+ }
37
+
38
+ return plans.map(({ target, manifest }) => {
39
+ const previous = manifest.packs[pack.name]
40
+ const result = { agent: target.agent, label: target.label, skillsDir: target.skillsDir, skills: pack.skills, hook: null, upgraded: Boolean(previous), dryRun }
41
+
42
+ if (!dryRun) {
43
+ for (const skill of pack.skills) {
44
+ const dst = path.join(target.skillsDir, skill)
45
+ fs.rmSync(dst, { recursive: true, force: true })
46
+ fs.mkdirSync(target.skillsDir, { recursive: true })
47
+ copyTree(path.join(pack.root, 'skills', skill), dst)
48
+ }
49
+ // Drop skills a previous version shipped but this one doesn't.
50
+ for (const old of previous?.skills ?? []) {
51
+ if (!pack.skills.includes(old)) fs.rmSync(path.join(target.skillsDir, old), { recursive: true, force: true })
52
+ }
53
+ }
54
+
55
+ const entry = { version: pack.version, skills: pack.skills, installedAt: new Date().toISOString() }
56
+
57
+ if (pack.hook && target.hooks) {
58
+ const hookDir = path.join(target.hookRoot, pack.name)
59
+ const command = hookCommandFor(target, pack)
60
+ if (!dryRun) {
61
+ fs.rmSync(hookDir, { recursive: true, force: true })
62
+ for (const rel of pack.hook.include) {
63
+ const dst = path.join(hookDir, rel)
64
+ fs.mkdirSync(path.dirname(dst), { recursive: true })
65
+ copyTree(path.join(pack.root, rel), dst)
66
+ }
67
+ addHook(target.settingsFile, { event: pack.hook.event, matcher: pack.hook.matcher, command })
68
+ }
69
+ entry.hook = { event: pack.hook.event, command, dir: hookDir, settingsFile: target.settingsFile }
70
+ result.hook = entry.hook
71
+ } else if (pack.hook) {
72
+ result.note = `${target.label} has no hook system: skills installed, but the session-start bootstrap won't run automatically.`
73
+ }
74
+
75
+ if (!dryRun) {
76
+ manifest.packs[pack.name] = entry
77
+ writeManifest(target.skillsDir, manifest)
78
+ }
79
+ return result
80
+ })
81
+ }
82
+
83
+ /** Remove exactly what the manifest says this pack installed. */
84
+ export function uninstall(packName, opts = {}) {
85
+ const agents = expandAgents(opts.agent)
86
+ return agents.map((agent) => {
87
+ const target = resolveTarget(agent, opts)
88
+ const manifest = readManifest(target.skillsDir)
89
+ const entry = manifest.packs[packName]
90
+ if (!entry) return { agent, label: target.label, removed: false }
91
+ if (!opts.dryRun) {
92
+ for (const s of entry.skills ?? []) fs.rmSync(path.join(target.skillsDir, s), { recursive: true, force: true })
93
+ if (entry.hook) {
94
+ removeHook(entry.hook.settingsFile, { event: entry.hook.event, command: entry.hook.command })
95
+ fs.rmSync(entry.hook.dir, { recursive: true, force: true })
96
+ const parent = path.dirname(entry.hook.dir)
97
+ if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) fs.rmdirSync(parent)
98
+ }
99
+ delete manifest.packs[packName]
100
+ writeManifest(target.skillsDir, manifest)
101
+ }
102
+ return { agent, label: target.label, removed: true, skills: entry.skills, hook: entry.hook ?? null }
103
+ })
104
+ }
105
+
106
+ /** Installed packs per agent target, for both user and project scope. */
107
+ export function status(opts = {}) {
108
+ const rows = []
109
+ const seen = new Set()
110
+ for (const project of [false, true]) {
111
+ for (const agent of AGENTS) {
112
+ const target = resolveTarget(agent, { ...opts, project })
113
+ if (seen.has(target.skillsDir)) continue
114
+ seen.add(target.skillsDir)
115
+ let manifest
116
+ try {
117
+ manifest = readManifest(target.skillsDir)
118
+ } catch (e) {
119
+ rows.push({ agent, label: target.label, skillsDir: target.skillsDir, error: e.message })
120
+ continue
121
+ }
122
+ for (const [name, entry] of Object.entries(manifest.packs)) {
123
+ rows.push({ agent, label: target.label, skillsDir: target.skillsDir, pack: name, version: entry.version, skills: entry.skills, hook: Boolean(entry.hook) })
124
+ }
125
+ }
126
+ }
127
+ return rows
128
+ }
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export const MANIFEST = '.skilldeck.json'
5
+
6
+ /** Per-skills-directory record of what SkillDeck installed, so removal touches nothing else. */
7
+ export function readManifest(skillsDir) {
8
+ const file = path.join(skillsDir, MANIFEST)
9
+ if (!fs.existsSync(file)) return { packs: {} }
10
+ try {
11
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'))
12
+ return data && typeof data.packs === 'object' ? data : { packs: {} }
13
+ } catch {
14
+ throw new Error(`${file} is not valid JSON. Fix or delete it, then retry.`)
15
+ }
16
+ }
17
+
18
+ export function writeManifest(skillsDir, manifest) {
19
+ const file = path.join(skillsDir, MANIFEST)
20
+ if (Object.keys(manifest.packs).length === 0) {
21
+ fs.rmSync(file, { force: true })
22
+ return
23
+ }
24
+ fs.mkdirSync(skillsDir, { recursive: true })
25
+ fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n')
26
+ }
27
+
28
+ /** Which pack (if any) owns a skill directory name. */
29
+ export function ownerOf(manifest, skill) {
30
+ for (const [name, entry] of Object.entries(manifest.packs)) if (entry.skills?.includes(skill)) return name
31
+ return null
32
+ }
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ function read(file) {
5
+ if (!fs.existsSync(file)) return {}
6
+ const text = fs.readFileSync(file, 'utf8')
7
+ if (!text.trim()) return {}
8
+ try {
9
+ return JSON.parse(text)
10
+ } catch {
11
+ throw new Error(`${file} is not valid JSON; refusing to modify it. Fix it and retry.`)
12
+ }
13
+ }
14
+
15
+ function write(file, data) {
16
+ fs.mkdirSync(path.dirname(file), { recursive: true })
17
+ if (fs.existsSync(file)) fs.copyFileSync(file, file + '.bak')
18
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n')
19
+ }
20
+
21
+ const hasCommand = (entry, command) => entry?.hooks?.some((h) => h.command === command)
22
+
23
+ /** Add a command hook for `event`. Idempotent: keyed on the exact command string. Returns true if written. */
24
+ export function addHook(file, { event, matcher, command, timeout = 10 }) {
25
+ const settings = read(file)
26
+ settings.hooks ??= {}
27
+ settings.hooks[event] ??= []
28
+ if (settings.hooks[event].some((e) => hasCommand(e, command))) return false
29
+ settings.hooks[event].push({ matcher, hooks: [{ type: 'command', command, timeout }] })
30
+ write(file, settings)
31
+ return true
32
+ }
33
+
34
+ /** Remove every hook whose command matches, pruning empty entries. Returns true if written. */
35
+ export function removeHook(file, { event, command }) {
36
+ if (!fs.existsSync(file)) return false
37
+ const settings = read(file)
38
+ const list = settings.hooks?.[event]
39
+ if (!Array.isArray(list) || !list.some((e) => hasCommand(e, command))) return false
40
+ settings.hooks[event] = list
41
+ .map((e) => ({ ...e, hooks: (e.hooks ?? []).filter((h) => h.command !== command) }))
42
+ .filter((e) => e.hooks.length > 0)
43
+ if (settings.hooks[event].length === 0) delete settings.hooks[event]
44
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks
45
+ // Nothing left but what we added: remove the file rather than leave an empty `{}` behind.
46
+ if (Object.keys(settings).length === 0) {
47
+ fs.copyFileSync(file, file + '.bak')
48
+ fs.rmSync(file)
49
+ return true
50
+ }
51
+ write(file, settings)
52
+ return true
53
+ }
@@ -0,0 +1,42 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ export const AGENTS = ['claude', 'codex', 'agents']
5
+
6
+ /**
7
+ * Where a given agent looks for skills, and whether it supports hooks.
8
+ * `project` scopes everything to `cwd` instead of the user's home.
9
+ */
10
+ export function resolveTarget(agent, { home = os.homedir(), cwd = process.cwd(), project = false } = {}) {
11
+ const base = project ? cwd : home
12
+ switch (agent) {
13
+ case 'claude':
14
+ return {
15
+ agent,
16
+ label: project ? 'Claude Code (project)' : 'Claude Code',
17
+ skillsDir: path.join(base, '.claude', 'skills'),
18
+ hooks: true,
19
+ settingsFile: path.join(base, '.claude', 'settings.json'),
20
+ hookRoot: path.join(base, '.claude', 'skilldeck'),
21
+ // Project installs reference $CLAUDE_PROJECT_DIR so the checked-in settings work on every machine.
22
+ hookCommand: (rel) =>
23
+ project ? `"$CLAUDE_PROJECT_DIR"/.claude/skilldeck/${rel}` : `"${path.join(base, '.claude', 'skilldeck', rel)}"`,
24
+ }
25
+ case 'codex':
26
+ return { agent, label: project ? 'Codex (project)' : 'Codex', skillsDir: path.join(base, '.codex', 'skills'), hooks: false }
27
+ case 'agents':
28
+ return { agent, label: project ? '.agents (project)' : '.agents', skillsDir: path.join(base, '.agents', 'skills'), hooks: false }
29
+ default:
30
+ throw new Error(`Unknown agent "${agent}". Use one of: ${AGENTS.join(', ')}, all`)
31
+ }
32
+ }
33
+
34
+ export function expandAgents(value = 'claude') {
35
+ const list = String(value)
36
+ .split(',')
37
+ .map((s) => s.trim())
38
+ .filter(Boolean)
39
+ if (list.includes('all')) return [...AGENTS]
40
+ for (const a of list) if (!AGENTS.includes(a)) throw new Error(`Unknown agent "${a}". Use one of: ${AGENTS.join(', ')}, all`)
41
+ return [...new Set(list)]
42
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@skilldeck/core",
3
+ "version": "0.1.0",
4
+ "description": "Installer library for SkillDeck skill packs: copies SKILL.md folders into Claude Code, Codex or .agents and wires hooks.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.mjs"
8
+ },
9
+ "main": "./index.mjs",
10
+ "files": [
11
+ "index.mjs",
12
+ "lib/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "license": "MIT",
20
+ "keywords": [
21
+ "claude-code",
22
+ "agent-skills",
23
+ "skill-md",
24
+ "installer"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "author": "kitasid"
30
+ }