@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 +21 -0
- package/README.md +34 -0
- package/index.mjs +5 -0
- package/lib/cli.mjs +100 -0
- package/lib/copy.mjs +18 -0
- package/lib/install.mjs +128 -0
- package/lib/manifest.mjs +32 -0
- package/lib/settings.mjs +53 -0
- package/lib/targets.mjs +42 -0
- package/package.json +30 -0
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
|
+
}
|
package/lib/install.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/manifest.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/settings.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/targets.mjs
ADDED
|
@@ -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
|
+
}
|