@codepassion/skills 1.5.0 → 1.6.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/README.md CHANGED
@@ -1,21 +1,57 @@
1
1
  # C9N Agent Skills
2
2
 
3
- Agent skills (SKILL.md) based on the CodePassion (C9N) conventions.
3
+ Agent skills (SKILL.md) based on the CodePassion (C9N) conventions, plus the `c9n` CLI
4
+ that installs them and drives tag-based deployments.
4
5
 
5
6
  ## Install
6
7
 
7
- npx @codepassion/skills install claude # ~/.claude/skills
8
- npx @codepassion/skills install codex # ~/.codex/skills
9
- npx @codepassion/skills install project # ./.agents/skills
10
- npx @codepassion/skills install combined # one combined skill
11
- npx @codepassion/skills install cowork # zip skills for claude.ai/Cowork upload (UI, no API)
8
+ npm i -g @codepassion/skills # provides the c9n command
9
+ c9n skill install claude # ~/.claude/skills (default target)
10
+
11
+ Or without a global install:
12
+
13
+ npx -p @codepassion/skills c9n skill install claude
12
14
 
13
15
  Or: ./install.sh {claude|codex|project|combined|cowork}
14
16
 
15
- claude.ai and Claude Cowork share a personal skill library with no upload API, so `cowork`
16
- packages upload-ready zips into dist/cowork/ — upload them at
17
+ ## CLI
18
+
19
+ ### c9n skill
20
+
21
+ c9n skill install [target] # copy package skills into the target (skips existing; --force overwrites)
22
+ c9n skill list [target] # per-skill state: ok | drift | missing
23
+ c9n skill update [target] # overwrite drifted skills from the package (confirms; --yes skips)
24
+ c9n skill uninstall [target] # remove installed c9n skills (confirms; --yes skips)
25
+ c9n skill doctor [target] # diagnose skills dirs, drift, frontmatter, gh/zip tooling
26
+
27
+ Targets: `claude` (default, ~/.claude/skills) | `codex` (~/.codex/skills) |
28
+ `project` (./.agents/skills) | `combined` (one merged skill) | `cowork` (upload zips).
29
+ `--dest <dir>` overrides the directory; `--dry-run` previews any write.
30
+
31
+ State is a runtime content-hash comparison against the package — "drift" covers both
32
+ stale and hand-edited copies (see docs/adr/0001). `update` treats the package as the
33
+ source of truth; skill edits belong in this repo, not in installed copies.
34
+
35
+ claude.ai and Claude Cowork share a personal skill library with no upload API, so
36
+ `c9n skill install cowork` packages upload-ready zips into dist/cowork/ — upload them at
17
37
  claude.ai → Settings → Capabilities → Upload skill.
18
38
 
39
+ `c9n-skills` (the old bin) still works as a deprecated alias for `c9n skill`.
40
+
41
+ ### c9n deploy
42
+
43
+ Creates and pushes an annotated tag in any C9N project with a tag-driven pipeline —
44
+ the CLI never builds or ships artifacts itself.
45
+
46
+ c9n deploy -e alpha # tags vX.Y.Z-alpha.N (version from package.json, N auto-increments)
47
+ c9n deploy -e alpha -v 1.2.3 # explicit version — always bare X.Y.Z, never the -alpha.N suffix
48
+ c9n deploy -e production -v 1.2.3 # tags v1.2.3 (requires a prior alpha of the same version)
49
+
50
+ Guards (all must pass before the tag is pushed): clean working tree, on `main` and in
51
+ sync with origin, tag ordering (production requires a prior alpha; versions must move
52
+ forward), CI green on HEAD via `gh` (`--skip-ci` to bypass), and a confirmation prompt
53
+ (`--yes` for CI). `--dry-run` runs every guard and shows the tag without creating it.
54
+
19
55
  ## Edit
20
56
 
21
57
  Per-topic SKILL.md files in skills/c9n-* are the source of truth.
package/SKILLS.md CHANGED
@@ -1,21 +1,57 @@
1
1
  # C9N Agent Skills
2
2
 
3
- Agent skills (SKILL.md) based on the CodePassion (C9N) conventions.
3
+ Agent skills (SKILL.md) based on the CodePassion (C9N) conventions, plus the `c9n` CLI
4
+ that installs them and drives tag-based deployments.
4
5
 
5
6
  ## Install
6
7
 
7
- npx @codepassion/skills install claude # ~/.claude/skills
8
- npx @codepassion/skills install codex # ~/.codex/skills
9
- npx @codepassion/skills install project # ./.agents/skills
10
- npx @codepassion/skills install combined # one combined skill
11
- npx @codepassion/skills install cowork # zip skills for claude.ai/Cowork upload (UI, no API)
8
+ npm i -g @codepassion/skills # provides the c9n command
9
+ c9n skill install claude # ~/.claude/skills (default target)
10
+
11
+ Or without a global install:
12
+
13
+ npx -p @codepassion/skills c9n skill install claude
12
14
 
13
15
  Or: ./install.sh {claude|codex|project|combined|cowork}
14
16
 
15
- claude.ai and Claude Cowork share a personal skill library with no upload API, so `cowork`
16
- packages upload-ready zips into dist/cowork/ — upload them at
17
+ ## CLI
18
+
19
+ ### c9n skill
20
+
21
+ c9n skill install [target] # copy package skills into the target (skips existing; --force overwrites)
22
+ c9n skill list [target] # per-skill state: ok | drift | missing
23
+ c9n skill update [target] # overwrite drifted skills from the package (confirms; --yes skips)
24
+ c9n skill uninstall [target] # remove installed c9n skills (confirms; --yes skips)
25
+ c9n skill doctor [target] # diagnose skills dirs, drift, frontmatter, gh/zip tooling
26
+
27
+ Targets: `claude` (default, ~/.claude/skills) | `codex` (~/.codex/skills) |
28
+ `project` (./.agents/skills) | `combined` (one merged skill) | `cowork` (upload zips).
29
+ `--dest <dir>` overrides the directory; `--dry-run` previews any write.
30
+
31
+ State is a runtime content-hash comparison against the package — "drift" covers both
32
+ stale and hand-edited copies (see docs/adr/0001). `update` treats the package as the
33
+ source of truth; skill edits belong in this repo, not in installed copies.
34
+
35
+ claude.ai and Claude Cowork share a personal skill library with no upload API, so
36
+ `c9n skill install cowork` packages upload-ready zips into dist/cowork/ — upload them at
17
37
  claude.ai → Settings → Capabilities → Upload skill.
18
38
 
39
+ `c9n-skills` (the old bin) still works as a deprecated alias for `c9n skill`.
40
+
41
+ ### c9n deploy
42
+
43
+ Creates and pushes an annotated tag in any C9N project with a tag-driven pipeline —
44
+ the CLI never builds or ships artifacts itself.
45
+
46
+ c9n deploy -e alpha # tags vX.Y.Z-alpha.N (version from package.json, N auto-increments)
47
+ c9n deploy -e alpha -v 1.2.3 # explicit version — always bare X.Y.Z, never the -alpha.N suffix
48
+ c9n deploy -e production -v 1.2.3 # tags v1.2.3 (requires a prior alpha of the same version)
49
+
50
+ Guards (all must pass before the tag is pushed): clean working tree, on `main` and in
51
+ sync with origin, tag ordering (production requires a prior alpha; versions must move
52
+ forward), CI green on HEAD via `gh` (`--skip-ci` to bypass), and a confirmation prompt
53
+ (`--yes` for CI). `--dry-run` runs every guard and shows the tag without creating it.
54
+
19
55
  ## Edit
20
56
 
21
57
  Per-topic SKILL.md files in skills/c9n-* are the source of truth.
package/bin/c9n-skills.js CHANGED
@@ -1,61 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { cp, mkdir, readdir, rm, stat } from 'node:fs/promises'
3
- import { existsSync } from 'node:fs'
4
- import { homedir } from 'node:os'
5
- import { dirname, join, resolve } from 'node:path'
6
- import { fileURLToPath } from 'node:url'
7
- import { execFile } from 'node:child_process'
8
- const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'skills')
9
- const [,, cmd='install', target='claude', ...rest] = process.argv
10
- if (cmd==='help'||cmd==='--help'||cmd==='-h'){ help(); process.exit(0) }
11
- if (cmd!=='install'){ help(); process.exit(1) }
12
- const flags={dest:null,force:false,dryRun:false}
13
- for (let i=0;i<rest.length;i++){ const a=rest[i]
14
- if (a==='--force') flags.force=true
15
- else if (a==='--dry-run') flags.dryRun=true
16
- else if (a==='--dest'){ const v=rest[++i]
17
- if (!v){ console.error('--dest requires a value'); process.exit(1) }
18
- flags.dest=resolve(v) } }
19
- // cowork: claude.ai / Cowork share a personal skill library with NO upload API —
20
- // this packages upload-ready zips for manual drag-drop in Settings → Capabilities.
21
- if (target==='cowork'){ await packageForCowork(flags); process.exit(0) }
22
- const dest = flags.dest ?? defaultDest(target)
23
- if (!dest){ help(); process.exit(1) }
24
- const sources = await resolveSources(target)
25
- if (!sources.length){ console.error('No skills found'); process.exit(1) }
26
- console.log(`Installing ${sources.length} skill(s) into ${dest}${flags.dryRun?' (dry-run)':''}`)
27
- if (!flags.dryRun) await mkdir(dest,{recursive:true})
28
- for (const src of sources){ const n=src.split(/[\\/]/).pop(); const t=join(dest,n)
29
- if (existsSync(t) && !flags.force){ console.log(` skip ${n}`); continue }
30
- console.log(` copy ${n}`); if (!flags.dryRun) await cp(src,t,{recursive:true,force:true}) }
31
- console.log('Done.')
32
- async function resolveSources(t){
33
- if (t==='combined'){ const c=join(SRC,'c9n'); return existsSync(c)?[c]:[] }
34
- const out=[]; for (const e of await readdir(SRC)){ if (!e.startsWith('c9n-')) continue
35
- const f=join(SRC,e); if ((await stat(f)).isDirectory()) out.push(f) }
36
- return out }
37
- function defaultDest(t){ const h=homedir()
38
- if (t==='claude'||t==='combined') return join(h,'.claude','skills')
39
- if (t==='codex') return join(h,'.codex','skills')
40
- if (t==='project') return join(process.cwd(),'.agents','skills')
41
- return null }
42
- async function packageForCowork(flags){
43
- const ROOT=resolve(SRC,'..')
44
- // only auto-clean the default output dir — never delete a user-supplied --dest
45
- const isDefaultOut=!flags.dest
46
- const out=flags.dest ?? join(ROOT,'dist','cowork')
47
- const sources=[...await resolveSources('combined'), ...await resolveSources('cowork')]
48
- if (!sources.length){ console.error('No skills found'); process.exit(1) }
49
- console.log(`Packaging ${sources.length} skill zip(s) into ${out}${flags.dryRun?' (dry-run)':''}`)
50
- if (!flags.dryRun){ if (isDefaultOut) await rm(out,{recursive:true,force:true}); await mkdir(out,{recursive:true}) }
51
- for (const src of sources){ const n=src.split(/[\\/]/).pop()
52
- console.log(` zip ${n}.zip`)
53
- if (!flags.dryRun) await zipDir(SRC,n,join(out,`${n}.zip`)) }
54
- console.log('Done. Upload at claude.ai → Settings → Capabilities → Upload skill (shared with Cowork).')
55
- console.log('Action skills (c9n-spec, c9n-deliverables) bundle their templates/scripts in the zip.')
56
- }
57
- function zipDir(cwd,name,outFile){
58
- return new Promise((res,rej)=>{ execFile('zip',['-qr',outFile,name],{cwd},(e)=>{
59
- if (e && e.code==='ENOENT') rej(new Error("'zip' command not found — install zip and retry"))
60
- else if (e) rej(e); else res() }) }) }
61
- function help(){ console.log('c9n-skills install [claude|codex|project|combined|cowork] [--dest <dir>] [--force] [--dry-run]') }
2
+ // Deprecated alias: `c9n-skills <cmd> ...` `c9n skill <cmd> ...`
3
+ console.error('note: `c9n-skills` is deprecated use `c9n skill <command>` instead')
4
+ process.argv.splice(2, 0, 'skill')
5
+ await import('./c9n.js')
package/bin/c9n.js ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { skillCommand, skillHelp } from './skill.js'
3
+ import { deployCommand, deployHelp } from './deploy.js'
4
+
5
+ const [, , cmd, ...rest] = process.argv
6
+ try {
7
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') help(cmd ? 0 : 1)
8
+ else if (cmd === 'skill') await skillCommand(rest)
9
+ else if (cmd === 'deploy') await deployCommand(rest)
10
+ else { console.error(`unknown command: ${cmd}`); help(1) }
11
+ } catch (e) {
12
+ console.error(`error: ${e.message}`)
13
+ process.exit(1)
14
+ }
15
+
16
+ function help(code) {
17
+ console.log(`c9n — CodePassion (C9N) engineering CLI
18
+
19
+ Usage:
20
+ c9n skill <install|list|update|uninstall|doctor> [target] [options]
21
+ c9n deploy -e <alpha|production> [-v <X.Y.Z>] [options]
22
+
23
+ Run \`c9n skill help\` or \`c9n deploy help\` for details.`)
24
+ process.exit(code)
25
+ }
package/bin/deploy.js ADDED
@@ -0,0 +1,114 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { confirm, has, parseArgs, run } from './util.js'
4
+
5
+ const RELEASE_BRANCH = 'main'
6
+
7
+ export async function deployCommand(argv) {
8
+ if (['help', '--help', '-h'].includes(argv[0])) return deployHelp(0)
9
+ const flags = parseArgs(argv, { flags: ['yes', 'skip-ci', 'dry-run'], values: { env: 'e', version: 'v' } })
10
+ if (flags._.length) return deployHelp(1)
11
+ const env = flags.env
12
+ if (!env) throw new Error('missing -e <alpha|production>')
13
+ if (env !== 'alpha' && env !== 'production') throw new Error(`unknown environment: ${env} (use alpha | production)`)
14
+
15
+ const root = await git('rev-parse', '--show-toplevel').catch(() => { throw new Error('not inside a git repository') })
16
+ const version = await resolveVersion(flags.version, root)
17
+
18
+ // Guards: clean tree, on release branch, in sync with origin
19
+ const branch = await git('rev-parse', '--abbrev-ref', 'HEAD')
20
+ if (branch !== RELEASE_BRANCH) throw new Error(`deploy runs from ${RELEASE_BRANCH} — you are on ${branch}`)
21
+ if (await git('status', '--porcelain')) throw new Error('working tree is not clean — commit or stash first')
22
+ await git('fetch', 'origin', RELEASE_BRANCH, '--tags')
23
+ const head = await git('rev-parse', 'HEAD')
24
+ const remote = await git('rev-parse', `origin/${RELEASE_BRANCH}`)
25
+ if (head !== remote) throw new Error(`${RELEASE_BRANCH} is not in sync with origin/${RELEASE_BRANCH} — pull or push first`)
26
+
27
+ // Guard: tag ordering
28
+ const tags = (await git('tag', '-l', 'v*')).split('\n').filter(Boolean)
29
+ const prod = tags.filter((t) => /^v\d+\.\d+\.\d+$/.test(t)).map((t) => t.slice(1))
30
+ const latestProd = prod.sort(cmpVer).at(-1)
31
+ let tag
32
+ if (env === 'alpha') {
33
+ if (prod.includes(version)) throw new Error(`v${version} is already released to production — bump the version`)
34
+ if (latestProd && cmpVer(version, latestProd) < 0) throw new Error(`v${version} is older than latest production release v${latestProd}`)
35
+ const ns = tags.map((t) => t.match(new RegExp(`^v${version.replaceAll('.', '\\.')}-alpha\\.(\\d+)$`))).filter(Boolean).map((m) => Number(m[1]))
36
+ tag = `v${version}-alpha.${Math.max(0, ...ns) + 1}`
37
+ } else {
38
+ tag = `v${version}`
39
+ if (tags.includes(tag)) throw new Error(`${tag} already exists`)
40
+ if (!tags.some((t) => t.startsWith(`${tag}-alpha.`))) throw new Error(`no alpha of ${version} found — deploy \`-e alpha\` first`)
41
+ if (latestProd && cmpVer(version, latestProd) <= 0) throw new Error(`v${version} is not newer than latest production release v${latestProd}`)
42
+ }
43
+
44
+ // Guard: CI green on HEAD
45
+ if (!flags['skip-ci']) await checkCi(head)
46
+ else console.log('· CI check skipped (--skip-ci)')
47
+
48
+ const subject = await git('log', '-1', '--format=%h %s')
49
+ console.log(`\nDeploy ${env}`)
50
+ console.log(` tag ${tag}`)
51
+ console.log(` commit ${subject}`)
52
+ if (flags['dry-run']) { console.log('\n(dry-run) would create and push the tag.'); return }
53
+ if (!flags.yes && !(await confirm(`\nCreate and push ${tag}?`))) { console.log('Aborted.'); return }
54
+
55
+ await git('tag', '-a', tag, '-m', tag)
56
+ try {
57
+ await git('push', 'origin', tag)
58
+ } catch (e) {
59
+ throw new Error(`tag ${tag} created locally but push failed — retry with \`git push origin ${tag}\`\n${e.stderr ?? e.message}`)
60
+ }
61
+ console.log(`Pushed ${tag} — the pipeline takes it from here.`)
62
+ }
63
+
64
+ async function resolveVersion(raw, root) {
65
+ let v = raw
66
+ if (!v) {
67
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8').catch(() => { throw new Error('no -v given and no package.json at repo root') }))
68
+ v = pkg.version
69
+ if (!v) throw new Error('no -v given and package.json has no version')
70
+ }
71
+ v = v.replace(/^v/, '')
72
+ if (!/^\d+\.\d+\.\d+$/.test(v)) throw new Error(`version must be bare X.Y.Z (got ${raw ?? v}) — the CLI appends -alpha.N itself`)
73
+ return v
74
+ }
75
+
76
+ async function checkCi(sha) {
77
+ if (!(await has('gh'))) throw new Error('gh CLI required for the CI check — install GitHub CLI or pass --skip-ci')
78
+ let runs
79
+ try {
80
+ runs = JSON.parse(await run('gh', ['run', 'list', '--commit', sha, '--json', 'name,status,conclusion', '--limit', '50']))
81
+ } catch (e) {
82
+ throw new Error(`could not query CI runs via gh — ${String(e.stderr ?? e.message).trim()} (or pass --skip-ci)`)
83
+ }
84
+ if (!runs.length) throw new Error('no CI runs found for HEAD — wait for CI to start or pass --skip-ci')
85
+ const pending = runs.filter((r) => r.status !== 'completed')
86
+ if (pending.length) throw new Error(`CI still running for HEAD: ${pending.map((r) => r.name).join(', ')}`)
87
+ const failed = runs.filter((r) => !['success', 'skipped', 'neutral'].includes(r.conclusion))
88
+ if (failed.length) throw new Error(`CI not green for HEAD: ${failed.map((r) => `${r.name} (${r.conclusion})`).join(', ')}`)
89
+ console.log(`· CI green on HEAD (${runs.length} run(s))`)
90
+ }
91
+
92
+ function cmpVer(a, b) {
93
+ const [ax, ay, az] = a.split('.').map(Number); const [bx, by, bz] = b.split('.').map(Number)
94
+ return ax - bx || ay - by || az - bz
95
+ }
96
+
97
+ function git(...args) {
98
+ return run('git', args)
99
+ }
100
+
101
+ export function deployHelp(code) {
102
+ console.log(`Usage: c9n deploy -e <alpha|production> [-v <X.Y.Z>] [options]
103
+
104
+ Creates and pushes an annotated tag — the tag-driven pipeline does the rest.
105
+ alpha tags vX.Y.Z-alpha.N (N auto-increments)
106
+ production tags vX.Y.Z (requires a prior alpha of the same version)
107
+
108
+ Options:
109
+ -v <X.Y.Z> version to deploy (default: package.json version; never include -alpha.N)
110
+ --yes skip the confirmation prompt
111
+ --skip-ci skip the CI-green check (no gh needed)
112
+ --dry-run run all guards, show the tag, don't create it`)
113
+ process.exit(code)
114
+ }
package/bin/skill.js ADDED
@@ -0,0 +1,207 @@
1
+ import { cp, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { createHash } from 'node:crypto'
4
+ import { homedir } from 'node:os'
5
+ import { dirname, join, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { execFile } from 'node:child_process'
8
+ import { confirm, has, parseArgs } from './util.js'
9
+
10
+ const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'skills')
11
+ const TARGETS = ['claude', 'codex', 'project', 'combined', 'cowork']
12
+
13
+ export async function skillCommand(argv) {
14
+ if (['help', '--help', '-h'].includes(argv[0])) return skillHelp(0)
15
+ const flags = parseArgs(argv, { flags: ['force', 'dry-run', 'yes'], values: { dest: null } })
16
+ const [sub = 'install', target = 'claude'] = flags._
17
+ if (!TARGETS.includes(target)) throw new Error(`unknown target: ${target} (use ${TARGETS.join(' | ')})`)
18
+ const cmds = { install, list, update, uninstall, doctor }
19
+ if (!cmds[sub]) { console.error(`unknown skill command: ${sub}`); return skillHelp(1) }
20
+ if (sub !== 'install' && target === 'cowork') {
21
+ throw new Error(`cowork is packaged as zips, not installed — only \`c9n skill install cowork\` applies`)
22
+ }
23
+ await cmds[sub](target, { ...flags, dryRun: flags['dry-run'], dest: flags.dest ? resolve(flags.dest) : null })
24
+ }
25
+
26
+ async function install(target, flags) {
27
+ if (target === 'cowork') return packageForCowork(flags)
28
+ const dest = flags.dest ?? defaultDest(target)
29
+ const sources = await resolveSources(target)
30
+ if (!sources.length) throw new Error('No skills found')
31
+ console.log(`Installing ${sources.length} skill(s) into ${dest}${flags.dryRun ? ' (dry-run)' : ''}`)
32
+ if (!flags.dryRun) await mkdir(dest, { recursive: true })
33
+ for (const src of sources) {
34
+ const n = src.split(/[\\/]/).pop(); const t = join(dest, n)
35
+ if (existsSync(t) && !flags.force) { console.log(` skip ${n}`); continue }
36
+ console.log(` copy ${n}`)
37
+ if (!flags.dryRun) await cp(src, t, { recursive: true, force: true })
38
+ }
39
+ console.log('Done.')
40
+ }
41
+
42
+ async function list(target, flags) {
43
+ const dest = flags.dest ?? defaultDest(target)
44
+ const rows = await status(target, dest)
45
+ console.log(`Skills in ${dest}:`)
46
+ for (const r of rows) console.log(` ${r.state.padEnd(9)} ${r.name}`)
47
+ const drifted = rows.filter((r) => r.state === 'drift').length
48
+ if (drifted) console.log(`\n${drifted} skill(s) differ from the package — \`c9n skill update ${target}\` overwrites them.`)
49
+ }
50
+
51
+ async function update(target, flags) {
52
+ const dest = flags.dest ?? defaultDest(target)
53
+ const drifted = (await status(target, dest)).filter((r) => r.state === 'drift')
54
+ if (!drifted.length) { console.log('All installed skills match the package — nothing to update.'); return }
55
+ console.log(`Package differs from ${drifted.length} installed skill(s) in ${dest}:`)
56
+ for (const r of drifted) console.log(` ${r.name}`)
57
+ console.log('Local edits will be overwritten — the package is the source of truth.')
58
+ if (flags.dryRun) { console.log('(dry-run) no changes made.'); return }
59
+ if (!flags.yes && !(await confirm(`Overwrite ${drifted.length} skill(s)?`))) { console.log('Aborted.'); return }
60
+ for (const r of drifted) {
61
+ console.log(` copy ${r.name}`)
62
+ await cp(join(SRC, r.name), join(dest, r.name), { recursive: true, force: true })
63
+ }
64
+ console.log('Done.')
65
+ }
66
+
67
+ async function uninstall(target, flags) {
68
+ const dest = flags.dest ?? defaultDest(target)
69
+ const installed = (await status(target, dest)).filter((r) => r.state !== 'missing')
70
+ if (!installed.length) { console.log(`No c9n skills installed in ${dest}.`); return }
71
+ console.log(`Removing ${installed.length} skill(s) from ${dest}:`)
72
+ for (const r of installed) console.log(` ${r.name}`)
73
+ if (flags.dryRun) { console.log('(dry-run) no changes made.'); return }
74
+ if (!flags.yes && !(await confirm(`Remove ${installed.length} skill(s)?`))) { console.log('Aborted.'); return }
75
+ for (const r of installed) await rm(join(dest, r.name), { recursive: true, force: true })
76
+ console.log('Done.')
77
+ }
78
+
79
+ async function doctor(target, flags) {
80
+ const dest = flags.dest ?? defaultDest(target)
81
+ let problems = 0
82
+ const check = (ok, label, hint) => {
83
+ console.log(` ${ok ? '✓' : '✗'} ${label}${ok || !hint ? '' : ` — ${hint}`}`)
84
+ if (!ok) problems++
85
+ }
86
+ console.log(`Doctor (target: ${target})`)
87
+ check(existsSync(dest), `skills dir exists: ${dest}`, `run \`c9n skill install ${target}\``)
88
+ if (existsSync(dest)) {
89
+ const rows = await status(target, dest)
90
+ const fresh = rows.filter((r) => r.state === 'ok').length
91
+ const drift = rows.filter((r) => r.state === 'drift')
92
+ const missing = rows.filter((r) => r.state === 'missing')
93
+ check(!drift.length, `installed skills match package (${fresh}/${rows.length} fresh)`,
94
+ `${drift.length} differ (${drift.map((r) => r.name).join(', ')}) — \`c9n skill update ${target}\``)
95
+ if (missing.length) console.log(` · ${missing.length} not installed (${missing.map((r) => r.name).join(', ')})`)
96
+ for (const r of rows) {
97
+ if (r.state === 'missing') continue
98
+ const f = join(dest, r.name, 'SKILL.md')
99
+ const ok = existsSync(f) && /^---\r?\n[\s\S]*?\bname:/.test(await readFile(f, 'utf8'))
100
+ if (!ok) check(false, `${r.name}/SKILL.md has valid frontmatter`, 'reinstall with --force')
101
+ }
102
+ }
103
+ check(await has('gh'), 'gh CLI available (needed by `c9n deploy` CI check)', 'install GitHub CLI or use --skip-ci')
104
+ check(await hasZip(), 'zip available (needed by `c9n skill install cowork`)', 'install zip')
105
+ if (problems) { console.log(`\n${problems} problem(s) found.`); process.exitCode = 1 }
106
+ else console.log('\nAll good.')
107
+ }
108
+
109
+ async function status(target, dest) {
110
+ const rows = []
111
+ for (const src of await resolveSources(target)) {
112
+ const name = src.split(/[\\/]/).pop(); const t = join(dest, name)
113
+ if (!existsSync(t)) { rows.push({ name, state: 'missing' }); continue }
114
+ rows.push({ name, state: (await hashDir(src)) === (await hashDir(t)) ? 'ok' : 'drift' })
115
+ }
116
+ return rows
117
+ }
118
+
119
+ async function hashDir(dir) {
120
+ const h = createHash('sha256')
121
+ for (const rel of (await walk(dir)).sort()) {
122
+ h.update(rel); h.update('\0'); h.update(await readFile(join(dir, rel))); h.update('\0')
123
+ }
124
+ return h.digest('hex')
125
+ }
126
+
127
+ async function walk(dir, prefix = '') {
128
+ const out = []
129
+ for (const e of await readdir(dir)) {
130
+ if (e === '.DS_Store') continue
131
+ const f = join(dir, e); const rel = prefix ? `${prefix}/${e}` : e
132
+ if ((await stat(f)).isDirectory()) out.push(...await walk(f, rel))
133
+ else out.push(rel)
134
+ }
135
+ return out
136
+ }
137
+
138
+ async function resolveSources(t) {
139
+ if (t === 'combined') { const c = join(SRC, 'c9n'); return existsSync(c) ? [c] : [] }
140
+ const out = []
141
+ for (const e of await readdir(SRC)) {
142
+ if (!e.startsWith('c9n-')) continue
143
+ const f = join(SRC, e); if ((await stat(f)).isDirectory()) out.push(f)
144
+ }
145
+ return out
146
+ }
147
+
148
+ function defaultDest(t) {
149
+ const h = homedir()
150
+ if (t === 'claude' || t === 'combined') return join(h, '.claude', 'skills')
151
+ if (t === 'codex') return join(h, '.codex', 'skills')
152
+ if (t === 'project') return join(process.cwd(), '.agents', 'skills')
153
+ return null
154
+ }
155
+
156
+ // cowork: claude.ai / Cowork share a personal skill library with NO upload API —
157
+ // this packages upload-ready zips for manual drag-drop in Settings → Capabilities.
158
+ async function packageForCowork(flags) {
159
+ const ROOT = resolve(SRC, '..')
160
+ // only auto-clean the default output dir — never delete a user-supplied --dest
161
+ const isDefaultOut = !flags.dest
162
+ const out = flags.dest ?? join(ROOT, 'dist', 'cowork')
163
+ const sources = [...await resolveSources('combined'), ...await resolveSources('cowork')]
164
+ if (!sources.length) throw new Error('No skills found')
165
+ console.log(`Packaging ${sources.length} skill zip(s) into ${out}${flags.dryRun ? ' (dry-run)' : ''}`)
166
+ if (!flags.dryRun) { if (isDefaultOut) await rm(out, { recursive: true, force: true }); await mkdir(out, { recursive: true }) }
167
+ for (const src of sources) {
168
+ const n = src.split(/[\\/]/).pop()
169
+ console.log(` zip ${n}.zip`)
170
+ if (!flags.dryRun) await zipDir(SRC, n, join(out, `${n}.zip`))
171
+ }
172
+ console.log('Done. Upload at claude.ai → Settings → Capabilities → Upload skill (shared with Cowork).')
173
+ console.log('Action skills (c9n-spec, c9n-deliverables) bundle their templates/scripts in the zip.')
174
+ }
175
+
176
+ function zipDir(cwd, name, outFile) {
177
+ return new Promise((res, rej) => {
178
+ execFile('zip', ['-qr', outFile, name], { cwd }, (e) => {
179
+ if (e && e.code === 'ENOENT') rej(new Error("'zip' command not found — install zip and retry"))
180
+ else if (e) rej(e); else res()
181
+ })
182
+ })
183
+ }
184
+
185
+ async function hasZip() {
186
+ return new Promise((res) => execFile('zip', ['-v'], (e) => res(!e || e.code !== 'ENOENT')))
187
+ }
188
+
189
+ export function skillHelp(code) {
190
+ console.log(`Usage: c9n skill <command> [target] [options]
191
+
192
+ Commands:
193
+ install copy package skills into the target (default command)
194
+ list show installed/missing/drift state per skill
195
+ update overwrite drifted installed skills from the package
196
+ uninstall remove installed c9n skills from the target
197
+ doctor diagnose skills dirs and required tooling
198
+
199
+ Targets: ${TARGETS.join(' | ')} (default: claude)
200
+
201
+ Options:
202
+ --dest <dir> override the target directory
203
+ --force install: overwrite existing skills
204
+ --yes update/uninstall: skip confirmation
205
+ --dry-run show what would happen without writing`)
206
+ process.exit(code)
207
+ }
package/bin/util.js ADDED
@@ -0,0 +1,45 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { createInterface } from 'node:readline'
3
+
4
+ export function run(cmd, args, opts = {}) {
5
+ return new Promise((res, rej) => {
6
+ execFile(cmd, args, opts, (e, stdout, stderr) => {
7
+ if (e) { e.stdout = stdout; e.stderr = stderr; rej(e) }
8
+ else res(String(stdout).trim())
9
+ })
10
+ })
11
+ }
12
+
13
+ export async function has(cmd) {
14
+ try { await run(cmd, ['--version']); return true } catch { return false }
15
+ }
16
+
17
+ export async function confirm(question) {
18
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
19
+ const answer = await new Promise((r) => rl.question(`${question} [y/N] `, r))
20
+ rl.close()
21
+ return /^y(es)?$/i.test(answer.trim())
22
+ }
23
+
24
+ export function parseArgs(argv, spec) {
25
+ // spec: { flags: ['force', ...], values: { dest: null, env: 'e', version: 'v' } }
26
+ const out = { _: [] }
27
+ for (const k of spec.flags ?? []) out[k] = false
28
+ for (const k of Object.keys(spec.values ?? {})) out[k] = null
29
+ const shorts = Object.fromEntries(Object.entries(spec.values ?? {}).filter(([, s]) => s).map(([k, s]) => [`-${s}`, k]))
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i]
32
+ const flag = a.startsWith('--') ? a.slice(2) : null
33
+ if (flag && (spec.flags ?? []).includes(flag)) { out[flag] = true; continue }
34
+ const key = flag && flag in (spec.values ?? {}) ? flag : shorts[a]
35
+ if (key) {
36
+ const v = argv[++i]
37
+ if (v === undefined) throw new Error(`${a} requires a value`)
38
+ out[key] = v
39
+ continue
40
+ }
41
+ if (a.startsWith('-')) throw new Error(`unknown option: ${a}`)
42
+ out._.push(a)
43
+ }
44
+ return out
45
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@codepassion/skills",
3
- "version": "1.5.0",
4
- "description": "Agent skills (SKILL.md) for CodePassion (C9N) engineering conventions.",
3
+ "version": "1.6.0",
4
+ "description": "Agent skills (SKILL.md) for CodePassion (C9N) engineering conventions, plus the c9n CLI for skill installs and tag-based deploys.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
+ "c9n": "./bin/c9n.js",
8
9
  "c9n-skills": "./bin/c9n-skills.js"
9
10
  },
10
11
  "files": [