@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 +44 -8
- package/SKILLS.md +44 -8
- package/bin/c9n-skills.js +4 -60
- package/bin/c9n.js +25 -0
- package/bin/deploy.js +114 -0
- package/bin/skill.js +207 -0
- package/bin/util.js +45 -0
- package/package.json +3 -2
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import
|
|
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.
|
|
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": [
|