@skilldeck/core 0.1.0 → 0.1.1

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/lib/cli.mjs CHANGED
@@ -7,6 +7,7 @@ export const COMMON_OPTIONS = {
7
7
  agent: { type: 'string', short: 'a', default: 'claude' },
8
8
  project: { type: 'boolean', short: 'p', default: false },
9
9
  force: { type: 'boolean', short: 'f', default: false },
10
+ skill: { type: 'string', short: 's', multiple: true },
10
11
  'dry-run': { type: 'boolean', default: false },
11
12
  help: { type: 'boolean', short: 'h', default: false },
12
13
  version: { type: 'boolean', short: 'v', default: false },
@@ -15,6 +16,7 @@ export const COMMON_OPTIONS = {
15
16
  export const OPTIONS_HELP = `Options:
16
17
  -a, --agent <name> claude (default), codex, agents, all, or a comma list
17
18
  -p, --project install into ./.claude (or ./.codex, ./.agents) instead of your home
19
+ -s, --skill <name> only these skills from the pack (repeat, or comma-separate)
18
20
  -f, --force replace skill folders that SkillDeck didn't install
19
21
  --dry-run show what would happen without writing anything
20
22
  -h, --help show help
@@ -35,14 +37,15 @@ export function parse(argv, extra = {}) {
35
37
  }
36
38
 
37
39
  export function toOpts(values) {
38
- return { agent: values.agent, project: values.project, force: values.force, dryRun: values['dry-run'] }
40
+ const skills = (values.skill ?? []).flatMap((s) => s.split(',')).map((s) => s.trim()).filter(Boolean)
41
+ return { agent: values.agent, project: values.project, force: values.force, dryRun: values['dry-run'], skills: skills.length ? skills : undefined }
39
42
  }
40
43
 
41
44
  export function printInstall(pack, results) {
42
45
  for (const r of results) {
43
46
  const verb = r.dryRun ? 'would install' : r.upgraded ? 'updated' : 'installed'
44
47
  console.log(`${green('✓')} ${bold(pack.name)}@${pack.version} ${verb} for ${r.label} ${dim('→ ' + tilde(r.skillsDir))}`)
45
- console.log(dim(` skills: ${r.skills.join(', ')}`))
48
+ console.log(dim(` skills: ${r.skills.join(', ')}${r.partial ? ' (selected)' : ''}`))
46
49
  if (r.hook) console.log(dim(` hook: ${r.hook.event} in ${tilde(r.hook.settingsFile)}`))
47
50
  if (r.note) console.log(yellow(` note: ${r.note}`))
48
51
  }
@@ -50,7 +53,7 @@ export function printInstall(pack, results) {
50
53
 
51
54
  export function printUninstall(name, results) {
52
55
  for (const r of results) {
53
- if (r.removed) console.log(`${green('✓')} removed ${bold(name)} from ${r.label}${r.hook ? dim(' (and its hook)') : ''}`)
56
+ if (r.removed) console.log(`${green('✓')} removed ${bold(r.partial ? `${name}: ${r.skills.join(', ')}` : name)} from ${r.label}${r.hook ? dim(' (and its hook)') : ''}`)
54
57
  else console.log(dim(`- ${name} is not installed for ${r.label}`))
55
58
  }
56
59
  }
package/lib/install.mjs CHANGED
@@ -15,14 +15,37 @@ function hookCommandFor(target, pack) {
15
15
  return target.hookCommand(`${pack.name}/${pack.hook.script}`)
16
16
  }
17
17
 
18
- /** Install a pack for each requested agent. Throws before writing anything if a conflict is found. */
18
+ /** Skills a hook depends on (e.g. the bootstrap skill it injects). The hook is installed only with them. */
19
+ const hookSkills = (pack) => (pack.hook?.include ?? []).filter((p) => p.startsWith('skills/')).map((p) => p.split('/')[1])
20
+
21
+ /** Validate an optional `skills` subset against what the pack ships. */
22
+ function selectSkills(pack, wanted) {
23
+ if (!wanted?.length) return { selected: pack.skills, partial: false }
24
+ const unknown = wanted.filter((s) => !pack.skills.includes(s))
25
+ if (unknown.length) throw new Error(`${pack.name} has no skill named ${unknown.join(', ')}. Available: ${pack.skills.join(', ')}`)
26
+ const selected = pack.skills.filter((s) => wanted.includes(s))
27
+ return { selected, partial: selected.length < pack.skills.length }
28
+ }
29
+
30
+ function removeHookFiles(hook) {
31
+ removeHook(hook.settingsFile, { event: hook.event, command: hook.command })
32
+ fs.rmSync(hook.dir, { recursive: true, force: true })
33
+ const parent = path.dirname(hook.dir)
34
+ if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) fs.rmdirSync(parent)
35
+ }
36
+
37
+ /**
38
+ * Install a pack (or, with `skills`, just some of its skills) for each requested agent.
39
+ * Throws before writing anything if a conflict is found.
40
+ */
19
41
  export function install(pack, opts = {}) {
20
42
  const { force = false, dryRun = false } = opts
43
+ const { selected, partial } = selectSkills(pack, opts.skills)
21
44
  const agents = expandAgents(opts.agent)
22
45
  const plans = agents.map((agent) => {
23
46
  const target = resolveTarget(agent, opts)
24
47
  const manifest = readManifest(target.skillsDir)
25
- const conflicts = pack.skills.filter((s) => {
48
+ const conflicts = selected.filter((s) => {
26
49
  const owner = ownerOf(manifest, s)
27
50
  return owner !== pack.name && (owner || fs.existsSync(path.join(target.skillsDir, s)))
28
51
  })
@@ -35,26 +58,32 @@ export function install(pack, opts = {}) {
35
58
  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
59
  }
37
60
 
61
+ const wantsHook = Boolean(pack.hook) && (!partial || hookSkills(pack).every((s) => selected.includes(s)))
62
+
38
63
  return plans.map(({ target, manifest }) => {
39
64
  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 }
65
+ const result = { agent: target.agent, label: target.label, skillsDir: target.skillsDir, skills: selected, partial, hook: null, upgraded: Boolean(previous), dryRun }
41
66
 
42
67
  if (!dryRun) {
43
- for (const skill of pack.skills) {
68
+ for (const skill of selected) {
44
69
  const dst = path.join(target.skillsDir, skill)
45
70
  fs.rmSync(dst, { recursive: true, force: true })
46
71
  fs.mkdirSync(target.skillsDir, { recursive: true })
47
72
  copyTree(path.join(pack.root, 'skills', skill), dst)
48
73
  }
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 })
74
+ // A full install drops skills a previous version shipped but this one doesn't.
75
+ if (!partial) {
76
+ for (const old of previous?.skills ?? []) {
77
+ if (!pack.skills.includes(old)) fs.rmSync(path.join(target.skillsDir, old), { recursive: true, force: true })
78
+ }
52
79
  }
53
80
  }
54
81
 
55
- const entry = { version: pack.version, skills: pack.skills, installedAt: new Date().toISOString() }
82
+ // Partial installs add to what's already there.
83
+ const skills = partial ? pack.skills.filter((s) => selected.includes(s) || previous?.skills?.includes(s)) : pack.skills
84
+ const entry = { version: pack.version, skills, installedAt: new Date().toISOString() }
56
85
 
57
- if (pack.hook && target.hooks) {
86
+ if (wantsHook && target.hooks) {
58
87
  const hookDir = path.join(target.hookRoot, pack.name)
59
88
  const command = hookCommandFor(target, pack)
60
89
  if (!dryRun) {
@@ -66,9 +95,11 @@ export function install(pack, opts = {}) {
66
95
  }
67
96
  addHook(target.settingsFile, { event: pack.hook.event, matcher: pack.hook.matcher, command })
68
97
  }
69
- entry.hook = { event: pack.hook.event, command, dir: hookDir, settingsFile: target.settingsFile }
98
+ entry.hook = { event: pack.hook.event, command, dir: hookDir, settingsFile: target.settingsFile, skills: hookSkills(pack) }
70
99
  result.hook = entry.hook
71
- } else if (pack.hook) {
100
+ } else if (previous?.hook) {
101
+ entry.hook = previous.hook
102
+ } else if (wantsHook) {
72
103
  result.note = `${target.label} has no hook system: skills installed, but the session-start bootstrap won't run automatically.`
73
104
  }
74
105
 
@@ -80,7 +111,7 @@ export function install(pack, opts = {}) {
80
111
  })
81
112
  }
82
113
 
83
- /** Remove exactly what the manifest says this pack installed. */
114
+ /** Remove exactly what the manifest says this pack installed (or, with `skills`, only those skills). */
84
115
  export function uninstall(packName, opts = {}) {
85
116
  const agents = expandAgents(opts.agent)
86
117
  return agents.map((agent) => {
@@ -88,18 +119,27 @@ export function uninstall(packName, opts = {}) {
88
119
  const manifest = readManifest(target.skillsDir)
89
120
  const entry = manifest.packs[packName]
90
121
  if (!entry) return { agent, label: target.label, removed: false }
122
+
123
+ const installed = entry.skills ?? []
124
+ const wanted = opts.skills?.length ? opts.skills : installed
125
+ const removing = installed.filter((s) => wanted.includes(s))
126
+ if (removing.length === 0) return { agent, label: target.label, removed: false }
127
+ const remaining = installed.filter((s) => !removing.includes(s))
128
+ // The hook goes with the last skill, or with the skill it injects.
129
+ const hook = entry.hook ?? null
130
+ const dropHook = Boolean(hook) && (remaining.length === 0 || (hook.skills ?? []).some((s) => removing.includes(s)))
131
+
91
132
  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)
133
+ for (const s of removing) fs.rmSync(path.join(target.skillsDir, s), { recursive: true, force: true })
134
+ if (dropHook) removeHookFiles(hook)
135
+ if (remaining.length === 0) delete manifest.packs[packName]
136
+ else {
137
+ entry.skills = remaining
138
+ if (dropHook) delete entry.hook
98
139
  }
99
- delete manifest.packs[packName]
100
140
  writeManifest(target.skillsDir, manifest)
101
141
  }
102
- return { agent, label: target.label, removed: true, skills: entry.skills, hook: entry.hook ?? null }
142
+ return { agent, label: target.label, removed: true, skills: removing, partial: remaining.length > 0, hook: dropHook ? hook : null }
103
143
  })
104
144
  }
105
145
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilldeck/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Installer library for SkillDeck skill packs: copies SKILL.md folders into Claude Code, Codex or .agents and wires hooks.",
5
5
  "type": "module",
6
6
  "exports": {