quiver-skill-manager 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/README.md +87 -0
- package/bin/quiver.js +2 -0
- package/package.json +45 -0
- package/src/cli.js +307 -0
- package/src/core/add.js +33 -0
- package/src/core/config.js +48 -0
- package/src/core/export.js +48 -0
- package/src/core/import.js +57 -0
- package/src/core/inventory.js +234 -0
- package/src/core/paths.js +17 -0
- package/src/core/registry.js +488 -0
- package/src/core/remove.js +24 -0
- package/src/core/sync/git.js +212 -0
- package/src/core/sync/index.js +1 -0
- package/src/core/sync/snapshot.js +148 -0
- package/src/routes.js +270 -0
- package/src/server.js +58 -0
- package/ui/app.js +922 -0
- package/ui/index.html +14 -0
- package/ui/styles.css +870 -0
- package/ui/vendor/htm.mjs +4 -0
- package/ui/vendor/preact-hooks.mjs +3 -0
- package/ui/vendor/preact.mjs +3 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Quiver
|
|
2
|
+
|
|
3
|
+
A local web UI and CLI for browsing, installing, and managing Claude Code skills.
|
|
4
|
+
|
|
5
|
+
Quiver scans your local skills (`~/.claude/skills/`) and marketplace plugins, showing everything in one searchable interface with source-based colour coding.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Unified inventory** — local skills and marketplace plugins in one view
|
|
10
|
+
- **Web UI** — tabs, search, drag-and-drop import, skill detail with file paths
|
|
11
|
+
- **CLI** — list, add, remove, import, export skills from the terminal
|
|
12
|
+
- **macOS app** — standalone Quiver.app bundle (~1.8MB)
|
|
13
|
+
- **Launch on startup** — optional auto-start so your bookmark always works
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Install dependencies
|
|
19
|
+
npm install
|
|
20
|
+
|
|
21
|
+
# Launch the web UI
|
|
22
|
+
node bin/quiver.js ui
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Opens http://localhost:3456 in your browser.
|
|
26
|
+
|
|
27
|
+
## CLI Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# List all skills
|
|
31
|
+
node bin/quiver.js list
|
|
32
|
+
|
|
33
|
+
# Add a skill (symlink)
|
|
34
|
+
node bin/quiver.js add /path/to/my-skill
|
|
35
|
+
|
|
36
|
+
# Add a skill (copy)
|
|
37
|
+
node bin/quiver.js add /path/to/my-skill --copy
|
|
38
|
+
|
|
39
|
+
# Remove a skill
|
|
40
|
+
node bin/quiver.js remove my-skill
|
|
41
|
+
|
|
42
|
+
# Export a skill as .zip
|
|
43
|
+
node bin/quiver.js export my-skill -o ./exports
|
|
44
|
+
|
|
45
|
+
# Import a skill from .zip
|
|
46
|
+
node bin/quiver.js import ./my-skill.skill.zip
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Global Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm install -g .
|
|
53
|
+
quiver ui
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Build macOS App
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bash build/build-macos.sh
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Creates `dist/Quiver.app` and `dist/Quiver.zip`.
|
|
63
|
+
|
|
64
|
+
Requires Node.js on the machine — the app uses a shell launcher to find your Node installation.
|
|
65
|
+
|
|
66
|
+
## How It Works
|
|
67
|
+
|
|
68
|
+
Quiver reads skills from two locations:
|
|
69
|
+
|
|
70
|
+
| Source | Path | Badge Colour |
|
|
71
|
+
|--------|------|-------------|
|
|
72
|
+
| Local | `~/.claude/skills/` | Indigo |
|
|
73
|
+
| Marketplace | `~/.claude/plugins/marketplaces/*/plugins/*/` | Teal |
|
|
74
|
+
|
|
75
|
+
Skills are `.md` files with optional YAML frontmatter for metadata (name, description, tags).
|
|
76
|
+
|
|
77
|
+
## Tech Stack
|
|
78
|
+
|
|
79
|
+
- Node.js + Express
|
|
80
|
+
- Preact + HTM (CDN, no build step)
|
|
81
|
+
- Commander.js (CLI)
|
|
82
|
+
- esbuild (app bundling)
|
|
83
|
+
- gray-matter (frontmatter parsing)
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
package/bin/quiver.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quiver-skill-manager",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Manage Claude Code skills with a web UI and CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"quiver": "./bin/quiver.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"ui/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/scribblesvurt-crypto/quiver.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://skillquiver.com",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node src/cli.js",
|
|
22
|
+
"ui": "node src/cli.js ui"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"claude",
|
|
26
|
+
"claude-code",
|
|
27
|
+
"skills",
|
|
28
|
+
"skill-manager",
|
|
29
|
+
"quiver",
|
|
30
|
+
"cli"
|
|
31
|
+
],
|
|
32
|
+
"author": "Sam",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"adm-zip": "^0.5.16",
|
|
36
|
+
"commander": "^13.1.0",
|
|
37
|
+
"express": "^5.1.0",
|
|
38
|
+
"gray-matter": "^4.0.3",
|
|
39
|
+
"multer": "^1.4.5-lts.2",
|
|
40
|
+
"open": "^10.1.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"esbuild": "^0.28.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { program } from 'commander';
|
|
2
|
+
import { listAll, getSkillContent } from './core/inventory.js';
|
|
3
|
+
import { addSkill } from './core/add.js';
|
|
4
|
+
import { removeSkill } from './core/remove.js';
|
|
5
|
+
import { exportSkill, exportAll } from './core/export.js';
|
|
6
|
+
import { importSkill } from './core/import.js';
|
|
7
|
+
import { loadConfig, getConfigValue, setConfigValue } from './core/config.js';
|
|
8
|
+
import { syncInit, syncSetRemote, syncPush, syncPull, syncStatus } from './core/sync/index.js';
|
|
9
|
+
import { listAvailablePlugins, listCategories, listSources, setSourceEnabled } from './core/registry.js';
|
|
10
|
+
import { startServer } from './server.js';
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('quiver')
|
|
14
|
+
.description('Quiver — manage Claude Code skills')
|
|
15
|
+
.version('0.1.0');
|
|
16
|
+
|
|
17
|
+
// --- list ---
|
|
18
|
+
program
|
|
19
|
+
.command('list')
|
|
20
|
+
.alias('ls')
|
|
21
|
+
.description('List all installed skills')
|
|
22
|
+
.option('--json', 'Output as JSON')
|
|
23
|
+
.action((opts) => {
|
|
24
|
+
const skills = listAll();
|
|
25
|
+
if (skills.length === 0) {
|
|
26
|
+
console.log('No skills found.');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (opts.json) {
|
|
30
|
+
console.log(JSON.stringify(skills, null, 2));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const maxName = Math.max(...skills.map(s => s.name.length), 4);
|
|
34
|
+
const maxSource = Math.max(...skills.map(s => (s.pluginName || s.source).length), 6);
|
|
35
|
+
console.log(`${'NAME'.padEnd(maxName)} ${'SOURCE'.padEnd(maxSource)} ${'FILES'} DESCRIPTION`);
|
|
36
|
+
console.log(`${'─'.repeat(maxName)} ${'─'.repeat(maxSource)} ${'─────'} ${'─'.repeat(40)}`);
|
|
37
|
+
for (const s of skills) {
|
|
38
|
+
const source = s.pluginName || s.source;
|
|
39
|
+
const desc = s.description.length > 50 ? s.description.slice(0, 47) + '...' : s.description;
|
|
40
|
+
console.log(`${s.name.padEnd(maxName)} ${source.padEnd(maxSource)} ${String(s.fileCount).padStart(5)} ${desc}`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// --- add ---
|
|
45
|
+
program
|
|
46
|
+
.command('add <path>')
|
|
47
|
+
.description('Add a skill to ~/.claude/skills/ (symlink by default)')
|
|
48
|
+
.option('--copy', 'Copy instead of symlink')
|
|
49
|
+
.action((path, opts) => {
|
|
50
|
+
try {
|
|
51
|
+
const name = addSkill(path, { copy: opts.copy });
|
|
52
|
+
console.log(`Added skill: ${name}`);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error(`Error: ${e.message}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// --- remove ---
|
|
60
|
+
program
|
|
61
|
+
.command('remove <name>')
|
|
62
|
+
.alias('rm')
|
|
63
|
+
.description('Remove a skill from ~/.claude/skills/')
|
|
64
|
+
.action((name) => {
|
|
65
|
+
try {
|
|
66
|
+
removeSkill(name);
|
|
67
|
+
console.log(`Removed skill: ${name}`);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.error(`Error: ${e.message}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// --- export ---
|
|
75
|
+
program
|
|
76
|
+
.command('export <name>')
|
|
77
|
+
.description('Export a skill as a .skill.zip')
|
|
78
|
+
.option('--all', 'Export all skills')
|
|
79
|
+
.option('-o, --output <dir>', 'Output directory', '.')
|
|
80
|
+
.action((name, opts) => {
|
|
81
|
+
try {
|
|
82
|
+
if (opts.all) {
|
|
83
|
+
const files = exportAll(opts.output);
|
|
84
|
+
console.log(`Exported ${files.length} skills to ${opts.output}`);
|
|
85
|
+
} else {
|
|
86
|
+
const outPath = exportSkill(name, opts.output);
|
|
87
|
+
console.log(`Exported: ${outPath}`);
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.error(`Error: ${e.message}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// --- import ---
|
|
96
|
+
program
|
|
97
|
+
.command('import <zip>')
|
|
98
|
+
.description('Import a skill from a .zip file')
|
|
99
|
+
.action((zip) => {
|
|
100
|
+
try {
|
|
101
|
+
const name = importSkill(zip);
|
|
102
|
+
console.log(`Imported skill: ${name}`);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error(`Error: ${e.message}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// --- ui ---
|
|
110
|
+
program
|
|
111
|
+
.command('ui')
|
|
112
|
+
.description('Launch the web UI')
|
|
113
|
+
.option('-p, --port <port>', 'Port number', '3456')
|
|
114
|
+
.action((opts) => {
|
|
115
|
+
startServer(parseInt(opts.port));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// --- config ---
|
|
119
|
+
program
|
|
120
|
+
.command('config [key] [value]')
|
|
121
|
+
.description('Get or set config values')
|
|
122
|
+
.action((key, value) => {
|
|
123
|
+
if (!key) {
|
|
124
|
+
console.log(JSON.stringify(loadConfig(), null, 2));
|
|
125
|
+
} else if (value === undefined) {
|
|
126
|
+
console.log(getConfigValue(key) ?? '(not set)');
|
|
127
|
+
} else {
|
|
128
|
+
setConfigValue(key, value);
|
|
129
|
+
console.log(`Set ${key} = ${value}`);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// --- browse ---
|
|
134
|
+
program
|
|
135
|
+
.command('browse')
|
|
136
|
+
.description('Browse available marketplace plugins')
|
|
137
|
+
.option('--category <cat>', 'Filter by category')
|
|
138
|
+
.option('--search <term>', 'Search plugins')
|
|
139
|
+
.option('--json', 'Output as JSON')
|
|
140
|
+
.option('--categories', 'List categories only')
|
|
141
|
+
.option('--sources', 'List marketplace sources and their status')
|
|
142
|
+
.option('--enable <id>', 'Enable a marketplace source')
|
|
143
|
+
.option('--disable <id>', 'Disable a marketplace source')
|
|
144
|
+
.action(async (opts) => {
|
|
145
|
+
if (opts.sources) {
|
|
146
|
+
const sources = listSources();
|
|
147
|
+
console.log('Marketplace sources:\n');
|
|
148
|
+
for (const s of sources) {
|
|
149
|
+
const status = s.enabled ? '[ON] ' : '[OFF]';
|
|
150
|
+
console.log(` ${status} ${s.id}`);
|
|
151
|
+
console.log(` ${s.description}`);
|
|
152
|
+
}
|
|
153
|
+
console.log('\nUse --enable <id> or --disable <id> to toggle.');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (opts.enable) {
|
|
158
|
+
setSourceEnabled(opts.enable, true);
|
|
159
|
+
console.log(`Enabled: ${opts.enable}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (opts.disable) {
|
|
164
|
+
setSourceEnabled(opts.disable, false);
|
|
165
|
+
console.log(`Disabled: ${opts.disable}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (opts.categories) {
|
|
170
|
+
const cats = await listCategories();
|
|
171
|
+
if (cats.length === 0) {
|
|
172
|
+
console.log('No marketplaces found. Add one with: claude /plugin marketplace add anthropics/claude-plugins-official');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const c of cats) {
|
|
176
|
+
console.log(` ${c.name} (${c.count})`);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const plugins = await listAvailablePlugins({ search: opts.search, category: opts.category });
|
|
182
|
+
if (plugins.length === 0) {
|
|
183
|
+
console.log('No plugins found.');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (opts.json) {
|
|
187
|
+
console.log(JSON.stringify(plugins, null, 2));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const maxName = Math.max(...plugins.map(p => p.name.length), 4);
|
|
192
|
+
const maxCat = Math.max(...plugins.map(p => (p.category || '—').length), 8);
|
|
193
|
+
console.log(`${'NAME'.padEnd(maxName)} ${'CATEGORY'.padEnd(maxCat)} ${'STATUS'} DESCRIPTION`);
|
|
194
|
+
console.log(`${'─'.repeat(maxName)} ${'─'.repeat(maxCat)} ${'─'.repeat(9)} ${'─'.repeat(40)}`);
|
|
195
|
+
for (const p of plugins) {
|
|
196
|
+
const cat = (p.category || '—').padEnd(maxCat);
|
|
197
|
+
const status = p.installed ? 'installed' : 'available';
|
|
198
|
+
const desc = p.description.length > 50 ? p.description.slice(0, 47) + '...' : p.description;
|
|
199
|
+
console.log(`${p.name.padEnd(maxName)} ${cat} ${status.padEnd(9)} ${desc}`);
|
|
200
|
+
}
|
|
201
|
+
console.log(`\n${plugins.length} plugins (${plugins.filter(p => p.installed).length} installed)`);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// --- sync ---
|
|
205
|
+
const sync = program.command('sync').description('Sync skills across machines');
|
|
206
|
+
|
|
207
|
+
sync
|
|
208
|
+
.command('init')
|
|
209
|
+
.description('Initialize sync (creates git repo in ~/.quiver/sync/)')
|
|
210
|
+
.action(() => {
|
|
211
|
+
try {
|
|
212
|
+
const result = syncInit();
|
|
213
|
+
console.log(result.message);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
console.error(`Error: ${e.message}`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
sync
|
|
221
|
+
.command('remote <url>')
|
|
222
|
+
.description('Set the git remote URL')
|
|
223
|
+
.action((url) => {
|
|
224
|
+
try {
|
|
225
|
+
const result = syncSetRemote(url);
|
|
226
|
+
if (!result.ok) { console.error(`Error: ${result.error}`); process.exit(1); }
|
|
227
|
+
console.log(result.message);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.error(`Error: ${e.message}`);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
sync
|
|
235
|
+
.command('push')
|
|
236
|
+
.description('Push local skills to remote')
|
|
237
|
+
.action(() => {
|
|
238
|
+
try {
|
|
239
|
+
const result = syncPush();
|
|
240
|
+
if (!result.ok) { console.error(`Error: ${result.error}`); process.exit(1); }
|
|
241
|
+
console.log(result.message);
|
|
242
|
+
if (result.changes) {
|
|
243
|
+
for (const s of result.changes.added || []) console.log(` + ${s}`);
|
|
244
|
+
for (const s of result.changes.modified || []) console.log(` ~ ${s}`);
|
|
245
|
+
for (const s of result.changes.removed || []) console.log(` - ${s}`);
|
|
246
|
+
}
|
|
247
|
+
} catch (e) {
|
|
248
|
+
console.error(`Error: ${e.message}`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
sync
|
|
254
|
+
.command('pull')
|
|
255
|
+
.description('Pull skills from remote')
|
|
256
|
+
.action(() => {
|
|
257
|
+
try {
|
|
258
|
+
const result = syncPull();
|
|
259
|
+
if (!result.ok) { console.error(`Error: ${result.error}`); process.exit(1); }
|
|
260
|
+
console.log(result.message);
|
|
261
|
+
if (result.changes) {
|
|
262
|
+
for (const s of result.changes.added || []) console.log(` + ${s}`);
|
|
263
|
+
for (const s of result.changes.modified || []) console.log(` ~ ${s}`);
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error(`Error: ${e.message}`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
sync
|
|
272
|
+
.command('status')
|
|
273
|
+
.description('Show sync status')
|
|
274
|
+
.action(() => {
|
|
275
|
+
try {
|
|
276
|
+
const status = syncStatus();
|
|
277
|
+
if (!status.initialized) {
|
|
278
|
+
console.log('Sync not initialized. Run "quiver sync init" to get started.');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
console.log(`Backend: ${status.backend}`);
|
|
282
|
+
console.log(`Remote: ${status.remote || '(none)'}`);
|
|
283
|
+
console.log(`Last sync: ${status.lastSync || 'never'}`);
|
|
284
|
+
|
|
285
|
+
const { added, modified, removed } = status.localChanges;
|
|
286
|
+
const localTotal = added.length + modified.length + removed.length;
|
|
287
|
+
|
|
288
|
+
if (localTotal === 0 && status.remoteChanges === 0) {
|
|
289
|
+
console.log('\nEverything up to date.');
|
|
290
|
+
} else {
|
|
291
|
+
if (localTotal > 0) {
|
|
292
|
+
console.log(`\nLocal changes (${localTotal}):`);
|
|
293
|
+
for (const s of added) console.log(` + ${s} (new)`);
|
|
294
|
+
for (const s of modified) console.log(` ~ ${s} (modified)`);
|
|
295
|
+
for (const s of removed) console.log(` - ${s} (removed)`);
|
|
296
|
+
}
|
|
297
|
+
if (status.remoteChanges > 0) {
|
|
298
|
+
console.log(`\nRemote: ${status.remoteChanges} new commit${status.remoteChanges !== 1 ? 's' : ''} to pull.`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch (e) {
|
|
302
|
+
console.error(`Error: ${e.message}`);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
program.parse();
|
package/src/core/add.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, symlinkSync, cpSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, resolve, basename } from 'path';
|
|
3
|
+
import { SKILLS_DIR, ensureDirs } from './paths.js';
|
|
4
|
+
|
|
5
|
+
export function addSkill(sourcePath, opts = {}) {
|
|
6
|
+
ensureDirs();
|
|
7
|
+
const absPath = resolve(sourcePath);
|
|
8
|
+
|
|
9
|
+
if (!existsSync(absPath)) {
|
|
10
|
+
throw new Error(`Path not found: ${absPath}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Check for SKILL.md
|
|
14
|
+
const skillFile = join(absPath, 'SKILL.md');
|
|
15
|
+
if (!existsSync(skillFile)) {
|
|
16
|
+
throw new Error(`No SKILL.md found in ${absPath}. Skills must contain a SKILL.md file.`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const dirName = basename(absPath);
|
|
20
|
+
const target = join(SKILLS_DIR, dirName);
|
|
21
|
+
|
|
22
|
+
if (existsSync(target)) {
|
|
23
|
+
throw new Error(`Skill "${dirName}" already exists in ~/.claude/skills/`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (opts.copy) {
|
|
27
|
+
cpSync(absPath, target, { recursive: true });
|
|
28
|
+
} else {
|
|
29
|
+
symlinkSync(absPath, target, 'dir');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return dirName;
|
|
33
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { CONFIG_FILE, ensureDirs } from './paths.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
port: 3456,
|
|
6
|
+
sync: {
|
|
7
|
+
backend: 'local',
|
|
8
|
+
remote: null
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function loadConfig() {
|
|
13
|
+
ensureDirs();
|
|
14
|
+
try {
|
|
15
|
+
const raw = readFileSync(CONFIG_FILE, 'utf-8');
|
|
16
|
+
const saved = JSON.parse(raw);
|
|
17
|
+
return {
|
|
18
|
+
...DEFAULTS,
|
|
19
|
+
...saved,
|
|
20
|
+
sync: { ...DEFAULTS.sync, ...(saved.sync || {}) }
|
|
21
|
+
};
|
|
22
|
+
} catch {
|
|
23
|
+
return { ...DEFAULTS };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function saveConfig(config) {
|
|
28
|
+
ensureDirs();
|
|
29
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getConfigValue(key) {
|
|
33
|
+
const config = loadConfig();
|
|
34
|
+
return key.split('.').reduce((obj, k) => obj?.[k], config);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setConfigValue(key, value) {
|
|
38
|
+
const config = loadConfig();
|
|
39
|
+
const keys = key.split('.');
|
|
40
|
+
let obj = config;
|
|
41
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
42
|
+
if (typeof obj[keys[i]] !== 'object') obj[keys[i]] = {};
|
|
43
|
+
obj = obj[keys[i]];
|
|
44
|
+
}
|
|
45
|
+
obj[keys[keys.length - 1]] = value;
|
|
46
|
+
saveConfig(config);
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import AdmZip from 'adm-zip';
|
|
2
|
+
import { join, resolve, relative } from 'path';
|
|
3
|
+
import { readdirSync, statSync } from 'fs';
|
|
4
|
+
import { getSkill, listSkills } from './inventory.js';
|
|
5
|
+
|
|
6
|
+
const SENSITIVE_PATTERNS = [/^\.env/, /^\.git\//, /^node_modules\//, /^\.DS_Store$/];
|
|
7
|
+
|
|
8
|
+
function walkFiltered(dir, base = '') {
|
|
9
|
+
const results = [];
|
|
10
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
13
|
+
if (SENSITIVE_PATTERNS.some(p => p.test(rel) || p.test(entry.name))) continue;
|
|
14
|
+
if (entry.isDirectory()) {
|
|
15
|
+
results.push(...walkFiltered(join(dir, entry.name), rel));
|
|
16
|
+
} else {
|
|
17
|
+
results.push({ rel, abs: join(dir, entry.name) });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function exportSkill(name, outputDir = '.') {
|
|
24
|
+
const skill = getSkill(name);
|
|
25
|
+
if (!skill) {
|
|
26
|
+
throw new Error(`Skill "${name}" not found`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const zip = new AdmZip();
|
|
30
|
+
const files = walkFiltered(skill.path);
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const dir = file.rel.includes('/') ? file.rel.substring(0, file.rel.lastIndexOf('/')) : '';
|
|
33
|
+
zip.addLocalFile(file.abs, dir);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const outPath = resolve(outputDir, `${skill.dirName}.skill.zip`);
|
|
37
|
+
zip.writeZip(outPath);
|
|
38
|
+
return outPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function exportAll(outputDir = '.') {
|
|
42
|
+
const skills = listSkills();
|
|
43
|
+
const paths = [];
|
|
44
|
+
for (const skill of skills) {
|
|
45
|
+
paths.push(exportSkill(skill.dirName, outputDir));
|
|
46
|
+
}
|
|
47
|
+
return paths;
|
|
48
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import AdmZip from 'adm-zip';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { join, resolve, basename } from 'path';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import { SKILLS_DIR, ensureDirs } from './paths.js';
|
|
6
|
+
|
|
7
|
+
export function importSkill(zipPath) {
|
|
8
|
+
ensureDirs();
|
|
9
|
+
const absPath = resolve(zipPath);
|
|
10
|
+
|
|
11
|
+
if (!existsSync(absPath)) {
|
|
12
|
+
throw new Error(`File not found: ${absPath}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const zip = new AdmZip(absPath);
|
|
16
|
+
const entries = zip.getEntries();
|
|
17
|
+
|
|
18
|
+
// Check for SKILL.md in the zip
|
|
19
|
+
const skillEntry = entries.find(e => e.entryName === 'SKILL.md' || e.entryName.endsWith('/SKILL.md'));
|
|
20
|
+
if (!skillEntry) {
|
|
21
|
+
throw new Error('Zip does not contain a SKILL.md file');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Determine skill name from frontmatter or zip filename
|
|
25
|
+
let skillName;
|
|
26
|
+
try {
|
|
27
|
+
const content = skillEntry.getData().toString('utf-8');
|
|
28
|
+
const parsed = matter(content);
|
|
29
|
+
skillName = parsed.data.name;
|
|
30
|
+
} catch {
|
|
31
|
+
// Fall through to filename
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!skillName) {
|
|
35
|
+
skillName = basename(absPath, '.skill.zip').replace('.zip', '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Sanitize skill name to prevent path traversal
|
|
39
|
+
skillName = skillName.replace(/[/\\]/g, '-').replace(/\.\./g, '');
|
|
40
|
+
if (!skillName || skillName.startsWith('.')) throw new Error('Invalid skill name in zip');
|
|
41
|
+
|
|
42
|
+
const target = join(SKILLS_DIR, skillName);
|
|
43
|
+
if (existsSync(target)) {
|
|
44
|
+
throw new Error(`Skill "${skillName}" already exists. Remove it first.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Zip slip protection: verify no entries escape the target directory
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const resolved = resolve(target, entry.entryName);
|
|
50
|
+
if (!resolved.startsWith(target + '/') && resolved !== target) {
|
|
51
|
+
throw new Error('Zip contains unsafe path: ' + entry.entryName);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
zip.extractAllTo(target, true);
|
|
56
|
+
return skillName;
|
|
57
|
+
}
|