@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 +50 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/package.json +57 -0
- package/src/cli.js +71 -0
- package/src/commands/cat.js +12 -0
- package/src/commands/disable.js +27 -0
- package/src/commands/enable.js +31 -0
- package/src/commands/init.js +45 -0
- package/src/commands/install.js +90 -0
- package/src/commands/list.js +38 -0
- package/src/commands/show.js +18 -0
- package/src/commands/trigger.js +46 -0
- package/src/commands/update.js +148 -0
- package/src/lib/agents-md.js +58 -0
- package/src/lib/config.js +100 -0
- package/src/lib/format.js +12 -0
- package/src/lib/frontmatter.js +27 -0
- package/src/lib/npx.js +104 -0
- package/src/lib/paths.js +20 -0
- package/src/lib/store.js +61 -0
- package/src/lib/version.js +8 -0
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
|
+
[](https://github.com/victortomaili/skill-cli/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/@victortomaili/skill-cli)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](#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
|
+
}
|
package/src/lib/paths.js
ADDED
|
@@ -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
|
+
]
|
package/src/lib/store.js
ADDED
|
@@ -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
|