@victortomaili/skill-cli 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/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - "Star the repo" call to action in `skill --help` (with the `gh repo star` command) and the README.
12
+
13
+ ### Planned
14
+ - Cursor adapter (`.cursor/rules` format) for `init -g` bootstrap.
15
+ - Per-agent hook adapters for automatic `/X` triggering (push model).
16
+
17
+ ## [0.1.0] - 2026-07-07
18
+
19
+ ### Added
20
+ - **Central store + activation model** — skills live in `~/.skill-cli/store`;
21
+ agent directories stay clean.
22
+ - **Commands:** `init [-g]`, `install`, `update`, `enable [-g]`, `disable [-g]`,
23
+ `list`, `show`, `cat`, `trigger`. Aliases: `ls`, `add`, `info`.
24
+ - **Universal install sources** — `owner/repo`, GitHub/GitLab URL, git URL, local
25
+ path, npm package — delegated to `npx skills add` in a temp cwd (agent folders
26
+ untouched).
27
+ - **Three-layer activation** — installed → enabled globally → enabled per project,
28
+ with `allow` winning over `deny` and case-insensitive matching throughout.
29
+ - **Idempotent `AGENTS.md` bootstrap** — injected into `CLAUDE.md`, `AGENTS.md`,
30
+ `GEMINI.md` behind `BEGIN/END` markers; never duplicates, preserves existing
31
+ content.
32
+ - **Pull-based `/X` triggers** — `skill trigger <keyword>` resolves to content
33
+ (single match), candidates (multiple), or info (none).
34
+ - **Cross-platform** — Windows (`cmd.exe /c npx`, `CVE-2024-27980` workaround),
35
+ macOS, Linux.
36
+ - **`skill.config`** per-project override: `inherit` / `deny` / `allow`.
37
+ - **`.source` tracking** so `skill update` can re-fetch and diff (sha256 +
38
+ version compare); exits non-zero on failure.
39
+ - **157 tests** (unit + CLI), network-free by default; opt-in `npm run test:e2e`
40
+ for the real `npx` fetch path.
41
+
42
+ ### Security
43
+ - `install`/`update` destination names are sanitized (`SAFE_NAME`) — a malicious
44
+ frontmatter `name: ../x` cannot escape the store or `rmSync` outside it.
45
+ - `update` case-folds skill names (consistent with all other commands).
46
+ - Windows `npx` spawn runs with `shell:false` + an args array and rejects sources
47
+ containing shell metacharacters (`& | < > ^`).
48
+
49
+ [Unreleased]: https://github.com/victortomaili/skill-cli/compare/v0.1.0...HEAD
50
+ [0.1.0]: https://github.com/victortomaili/skill-cli/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 skill-cli contributors
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,192 @@
1
+ # skill-cli
2
+
3
+ > One skill store. Clean agent folders. Activate from anywhere.
4
+
5
+ [![CI](https://github.com/victortomaili/skill-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/victortomaili/skill-cli/actions/workflows/ci.yml)
6
+ [![npm version](https://img.shields.io/npm/v/@victortomaili/skill-cli.svg)](https://www.npmjs.com/package/@victortomaili/skill-cli)
7
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
+ [![node](https://img.shields.io/badge/node-%3E%3D22-green.svg)](https://nodejs.org)
9
+ [![tests](https://img.shields.io/badge/tests-157%20passing-brightgreen.svg)](#development)
10
+
11
+ **skill-cli** is a cross-agent skill manager for AI coding agents (Claude Code,
12
+ Codex, Cursor, Gemini, …). Skills live in a single global store — your agent
13
+ directories stay **clean** — and you control which skill is active where with one
14
+ config file.
15
+
16
+ - 🗂️ **One canonical store** — `~/.skill-cli/store`. No more copies scattered across `~/.claude`, `~/.codex`, `~/.cursor`, …
17
+ - 🧹 **Agent folders untouched** — nothing is written into agent directories.
18
+ - 🎛️ **Three-layer activation** — installed → enabled globally → enabled per project. `allow` always wins over `deny`.
19
+ - 🔌 **Universal sources** — `owner/repo`, GitHub/GitLab URL, git URL, local path, npm package (via `npx skills`).
20
+ - ⚡ **Pull-based** — agents pull skill content into context on demand via `skill trigger /X`. No hooks required.
21
+ - 🖥️ **Cross-platform** — Windows, macOS, Linux (handles the Windows `npx` spawn quirk for you).
22
+ - 🧪 **Well tested** — 157 unit + CLI tests, network-free by default.
23
+
24
+ ## ⭐ Found this useful?
25
+
26
+ If skill-cli saves you time, please [give it a star ⭐](https://github.com/victortomaili/skill-cli)
27
+ on GitHub — it helps others discover the project and keeps development going. Thank you! 💜
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ npm install -g @victortomaili/skill-cli
33
+ ```
34
+
35
+ Requires Node.js 22+.
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ # 1) one-time global setup (creates the store + bootstraps detected agents)
41
+ skill init -g
42
+
43
+ # 2) install a skill from any source
44
+ skill install owner/repo
45
+
46
+ # 3) use it — type /<trigger> in your agent, or load directly
47
+ skill list
48
+ skill cat <name>
49
+ ```
50
+
51
+ That's it. Your agent now knows: *when the user types `/X`, run `skill trigger X`*.
52
+
53
+ ## How it works
54
+
55
+ ```
56
+ ~/.skill-cli/
57
+ store/<skill>/SKILL.md ← one canonical store (skills live here)
58
+ config.yaml ← global config (enabled_global)
59
+
60
+ <project>/skill.config ← per-project activation (inherit / deny / allow)
61
+ ~/.claude/CLAUDE.md ← bootstrap block injected by `skill init -g`
62
+ ~/.codex/AGENTS.md (idempotent — preserves your existing content)
63
+ ~/.gemini/GEMINI.md
64
+ ```
65
+
66
+ Agents don't read `skill.config` directly — the CLI does. The agent only needs the
67
+ short bootstrap block telling it to run `skill` on `/X`.
68
+
69
+ ## Commands
70
+
71
+ | Command | Description |
72
+ |---|---|
73
+ | `skill init -g` | Global setup: create store + inject bootstrap into agent files |
74
+ | `skill init` | Create a `skill.config` for the current project |
75
+ | `skill install <source>` | Fetch skill(s) to the store (agent dirs untouched) |
76
+ | `skill enable <name> [-g]` | Enable in project, or globally with `-g` |
77
+ | `skill disable <name> [-g]` | Disable |
78
+ | `skill list` | Show installed + active skills (cwd-aware) |
79
+ | `skill show <name>` | Skill metadata (path, triggers, version) |
80
+ | `skill cat <name>` | Dump skill content into context |
81
+ | `skill trigger <keyword>` | `/X` trigger: single→content, multi→candidates, none→info |
82
+ | `skill update [name…\|--all]` | Re-fetch from source, update changed skills |
83
+
84
+ Aliases: `ls` (list), `add` (install), `info` (show).
85
+
86
+ ## Install sources
87
+
88
+ `install` delegates resolution to `npx skills`, so every format works:
89
+
90
+ ```
91
+ owner/repo GitHub shorthand
92
+ https://github.com/owner/repo full GitHub URL
93
+ https://github.com/owner/repo/tree/main/skills/x path inside a repo
94
+ https://gitlab.com/org/repo GitLab URL
95
+ git@github.com:owner/repo.git any git URL
96
+ ./my-local-skills local path
97
+ <npm-package> npm package shipping skills
98
+ ```
99
+
100
+ Install runs `npx skills add` in a **temp cwd** so files land in `<tmp>/.claude/skills/`
101
+ and are then moved into `~/.skill-cli/store/`. Your real agent directories are
102
+ **never** written to.
103
+
104
+ ## Skill format
105
+
106
+ Faithful to the `SKILL.md` standard (the author decides the structure):
107
+
108
+ ```yaml
109
+ ---
110
+ name: deep-research
111
+ description: Deep source-research workflow
112
+ version: 1.2.0
113
+ triggers: [research, deep-search] # skill-cli-specific; powers /<trigger>
114
+ ---
115
+ # Your instructions here
116
+ ```
117
+
118
+ `triggers` is optional and skill-cli-specific. See [`examples/hello-world/SKILL.md`](examples/hello-world/SKILL.md)
119
+ for a minimal, copy-paste template.
120
+
121
+ ## Activation
122
+
123
+ Three layers, most-specific wins:
124
+
125
+ 1. **installed** — present in the store (passive)
126
+ 2. **enabled globally** — listed in `config.yaml`; active by default in every project
127
+ 3. **enabled per project** — in the project's `skill.config` `allow` list
128
+
129
+ A project `skill.config` overrides the global set. Pure allowlist mode:
130
+
131
+ ```yaml
132
+ # <project>/skill.config
133
+ inherit: true
134
+ deny: ["*"] # block every globally-enabled skill in this project
135
+ allow: [react-best-practices] # then open them one by one
136
+ ```
137
+
138
+ > `allow` always wins over `deny`, so `deny: ["*"]` + `allow: [X]` = "only X".
139
+ > Matching is case-insensitive throughout.
140
+
141
+ ## Bootstrap
142
+
143
+ `skill init -g` injects a short, idempotent block into each detected agent's global
144
+ instruction file (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`):
145
+
146
+ > When the user types `/X`, run `skill trigger X`. Single match → apply the output;
147
+ > multiple → show candidates; load each skill only once per session.
148
+
149
+ It's wrapped in `<!-- BEGIN skill-cli --> … <!-- END skill-cli -->` markers, never
150
+ duplicates, and preserves your existing file content. Re-run `init -g` any time —
151
+ it reports `already set up` and rewrites nothing.
152
+
153
+ **Supported agents:** Claude Code, Codex, Gemini. (Cursor uses `.cursor/rules` with
154
+ a different format — adapter planned. See [issue tracker](https://github.com/victortomaili/skill-cli/issues).)
155
+
156
+ ## Cross-platform
157
+
158
+ Works on Windows, macOS, and Linux. On Windows it spawns `npx` via `cmd.exe /c`
159
+ (the Node 20+ `CVE-2024-27980` workaround) with `shell:false` and validates the
160
+ source against shell metacharacters, so it's safe by default.
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ git clone https://github.com/victortomaili/skill-cli
166
+ cd skill-cli
167
+ npm install
168
+ npm link # makes `skill` point at your checkout
169
+ npm test # 157 tests, network-free (~3s)
170
+ npm run test:e2e # opt-in: real npx fetch over the network
171
+ ```
172
+
173
+ Tests never touch your real `~` — they use an isolated `SKILL_CLI_HOME`. See
174
+ [CONTRIBUTING.md](./CONTRIBUTING.md) for the project structure, how to add a command,
175
+ and the PR checklist.
176
+
177
+ ## Contributing
178
+
179
+ Contributions are welcome! 💜 Please read [CONTRIBUTING.md](./CONTRIBUTING.md) and
180
+ follow our [Code of Conduct](./CODE_OF_CONDUCT.md).
181
+
182
+ - 🐛 [Report a bug](https://github.com/victortomaili/skill-cli/issues/new?template=bug_report.yml)
183
+ - ✨ [Request a feature](https://github.com/victortomaili/skill-cli/issues/new?template=feature_request.yml)
184
+ - 🔒 Found a security issue? See [SECURITY.md](./SECURITY.md) — report it privately, not as a public issue.
185
+
186
+ ## Changelog
187
+
188
+ See [CHANGELOG.md](./CHANGELOG.md).
189
+
190
+ ## License
191
+
192
+ [MIT](./LICENSE) © skill-cli contributors
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@victortomaili/skill-cli",
3
+ "version": "0.1.0",
4
+ "description": "Cross-agent skill manager — one global store, clean agent folders, activation via skill.config",
5
+ "license": "MIT",
6
+ "author": "Victor Tomaili <victor@tomaili.com>",
7
+ "homepage": "https://github.com/victortomaili/skill-cli#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/victortomaili/skill-cli.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/victortomaili/skill-cli/issues"
14
+ },
15
+ "type": "module",
16
+ "bin": {
17
+ "skill": "./src/cli.js"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "README.md",
22
+ "LICENSE",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "scripts": {
29
+ "test": "node --test",
30
+ "test:e2e": "node scripts/e2e.mjs"
31
+ },
32
+ "dependencies": {
33
+ "picocolors": "^1.1.1",
34
+ "yaml": "^2.6.0"
35
+ },
36
+ "keywords": [
37
+ "cli",
38
+ "skills",
39
+ "skill",
40
+ "agent",
41
+ "ai",
42
+ "ai-agent",
43
+ "llm",
44
+ "claude",
45
+ "claude-code",
46
+ "codex",
47
+ "cursor",
48
+ "gemini",
49
+ "prompt-engineering",
50
+ "developer-tools",
51
+ "workflow"
52
+ ],
53
+ "publishConfig": {
54
+ "access": "public",
55
+ "registry": "https://registry.npmjs.org/"
56
+ }
57
+ }
package/src/cli.js ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ import c from 'picocolors'
3
+ import { cmdInit } from './commands/init.js'
4
+ import { cmdList } from './commands/list.js'
5
+ import { cmdTrigger } from './commands/trigger.js'
6
+ import { cmdShow } from './commands/show.js'
7
+ import { cmdCat } from './commands/cat.js'
8
+ import { cmdEnable } from './commands/enable.js'
9
+ import { cmdDisable } from './commands/disable.js'
10
+ import { cmdInstall } from './commands/install.js'
11
+ import { cmdUpdate } from './commands/update.js'
12
+ import { VERSION } from './lib/version.js'
13
+
14
+ const HELP = `${c.bold('skill')} ${c.gray('v' + VERSION)} — cross-agent skill manager
15
+ ${c.gray('Single global store + activation (skill.config) + bootstrap (AGENTS.md).')}
16
+
17
+ ${c.bold('Setup')}
18
+ ${c.cyan('skill init -g')} global setup (store + AGENTS.md injection)
19
+ ${c.cyan('skill init')} create skill.config for this project
20
+
21
+ ${c.bold('Acquire skills')}
22
+ ${c.cyan('skill install')} ${c.gray('<source>')} fetch to store via npx skills (agent dirs untouched)
23
+
24
+ ${c.bold('Activation')}
25
+ ${c.cyan('skill enable')} ${c.gray('<name> [-g]')} enable in project, or globally (-g)
26
+ ${c.cyan('skill disable')} ${c.gray('<name> [-g]')} disable
27
+ ${c.cyan('skill list')} installed + active skills (cwd-aware)
28
+
29
+ ${c.bold('Usage (agent)')}
30
+ ${c.cyan('skill show')} ${c.gray('<name>')} metadata + path + triggers
31
+ ${c.cyan('skill cat')} ${c.gray('<name>')} dump content to context
32
+ ${c.cyan('skill trigger')} ${c.gray('<keyword>')} /X trigger: single→content, multi→candidates
33
+
34
+ ${c.bold('Maintenance')}
35
+ ${c.cyan('skill update')} ${c.gray('[name|--all]')} refresh store from source
36
+
37
+ ${c.gray('Source formats (install): owner/repo | github/gitlab URL | git URL | local path | npm package')}
38
+ ${c.gray('Test (no real ~ touched): SKILL_CLI_HOME=/tmp/sktest skill init -g')}
39
+
40
+ ${c.bold('★ Enjoying skill-cli?')} ${c.gray('Star the repo — it helps others find it.')}
41
+ ${c.cyan('gh repo star victortomaili/skill-cli')}
42
+ ${c.gray('or: https://github.com/victortomaili/skill-cli')}
43
+ `
44
+
45
+ const [, , cmd, ...rest] = process.argv
46
+
47
+ try {
48
+ switch (cmd) {
49
+ case undefined:
50
+ case '-h': case '--help': case 'help':
51
+ console.log(HELP); break
52
+ case 'init': cmdInit(rest); break
53
+ case 'install': case 'add': cmdInstall(rest); break
54
+ case 'enable': cmdEnable(rest); break
55
+ case 'disable': cmdDisable(rest); break
56
+ case 'list': case 'ls': cmdList(rest); break
57
+ case 'show': case 'info': cmdShow(rest); break
58
+ case 'cat': cmdCat(rest); break
59
+ case 'trigger': cmdTrigger(rest); break
60
+ case 'update': cmdUpdate(rest); break
61
+ case '-v': case '--version':
62
+ console.log('skill-cli ' + VERSION); break
63
+ default:
64
+ console.error(c.red('Unknown command: ' + cmd))
65
+ console.error(c.gray(' skill --help'))
66
+ process.exit(1)
67
+ }
68
+ } catch (e) {
69
+ console.error(c.red('Error: ') + e.message)
70
+ process.exit(1)
71
+ }
@@ -0,0 +1,12 @@
1
+ import c from 'picocolors'
2
+ import { readSkill } from '../lib/store.js'
3
+
4
+ // Dumps the skill body so it can be loaded into context.
5
+ export function cmdCat(args) {
6
+ const name = args[0]
7
+ if (!name) { console.error(c.red('Usage: skill cat <name>')); process.exit(1) }
8
+ const s = readSkill(name)
9
+ if (!s) { console.error(c.red('Skill not found: ' + name)); process.exit(1) }
10
+ console.log(c.gray('<!-- skill: ' + s.name + ' -->'))
11
+ console.log(s.body.trim())
12
+ }
@@ -0,0 +1,27 @@
1
+ import c from 'picocolors'
2
+ import { readGlobalConfig, writeGlobalConfig, readProjectConfig, writeProjectConfig } from '../lib/config.js'
3
+
4
+ export function cmdDisable(args) {
5
+ const global = args.includes('-g') || args.includes('--global')
6
+ const name = args.find(a => !a.startsWith('-'))
7
+ if (!name) { console.error(c.red('Usage: skill disable <name> [-g]')); process.exit(1) }
8
+
9
+ if (global) {
10
+ const cfg = readGlobalConfig()
11
+ const had = (cfg.enabled_global || []).some(a => a.toLowerCase() === name.toLowerCase())
12
+ cfg.enabled_global = (cfg.enabled_global || []).filter(a => a.toLowerCase() !== name.toLowerCase())
13
+ writeGlobalConfig(cfg)
14
+ console.log(had
15
+ ? (c.green('✓') + ' disabled globally: ' + c.bold(name))
16
+ : (c.gray('·') + ' not enabled globally: ' + c.bold(name) + c.gray(' (nothing to do)')))
17
+ } else {
18
+ const cwd = process.cwd()
19
+ const cfg = readProjectConfig(cwd) || { inherit: true, deny: [], allow: [] }
20
+ const had = (cfg.allow || []).some(a => a.toLowerCase() === name.toLowerCase())
21
+ cfg.allow = (cfg.allow || []).filter(a => a.toLowerCase() !== name.toLowerCase())
22
+ writeProjectConfig(cwd, cfg)
23
+ console.log(had
24
+ ? (c.green('✓') + ' disabled in project: ' + c.bold(name))
25
+ : (c.gray('·') + ' not enabled in project: ' + c.bold(name) + c.gray(' (nothing to do)')))
26
+ }
27
+ }
@@ -0,0 +1,31 @@
1
+ import c from 'picocolors'
2
+ import { readGlobalConfig, writeGlobalConfig, readProjectConfig, writeProjectConfig } from '../lib/config.js'
3
+ import { listStore } from '../lib/store.js'
4
+
5
+ export function cmdEnable(args) {
6
+ const global = args.includes('-g') || args.includes('--global')
7
+ const name = args.find(a => !a.startsWith('-'))
8
+ if (!name) { console.error(c.red('Usage: skill enable <name> [-g]')); process.exit(1) }
9
+
10
+ // must be installed — enabling a typo silently "succeeds" but never activates
11
+ if (!listStore().some(s => s.name.toLowerCase() === name.toLowerCase())) {
12
+ console.error(c.red('Not installed: ' + name))
13
+ console.error(c.gray(' Install first: skill install <source>'))
14
+ process.exit(1)
15
+ }
16
+
17
+ if (global) {
18
+ const cfg = readGlobalConfig()
19
+ if (!cfg.enabled_global.some(a => a.toLowerCase() === name.toLowerCase())) {
20
+ cfg.enabled_global.push(name.toLowerCase()); cfg.enabled_global.sort()
21
+ }
22
+ writeGlobalConfig(cfg)
23
+ console.log(c.green('✓') + ' enabled globally: ' + c.bold(name) + c.gray(' (active in all projects)'))
24
+ } else {
25
+ const cwd = process.cwd()
26
+ const cfg = readProjectConfig(cwd) || { inherit: true, deny: [], allow: [] }
27
+ if (!cfg.allow.some(a => a.toLowerCase() === name.toLowerCase())) cfg.allow.push(name.toLowerCase())
28
+ writeProjectConfig(cwd, cfg)
29
+ console.log(c.green('✓') + ' enabled in project: ' + c.bold(name) + c.gray(' (' + cwd + ')'))
30
+ }
31
+ }
@@ -0,0 +1,45 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import c from 'picocolors'
4
+ import { CLI_ROOT, STORE_DIR } from '../lib/paths.js'
5
+ import { readGlobalConfig, writeGlobalConfig, projectConfigPath, writeProjectConfig } from '../lib/config.js'
6
+ import { injectToAllAgents } from '../lib/agents-md.js'
7
+
8
+ export function cmdInit(args) {
9
+ const global = args.includes('-g') || args.includes('--global')
10
+ return global ? initGlobal() : initProject()
11
+ }
12
+
13
+ function initGlobal() {
14
+ fs.mkdirSync(STORE_DIR, { recursive: true })
15
+ const cfg = readGlobalConfig()
16
+ writeGlobalConfig(cfg)
17
+
18
+ console.log(c.green('✓') + ' skill-cli installed globally')
19
+ console.log(c.gray(' store: ') + STORE_DIR)
20
+ console.log(c.gray(' config: ') + path.join(CLI_ROOT, 'config.yaml'))
21
+ console.log()
22
+ console.log(c.bold('AGENTS.md injection (idempotent):'))
23
+ for (const r of injectToAllAgents()) {
24
+ if (r.status === 'updated') console.log(c.green(' ✓ ') + r.agent + c.gray(' → ' + r.file))
25
+ else if (r.status === 'current') console.log(c.gray(' · ') + r.agent + c.gray(' (already set up)'))
26
+ else console.log(c.gray(' · ') + r.agent + c.gray(' (not found, skipped)'))
27
+ }
28
+ console.log()
29
+ console.log(c.cyan('Next: ') + 'skill install <source> ' + c.gray('# e.g. vercel-labs/agent-skills'))
30
+ }
31
+
32
+ function initProject() {
33
+ const cwd = process.cwd()
34
+ const cfgPath = projectConfigPath(cwd)
35
+ if (!fs.existsSync(cfgPath)) {
36
+ writeProjectConfig(cwd, { inherit: true, deny: [], allow: [] })
37
+ console.log(c.green('✓') + ' created: ' + c.cyan('skill.config'))
38
+ } else {
39
+ console.log(c.yellow('·') + ' skill.config already exists')
40
+ }
41
+ console.log(c.gray(' inherit: true → inherits globally-enabled skills'))
42
+ console.log(c.gray(' deny: ["*"] + allow: [...] → pure allowlist (block global, open one by one)'))
43
+ console.log()
44
+ console.log(c.cyan('Active skills: ') + 'skill list')
45
+ }
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import c from 'picocolors'
4
+ import { STORE_DIR } from '../lib/paths.js'
5
+ import { parseSkillMd } from '../lib/frontmatter.js'
6
+ import { fetchSkillsToTemp } from '../lib/npx.js'
7
+ import { sanitizeSkillName } from '../lib/store.js'
8
+
9
+ // Local paths are resolved to absolute BEFORE switching to the temp cwd, so
10
+ // npx skills looks them up relative to the user's cwd, not the temp dir.
11
+ function resolveSource(source) {
12
+ if (/:\/\//.test(source) || source.startsWith('git@')) return source
13
+ if (fs.existsSync(source)) return path.resolve(source)
14
+ return source
15
+ }
16
+
17
+ // Fetch skill(s) from ANY source `npx skills` understands — github owner/repo,
18
+ // full GitHub/GitLab URL, git URL, local path, npm package — into a TEMP cwd,
19
+ // then move the files into our store. Agent folders stay untouched.
20
+ export function cmdInstall(args) {
21
+ const source = args[0]
22
+ if (!source) {
23
+ console.error(c.red('Usage: skill install <source>'))
24
+ console.error(c.gray(' source: owner/repo | github/gitlab URL | git URL | local path | npm package'))
25
+ process.exit(1)
26
+ }
27
+
28
+ const resolved = resolveSource(source)
29
+ console.log(c.cyan('Fetching: ') + resolved)
30
+ console.log(c.gray(' via npx skills add (temp cwd; agent folders untouched)'))
31
+
32
+ let tmp, fetchedDir
33
+ try {
34
+ ({ tmp, fetchedDir } = fetchSkillsToTemp(resolved))
35
+ } catch (e) {
36
+ console.error(c.red(e.message))
37
+ console.error(c.gray('Check the source (owner/repo, URL, path, npm package).'))
38
+ process.exit(1)
39
+ }
40
+
41
+ try {
42
+ fs.mkdirSync(STORE_DIR, { recursive: true })
43
+ const moved = []
44
+ const skipped = []
45
+ for (const entry of fs.readdirSync(fetchedDir, { withFileTypes: true })) {
46
+ if (!entry.isDirectory()) continue
47
+ const srcSkillDir = path.join(fetchedDir, entry.name)
48
+ const mdPath = path.join(srcSkillDir, 'SKILL.md')
49
+ let raw = entry.name
50
+ if (fs.existsSync(mdPath)) {
51
+ try {
52
+ const { data } = parseSkillMd(fs.readFileSync(mdPath, 'utf8'))
53
+ if (data.name) raw = data.name
54
+ } catch { /* fall back to dir name */ }
55
+ }
56
+ // S1 (path traversal): the dest name is untrusted — it comes from the
57
+ // fetched SKILL.md frontmatter or source dir name. `name: ../x` could
58
+ // otherwise write (and the preceding rmSync could delete) outside STORE_DIR.
59
+ // Prefer the frontmatter name when safe; else the dir name; else skip.
60
+ const name = sanitizeSkillName(raw) || sanitizeSkillName(entry.name)
61
+ if (!name) { skipped.push(entry.name); continue }
62
+ const dest = path.join(STORE_DIR, name)
63
+ const reinstalled = fs.existsSync(dest)
64
+ fs.rmSync(dest, { recursive: true, force: true })
65
+ // cpSync (not renameSync): rename fails across volumes (EXDEV: C: temp → S: store).
66
+ fs.cpSync(srcSkillDir, dest, { recursive: true })
67
+ // remember the source so `skill update` can re-fetch it later
68
+ fs.writeFileSync(path.join(dest, '.source'), resolved + '\n')
69
+ moved.push({ name, reinstalled })
70
+ }
71
+ for (const s of skipped) console.log(c.yellow(' ⚠ skipped ') + c.bold(s) + c.gray(' (not a safe skill name)'))
72
+
73
+ if (moved.length === 0) {
74
+ // throw (not process.exit) so the finally cleans up the temp dir
75
+ throw new Error('No skills moved to store.')
76
+ }
77
+ const fresh = moved.filter(m => !m.reinstalled).length
78
+ const re = moved.length - fresh
79
+ console.log(c.green(`✓ ${fresh} installed` + (re ? c.gray(` · ${re} reinstalled`) : '') + ' to store:'))
80
+ for (const m of moved) {
81
+ const mark = m.reinstalled ? c.yellow('↻') : c.green('•')
82
+ const tail = m.reinstalled ? c.gray(' (reinstalled)') : c.gray(' → ' + path.join(STORE_DIR, m.name))
83
+ console.log(' ' + mark + ' ' + c.bold(m.name) + tail)
84
+ }
85
+ console.log()
86
+ console.log(c.gray('Skills are passive until enabled. Activate with: ') + c.cyan('skill enable <name>') + c.gray(' or ') + c.cyan('-g'))
87
+ } finally {
88
+ if (tmp) fs.rmSync(tmp, { recursive: true, force: true })
89
+ }
90
+ }
@@ -0,0 +1,38 @@
1
+ import c from 'picocolors'
2
+ import { listStore } from '../lib/store.js'
3
+ import { readGlobalConfig, readProjectConfig, computeEffective } from '../lib/config.js'
4
+ import { trunc } from '../lib/format.js'
5
+
6
+ export function cmdList(_args = []) {
7
+ const installed = listStore()
8
+ const globalCfg = readGlobalConfig()
9
+ const projCfg = readProjectConfig()
10
+ const effective = computeEffective(installed, globalCfg, projCfg)
11
+
12
+ const scope = projCfg ? c.magenta(process.cwd()) : c.gray('(global)')
13
+ console.log(c.bold('skill list') + c.gray(' — project: ') + scope)
14
+ console.log()
15
+
16
+ if (installed.length === 0) {
17
+ console.log(c.yellow(' Store empty.') + c.gray(' First: skill install <source>'))
18
+ return
19
+ }
20
+
21
+ for (const s of installed) {
22
+ const active = effective.includes(s.name)
23
+ const mark = active ? c.green('●') : c.gray('○')
24
+ const sc = labelScope(s.name, globalCfg, projCfg)
25
+ const trg = s.triggers.length ? ' ' + c.gray('/' + s.triggers.join(', /')) : ''
26
+ console.log(` ${mark} ${c.bold(s.name.padEnd(22))} ${c.gray(String(s.version).padEnd(8))} ${sc}${trg}`)
27
+ if (s.description) console.log(c.gray(' ' + trunc(s.description, 68)))
28
+ }
29
+ console.log()
30
+ console.log(c.green('● active ') + c.gray('○ passive (installed but not enabled in this project)'))
31
+ console.log(c.cyan('Details: ') + 'skill show <name> ' + c.cyan('Load: ') + 'skill cat <name>')
32
+ }
33
+
34
+ function labelScope(name, globalCfg, projCfg) {
35
+ if (projCfg && (projCfg.allow || []).some(a => a.toLowerCase() === name.toLowerCase())) return c.magenta('project')
36
+ if ((globalCfg.enabled_global || []).some(a => a.toLowerCase() === name.toLowerCase())) return c.blue('global ')
37
+ return c.gray('- ')
38
+ }
@@ -0,0 +1,18 @@
1
+ import c from 'picocolors'
2
+ import { readSkill } from '../lib/store.js'
3
+ import { getTriggers } from '../lib/frontmatter.js'
4
+
5
+ export function cmdShow(args) {
6
+ const name = args[0]
7
+ if (!name) { console.error(c.red('Usage: skill show <name>')); process.exit(1) }
8
+ const s = readSkill(name)
9
+ if (!s) { console.error(c.red('Skill not found: ' + name)); process.exit(1) }
10
+
11
+ console.log(c.bold(s.name) + c.gray(' v' + (s.data.version || '-')))
12
+ if (s.data.description) console.log(c.gray(s.data.description))
13
+ console.log()
14
+ console.log(c.gray('path: ') + s.path)
15
+ const trg = getTriggers(s.data).map(t => '/' + t).join(', ') || '—'
16
+ console.log(c.gray('triggers: ') + trg)
17
+ console.log(c.gray('content: ') + c.cyan('skill cat ' + s.name))
18
+ }
@@ -0,0 +1,46 @@
1
+ import c from 'picocolors'
2
+ import { listStore, readSkill } from '../lib/store.js'
3
+ import { readGlobalConfig, readProjectConfig, computeEffective } from '../lib/config.js'
4
+ import { normalizeTrigger } from '../lib/frontmatter.js'
5
+ import { trunc } from '../lib/format.js'
6
+
7
+ // /X trigger: keyword match across ACTIVE skills.
8
+ // single → dump skill content (drops into context)
9
+ // multi → candidate list (name + description); agent picks with `skill cat <name>`
10
+ // none → informational
11
+ export function cmdTrigger(args) {
12
+ const keyword = normalizeTrigger(args[0] || '')
13
+ if (!keyword) {
14
+ console.error(c.red('Usage: skill trigger <keyword> (e.g. /research)'))
15
+ process.exit(1)
16
+ }
17
+
18
+ const installed = listStore()
19
+ const effective = computeEffective(installed, readGlobalConfig(), readProjectConfig())
20
+ // Search ONLY active skills — a passive skill cannot trigger.
21
+ const candidates = installed.filter(s => effective.includes(s.name) && s.triggers.includes(keyword))
22
+
23
+ if (candidates.length === 0) {
24
+ console.log(c.yellow('No active skill has the ') + c.cyan('/' + keyword) + c.yellow(' trigger.'))
25
+ console.log(c.gray(' Run skill list to see active skills and their triggers.'))
26
+ return
27
+ }
28
+
29
+ if (candidates.length === 1) {
30
+ const s = readSkill(candidates[0].name)
31
+ if (!s) { console.error(c.red('Skill not readable: ' + candidates[0].name)); process.exit(1) }
32
+ console.log(c.green('▼ skill: ') + c.bold(s.name) + c.gray(' (triggered: /' + keyword + ')'))
33
+ console.log(c.gray('━'.repeat(56)))
34
+ console.log(s.body.trim())
35
+ return
36
+ }
37
+
38
+ console.log(c.yellow('/' + keyword) + ' matched ' + c.bold(candidates.length + ' skills') + ' — pick one:')
39
+ console.log()
40
+ for (const s of candidates) {
41
+ console.log(' ' + c.cyan(s.name.padEnd(20)) + c.gray(' ' + trunc(s.description, 54)))
42
+ }
43
+ console.log()
44
+ console.log(c.gray('Pick: ') + 'skill cat <name>')
45
+ }
46
+
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import c from 'picocolors'
5
+ import { STORE_DIR } from '../lib/paths.js'
6
+ import { parseSkillMd } from '../lib/frontmatter.js'
7
+ import { listStore } from '../lib/store.js'
8
+ import { fetchSkillsToTemp } from '../lib/npx.js'
9
+ import { pad } from '../lib/format.js'
10
+
11
+ // Re-fetch each skill from its recorded source and update the store if changed.
12
+ // skill update update all skills in the store
13
+ // skill update <name>... update specific skills
14
+ // skill update --all same as no args (explicit)
15
+ export function cmdUpdate(args) {
16
+ const installed = listStore()
17
+ const byLower = new Map(installed.map(s => [s.name.toLowerCase(), s.name]))
18
+ const explicit = args.filter(a => !a.startsWith('-'))
19
+
20
+ let targets = []
21
+ let unknown = 0
22
+ if (explicit.length) {
23
+ // B1: case-fold to the canonical installed name (every other command does).
24
+ // A side benefit: an unknown name like `../x` can't reach path.join(STORE_DIR,
25
+ // name), closing the latent traversal surface in `skill update <name>`.
26
+ for (const a of explicit) {
27
+ const canon = byLower.get(a.toLowerCase())
28
+ if (canon) targets.push(canon)
29
+ else { console.log(c.red(' ✗ ' + pad(a)) + c.red('not installed')); unknown++ }
30
+ }
31
+ } else {
32
+ targets = installed.map(s => s.name)
33
+ }
34
+
35
+ if (targets.length === 0 && unknown === 0) {
36
+ console.log(c.yellow('Store empty. Nothing to update.'))
37
+ return
38
+ }
39
+
40
+ console.log(c.bold(`Updating ${targets.length} skill(s)…`) + c.gray(' (re-fetch from source)'))
41
+ console.log()
42
+
43
+ const counts = { updated: 0, current: 0, failed: 0, nosource: 0 }
44
+ counts.failed += unknown
45
+ for (const name of targets) counts[updateOne(name)]++
46
+
47
+ console.log()
48
+ console.log(c.gray(` ${counts.updated} updated · ${counts.current} up to date · ${counts.failed} failed · ${counts.nosource} no source`))
49
+ // signal failure to callers/scripts: a non-zero exit if any fetch failed, so
50
+ // `skill update && deploy` won't silently proceed on a broken source.
51
+ if (counts.failed > 0) process.exit(1)
52
+ }
53
+
54
+ function updateOne(name) {
55
+ const skillPath = path.join(STORE_DIR, name)
56
+ if (!fs.existsSync(skillPath)) {
57
+ console.log(c.red(' ✗ ' + pad(name)) + c.red('not installed'))
58
+ return 'failed'
59
+ }
60
+ const sourceFile = path.join(skillPath, '.source')
61
+ if (!fs.existsSync(sourceFile)) {
62
+ console.log(c.gray(' · ' + pad(name)) + c.gray('source unknown — reinstall: skill install <source>'))
63
+ return 'nosource'
64
+ }
65
+ const source = fs.readFileSync(sourceFile, 'utf8').trim()
66
+
67
+ let tmp, fetchedDir
68
+ try {
69
+ ({ tmp, fetchedDir } = fetchSkillsToTemp(source))
70
+ } catch (e) {
71
+ console.log(c.red(' ✗ ' + pad(name)) + c.red((e.message || '').split('\n')[0]))
72
+ return 'failed'
73
+ }
74
+
75
+ try {
76
+ const newDir = resolveFetched(fetchedDir, name)
77
+ if (!newDir) throw new Error('skill not found in fetched source')
78
+ const newSkillDir = path.join(fetchedDir, newDir.name)
79
+
80
+ const oldVer = getVer(skillPath)
81
+ const newVer = getVer(newSkillDir)
82
+
83
+ if (hashDir(skillPath) === hashDir(newSkillDir)) {
84
+ console.log(c.gray(' ✓ ' + pad(name)) + c.gray('up to date'))
85
+ return 'current'
86
+ }
87
+ // replace content, preserve .source
88
+ fs.rmSync(skillPath, { recursive: true, force: true })
89
+ fs.cpSync(newSkillDir, skillPath, { recursive: true })
90
+ fs.writeFileSync(path.join(skillPath, '.source'), source + '\n')
91
+ const verInfo = oldVer !== newVer ? c.green(`${oldVer} → ${newVer}`) : c.gray('content changed')
92
+ console.log(c.green(' ↑ ') + c.bold(pad(name)) + verInfo)
93
+ return 'updated'
94
+ } catch (e) {
95
+ console.log(c.red(' ✗ ' + pad(name)) + c.red((e.message || '').split('\n')[0]))
96
+ return 'failed'
97
+ } finally {
98
+ fs.rmSync(tmp, { recursive: true, force: true })
99
+ }
100
+ }
101
+
102
+ // find the fetched skill dir by name (dir name or SKILL.md name field), else single
103
+ function resolveFetched(fetchedDir, name) {
104
+ const entries = fs.readdirSync(fetchedDir, { withFileTypes: true }).filter(e => e.isDirectory())
105
+ const byDir = entries.find(e => e.name === name)
106
+ if (byDir) return byDir
107
+ for (const e of entries) {
108
+ const md = path.join(fetchedDir, e.name, 'SKILL.md')
109
+ if (fs.existsSync(md)) {
110
+ try {
111
+ const { data } = parseSkillMd(fs.readFileSync(md, 'utf8'))
112
+ if (data.name === name) return e
113
+ } catch {}
114
+ }
115
+ }
116
+ // Not found by dir name or SKILL.md name field. Do NOT guess — the previous
117
+ // single-entry fallback could silently replace a skill with a different one if
118
+ // the source had changed which skill it ships. Fail loud so the user notices.
119
+ return null
120
+ }
121
+
122
+ // sha256 over all files in dir (excluding our own .source), sorted for determinism
123
+ function hashDir(dir) {
124
+ const h = crypto.createHash('sha256')
125
+ if (!fs.existsSync(dir)) return '0'
126
+ const files = []
127
+ function walk(d) {
128
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
129
+ if (e.name === '.source') continue
130
+ const p = path.join(d, e.name)
131
+ if (e.isDirectory()) walk(p)
132
+ else files.push(p)
133
+ }
134
+ }
135
+ walk(dir)
136
+ files.sort()
137
+ for (const f of files) {
138
+ h.update(f.slice(dir.length))
139
+ try { h.update(fs.readFileSync(f)) } catch {}
140
+ }
141
+ return h.digest('hex')
142
+ }
143
+
144
+ function getVer(dir) {
145
+ const md = path.join(dir, 'SKILL.md')
146
+ if (!fs.existsSync(md)) return '?'
147
+ try { const { data } = parseSkillMd(fs.readFileSync(md, 'utf8')); return data.version || '?' } catch { return '?' }
148
+ }
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { HOME, AGENT_GLOBALS } from './paths.js'
4
+
5
+ const BEGIN = '<!-- BEGIN skill-cli -->'
6
+ const END = '<!-- END skill-cli -->'
7
+
8
+ // Global instruction block — injected into each agent's global instruction file.
9
+ // Kept short (~140 words) so it doesn't bloat agent context on every session.
10
+ export const AGENTS_BLOCK = `## skill-cli
11
+
12
+ This machine uses the \`skill\` command to manage skills (instruction / workflow
13
+ packages). Skills live in a single global store (\`~/.skill-cli/store\`) and are
14
+ NOT copied into agent directories (\`~/.claude\`, \`~/.codex\`, etc.) — so they
15
+ won't appear here.
16
+
17
+ Usage:
18
+ - \`skill list\` — skills installed and active in the current project
19
+ - \`skill show <name>\` — skill summary (path, triggers, version)
20
+ - \`skill cat <name>\` — load skill content into context
21
+
22
+ Triggers: when the user types \`/X\`, run \`skill trigger X\`.
23
+ - Single match → apply the output directly.
24
+ - Multiple matches → show the candidate list; load the right one with \`skill cat <name>\`.
25
+ - Load each skill only ONCE per session (avoid re-injecting).
26
+ `
27
+
28
+ export function injectBlock(content) {
29
+ const wrapped = `${BEGIN}\n${AGENTS_BLOCK}\n${END}`
30
+ if (content.includes(BEGIN)) {
31
+ return content.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}`), wrapped)
32
+ }
33
+ return (content ? content.replace(/\n*$/, '') + '\n\n' : '') + wrapped + '\n'
34
+ }
35
+
36
+ export function injectToAgentGlobal(agent) {
37
+ const dir = path.join(HOME, agent.dir)
38
+ if (!fs.existsSync(dir)) return null
39
+ const file = path.join(dir, agent.file)
40
+ let content = ''
41
+ try { content = fs.readFileSync(file, 'utf8') } catch {}
42
+ const next = injectBlock(content)
43
+ // B4: only rewrite + claim "updated" when the block actually changed. On an
44
+ // idempotent re-run (block already present & identical) report "current" and
45
+ // leave the file untouched (preserves mtime, avoids needless rewrites).
46
+ const status = content.includes(BEGIN) ? (next === content ? 'current' : 'updated') : 'updated'
47
+ if (next !== content) fs.writeFileSync(file, next, 'utf8')
48
+ return { file, status }
49
+ }
50
+
51
+ export function injectToAllAgents() {
52
+ return AGENT_GLOBALS.map(agent => {
53
+ const r = injectToAgentGlobal(agent)
54
+ return r
55
+ ? { agent: agent.id, file: r.file, status: r.status }
56
+ : { agent: agent.id, file: null, status: 'agent-not-found' }
57
+ })
58
+ }
@@ -0,0 +1,100 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import yaml from 'yaml'
4
+ import { CLI_ROOT, GLOBAL_CONFIG, PROJECT_CONFIG, STORE_DIR } from './paths.js'
5
+
6
+ const DEFAULT_GLOBAL = {
7
+ version: 1,
8
+ store: STORE_DIR,
9
+ enabled_global: [],
10
+ }
11
+
12
+ export function readGlobalConfig() {
13
+ let raw
14
+ try { raw = fs.readFileSync(GLOBAL_CONFIG, 'utf8') } catch { return { ...DEFAULT_GLOBAL } }
15
+ let parsed
16
+ try { parsed = yaml.parse(raw) } catch (e) {
17
+ process.stderr.write('skill-cli config: parse error (' + (e.message || e) + ') — using defaults\n')
18
+ return { ...DEFAULT_GLOBAL }
19
+ }
20
+ return { ...DEFAULT_GLOBAL, ...(parsed || {}) }
21
+ }
22
+
23
+ export function writeGlobalConfig(cfg) {
24
+ fs.mkdirSync(CLI_ROOT, { recursive: true })
25
+ // B2: write only the known schema, not arbitrary pass-through keys. readGlobalConfig
26
+ // merges parsed-over-defaults, which would otherwise round-trip dead keys (e.g.
27
+ // the removed `default_agents`) back into the file forever.
28
+ const out = {
29
+ version: cfg.version ?? 1,
30
+ store: cfg.store ?? STORE_DIR,
31
+ enabled_global: cfg.enabled_global || [],
32
+ }
33
+ fs.writeFileSync(GLOBAL_CONFIG, yaml.stringify(out), 'utf8')
34
+ }
35
+
36
+ export function projectConfigPath(cwd = process.cwd()) {
37
+ return path.join(cwd, PROJECT_CONFIG)
38
+ }
39
+
40
+ // Returns null when no project config exists. A malformed YAML is reported on
41
+ // stderr (rather than silently falling back to global behavior).
42
+ export function readProjectConfig(cwd = process.cwd()) {
43
+ let raw
44
+ try { raw = fs.readFileSync(projectConfigPath(cwd), 'utf8') } catch { return null }
45
+ let parsed
46
+ try { parsed = yaml.parse(raw) } catch (e) {
47
+ process.stderr.write('skill.config: parse error (' + (e.message || e) + ') — using global behavior\n')
48
+ return null
49
+ }
50
+ return { inherit: true, deny: [], allow: [], ...(parsed || {}) }
51
+ }
52
+
53
+ export function writeProjectConfig(cwd, cfg) {
54
+ // B2: normalize to the known schema on write (drops stale/junk keys).
55
+ const out = {
56
+ inherit: cfg.inherit !== false,
57
+ deny: cfg.deny || [],
58
+ allow: cfg.allow || [],
59
+ }
60
+ fs.writeFileSync(projectConfigPath(cwd), yaml.stringify(out), 'utf8')
61
+ }
62
+
63
+ // Simple glob: * → any chars, ? → single char. Pattern length is capped to guard
64
+ // against ReDoS on user-supplied deny patterns.
65
+ function globMatch(pattern, name) {
66
+ if (pattern === '*') return true
67
+ if (pattern.length > 200) return false
68
+ const re = new RegExp(
69
+ '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$',
70
+ 'i'
71
+ )
72
+ return re.test(name)
73
+ }
74
+
75
+ // Effective skills, returned as CANONICAL names (from the installed store). allow
76
+ // always wins over deny, so `deny: ["*"]` + `allow: [X]` = "only X". Matching is
77
+ // case-insensitive throughout — the user may type "React-BP" while the skill is "react-bp".
78
+ export function computeEffective(installed, globalCfg, projCfg) {
79
+ const canonByLower = new Map(installed.map(s => [String(s.name).toLowerCase(), s.name]))
80
+ const enabled = new Set(
81
+ (projCfg && projCfg.inherit === false ? [] : (globalCfg.enabled_global || []))
82
+ .map(s => String(s).toLowerCase())
83
+ )
84
+ if (projCfg) {
85
+ const allowLower = new Set((projCfg.allow || []).map(a => String(a).toLowerCase()))
86
+ for (const d of (projCfg.deny || [])) {
87
+ for (const name of [...enabled]) {
88
+ if (allowLower.has(name)) continue
89
+ if (d.includes('*') ? globMatch(d, name) : String(d).toLowerCase() === name) {
90
+ enabled.delete(name)
91
+ }
92
+ }
93
+ }
94
+ for (const a of (projCfg.allow || [])) enabled.add(String(a).toLowerCase())
95
+ }
96
+ return [...enabled]
97
+ .filter(n => canonByLower.has(n))
98
+ .map(n => canonByLower.get(n))
99
+ .sort()
100
+ }
@@ -0,0 +1,12 @@
1
+ // Shared output helpers.
2
+
3
+ // Truncate to n chars, collapsing any newlines to spaces (keeps single-line output).
4
+ export function trunc(s, n) {
5
+ s = String(s ?? '').replace(/[\r\n]+/g, ' ').trim()
6
+ return s.length > n ? s.slice(0, n - 1) + '…' : s
7
+ }
8
+
9
+ // Left-pad a string to a fixed column width.
10
+ export function pad(s, n = 22) {
11
+ return (String(s) + ' '.repeat(n)).slice(0, n)
12
+ }
@@ -0,0 +1,27 @@
1
+ import yaml from 'yaml'
2
+
3
+ // SKILL.md = YAML frontmatter + markdown body. Faithful to the npx skills standard.
4
+ export function parseSkillMd(content) {
5
+ // strip a leading UTF-8 BOM — Windows editors often save with one, and it would
6
+ // break the `^---` frontmatter match (silently dropping name/triggers/version).
7
+ if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
8
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
9
+ if (!m) return { data: {}, body: content }
10
+ let data = {}
11
+ try { data = yaml.parse(m[1]) || {} } catch { /* malformed frontmatter → empty */ }
12
+ return { data, body: m[2] || '' }
13
+ }
14
+
15
+ // "/Research", "Research", "research" → "research". Trim BEFORE stripping the
16
+ // leading slash, so comma-split entries like " /code" also normalize.
17
+ export function normalizeTrigger(t) {
18
+ return String(t).trim().replace(/^\/+/, '').toLowerCase()
19
+ }
20
+
21
+ // frontmatter.triggers → normalized array
22
+ export function getTriggers(data) {
23
+ const t = data.triggers
24
+ if (Array.isArray(t)) return t.map(normalizeTrigger).filter(Boolean)
25
+ if (typeof t === 'string') return t.split(',').map(normalizeTrigger).filter(Boolean)
26
+ return []
27
+ }
package/src/lib/npx.js ADDED
@@ -0,0 +1,104 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+
6
+ // Fetch skill(s) from `source` into a TEMP cwd via `npx skills add --copy`.
7
+ // Output lands in <tmp>/.claude/skills/ — never in real agent dirs. The caller
8
+ // owns the returned `tmp` and MUST rmSync(tmp, {recursive,force}) in a finally.
9
+ //
10
+ // Assemble the spawn target for `npx skills add` so it runs safely on every
11
+ // platform. Pure + exported so the per-platform branching is unit-testable.
12
+ //
13
+ // Windows (process.platform === 'win32', incl. Git Bash / Cygwin / MSYS — Node
14
+ // still reports 'win32'): Node 20+ (CVE-2024-27980) refuses to spawn .cmd/.bat
15
+ // files such as npx.cmd with shell:false. We spawn cmd.exe (a real .exe) and let
16
+ // it resolve npx via `cmd.exe /c npx ...`.
17
+ //
18
+ // Linux / macOS / WSL: npx is a real executable on PATH (ships with npm/node),
19
+ // spawned directly.
20
+ //
21
+ // On ALL platforms we use shell:false with an args array, so the source is passed
22
+ // as a single argv element — no shell injection — and `--skill *` stays literal
23
+ // (cmd.exe does no globbing and no POSIX shell is ever involved). npx must be on
24
+ // PATH; it ships with node/npm on every platform.
25
+ export function buildNpxSpawn(source, platform = process.platform) {
26
+ const base = ['-y', 'skills', 'add', source, '--copy', '--agent', 'claude-code', '--skill', '*', '-y']
27
+ return platform === 'win32'
28
+ ? { cmd: 'cmd.exe', args: ['/c', 'npx', ...base] }
29
+ : { cmd: 'npx', args: base }
30
+ }
31
+
32
+ // Fetch skill(s) from `source` into a TEMP cwd via `npx skills add --copy`.
33
+ // Output lands in <tmp>/.claude/skills/ — never in real agent dirs. The caller
34
+ // owns the returned `tmp` and MUST rmSync(tmp, {recursive,force}) in a finally.
35
+ export function fetchSkillsToTemp(source) {
36
+ // Test seam: when SKILL_CLI_FETCH_FIXTURE is set, copy a local fixture tree
37
+ // instead of spawning npx. Lets install/update run with zero network.
38
+ // Production never sets this env var.
39
+ const fixture = process.env.SKILL_CLI_FETCH_FIXTURE
40
+ if (fixture) return fetchFromFixture(fixture)
41
+
42
+ const safe = String(source ?? '').trim()
43
+ if (!safe) throw new Error('empty source')
44
+ if (/[\r\n]/.test(safe)) throw new Error('source must be a single line')
45
+ // B5: defense-in-depth on Windows. The source reaches `cmd.exe /c npx … <source>`;
46
+ // cmd.exe parses shell metacharacters even though we spawn with shell:false
47
+ // (cmd.exe IS a shell). Real sources (owner/repo, URL, git URL, npm name, local
48
+ // path) never contain these, so reject loudly rather than risk cmd interpreting.
49
+ if (process.platform === 'win32' && /[&|<>^]/.test(safe)) {
50
+ throw new Error('source contains Windows shell metacharacters (& | < > ^) — use a path/URL without them')
51
+ }
52
+
53
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-cli-'))
54
+ const { cmd, args } = buildNpxSpawn(safe)
55
+
56
+ try {
57
+ execFileSync(cmd, args, {
58
+ cwd: tmp,
59
+ stdio: ['ignore', 'pipe', 'pipe'],
60
+ encoding: 'utf8',
61
+ shell: false,
62
+ timeout: 180000, // npx first-run + git clone can be slow; don't hang forever
63
+ env: { ...process.env, CI: '1' },
64
+ })
65
+ } catch (e) {
66
+ fs.rmSync(tmp, { recursive: true, force: true })
67
+ let msg
68
+ if (e.code === 'ENOENT') {
69
+ // npx/node not on PATH — same message on every platform
70
+ msg = 'npx not found on PATH — is Node.js (with npm) installed?'
71
+ } else {
72
+ const timedOut = e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT'
73
+ const tail = timedOut ? '' : ((e.stderr || e.stdout) || '').toString().trim().split('\n').pop()
74
+ msg = timedOut
75
+ ? 'fetch timed out after 3 min (network too slow or source too large?)'
76
+ : 'fetch failed: ' + (tail || e.message)
77
+ }
78
+ const err = new Error(msg)
79
+ err.cause = e
80
+ throw err
81
+ }
82
+
83
+ const fetchedDir = path.join(tmp, '.claude', 'skills')
84
+ if (!fs.existsSync(fetchedDir)) {
85
+ fs.rmSync(tmp, { recursive: true, force: true })
86
+ throw new Error('no skills found in source after fetch')
87
+ }
88
+ return { tmp, fetchedDir }
89
+ }
90
+
91
+ // Fixture-backed fetch. The fixture is a dir of skill dirs — the same layout npx
92
+ // produces under .claude/skills/. Mirror it into a temp so install/update behave
93
+ // identically to the real path, with no network.
94
+ function fetchFromFixture(fixture) {
95
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-cli-'))
96
+ const fetchedDir = path.join(tmp, '.claude', 'skills')
97
+ fs.mkdirSync(fetchedDir, { recursive: true })
98
+ if (fs.existsSync(fixture)) {
99
+ for (const entry of fs.readdirSync(fixture, { withFileTypes: true })) {
100
+ if (entry.isDirectory()) fs.cpSync(path.join(fixture, entry.name), path.join(fetchedDir, entry.name), { recursive: true })
101
+ }
102
+ }
103
+ return { tmp, fetchedDir }
104
+ }
@@ -0,0 +1,20 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ // SKILL_CLI_HOME overrides ALL home references — safe for testing. In production
5
+ // this is the real os.homedir(); ~/.skill-cli, ~/.claude, etc. all derive from it.
6
+ export const HOME = process.env.SKILL_CLI_HOME || os.homedir()
7
+
8
+ export const CLI_ROOT = path.join(HOME, '.skill-cli')
9
+ export const STORE_DIR = path.join(CLI_ROOT, 'store')
10
+ export const GLOBAL_CONFIG = path.join(CLI_ROOT, 'config.yaml')
11
+
12
+ export const PROJECT_CONFIG = 'skill.config'
13
+
14
+ // Each agent's global instruction file. `skill init -g` injects an idempotent
15
+ // block into these. (Cursor uses .cursor/rules with a different format — adapter later.)
16
+ export const AGENT_GLOBALS = [
17
+ { id: 'claude', dir: '.claude', file: 'CLAUDE.md' },
18
+ { id: 'codex', dir: '.codex', file: 'AGENTS.md' },
19
+ { id: 'gemini', dir: '.gemini', file: 'GEMINI.md' },
20
+ ]
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { STORE_DIR } from './paths.js'
4
+ import { parseSkillMd, getTriggers } from './frontmatter.js'
5
+
6
+ export function skillDir(name) { return path.join(STORE_DIR, name) }
7
+ export function skillMdPath(name) { return path.join(skillDir(name), 'SKILL.md') }
8
+
9
+ // Scan the store for all skills (name, description, version, triggers, path)
10
+ export function listStore() {
11
+ if (!fs.existsSync(STORE_DIR)) return []
12
+ const out = []
13
+ for (const entry of fs.readdirSync(STORE_DIR, { withFileTypes: true })) {
14
+ if (!entry.isDirectory()) continue
15
+ const md = skillMdPath(entry.name)
16
+ if (!fs.existsSync(md)) continue
17
+ try {
18
+ const { data } = parseSkillMd(fs.readFileSync(md, 'utf8'))
19
+ out.push({
20
+ name: data.name || entry.name,
21
+ dir: entry.name,
22
+ description: data.description || '',
23
+ version: data.version || '-',
24
+ triggers: getTriggers(data),
25
+ path: md,
26
+ })
27
+ } catch { /* broken skill → skip */ }
28
+ }
29
+ return out.sort((a, b) => a.name.localeCompare(b.name))
30
+ }
31
+
32
+ // A skill name is a plain identifier (alnum/._-, starting alnum). Rejecting path
33
+ // separators and ".." closes a path-traversal surface: `skill cat ../../x` can't
34
+ // escape STORE_DIR via the dir-name path. Exported so install/update can reuse it
35
+ // on the WRITE path — the dangerous one: a malicious frontmatter `name: ../x`
36
+ // could otherwise rmSync/cpSync outside STORE_DIR.
37
+ export const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
38
+
39
+ // Returns a safe skill name (alnum/._- only, no "..", resolving strictly inside
40
+ // STORE_DIR) or null if `name` could escape the store. Call before joining any
41
+ // untrusted name (frontmatter `name`, fetched dir name) onto STORE_DIR.
42
+ export function sanitizeSkillName(name) {
43
+ const n = String(name ?? '').trim()
44
+ if (!SAFE_NAME.test(n) || n.includes('..')) return null
45
+ const root = path.resolve(STORE_DIR)
46
+ const dest = path.resolve(STORE_DIR, n)
47
+ if (dest !== root && !dest.startsWith(root + path.sep)) return null
48
+ return n
49
+ }
50
+
51
+ export function readSkill(nameOrDir) {
52
+ const n = String(nameOrDir)
53
+ let md = SAFE_NAME.test(n) && !n.includes('..') && fs.existsSync(skillMdPath(n)) ? skillMdPath(n) : null
54
+ if (!md) {
55
+ const hit = listStore().find(s => s.name.toLowerCase() === n.toLowerCase())
56
+ if (!hit) return null
57
+ md = hit.path
58
+ }
59
+ const { data, body } = parseSkillMd(fs.readFileSync(md, 'utf8'))
60
+ return { name: data.name || n, data, body, path: md }
61
+ }
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import path from 'node:path'
4
+
5
+ // Single source of truth for the version — read from package.json so it never drifts.
6
+ const here = path.dirname(fileURLToPath(import.meta.url))
7
+ const pkg = JSON.parse(readFileSync(path.join(here, '..', '..', 'package.json'), 'utf8'))
8
+ export const VERSION = pkg.version