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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/cli.js';
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();
@@ -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
+ }