skillvault 0.3.0 → 0.4.1

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.
Files changed (3) hide show
  1. package/dist/cli.d.ts +10 -7
  2. package/dist/cli.js +338 -210
  3. package/package.json +1 -1
package/dist/cli.d.ts CHANGED
@@ -6,13 +6,16 @@
6
6
  * npx skillvault --invite CODE # Setup / add publisher
7
7
  * npx skillvault --status # Show publishers & skills
8
8
  * npx skillvault --refresh # Re-authenticate tokens + sync
9
- * npx skillvault --sync # Sync & install skills
10
- * npx skillvault # Same as --sync
9
+ * npx skillvault --sync # Sync vaults + install stubs
10
+ * npx skillvault # Start MCP server (after setup)
11
11
  *
12
- * What it does:
13
- * 1. Redeems invite code (if provided) additive across publishers
14
- * 2. Downloads encrypted skill vaults from the server
15
- * 3. Decrypts and installs skills as native Claude Code skills (~/.claude/skills/)
16
- * 4. Claude Code discovers and uses them directly — no MCP intermediary
12
+ * How it works:
13
+ * 1. Redeems invite code gets publisher token
14
+ * 2. Downloads encrypted skill vaults to ~/.skillvault/vaults/
15
+ * 3. Installs stub SKILL.md files in ~/.claude/skills/ (Claude discovers these)
16
+ * 4. Starts MCP server exposing skillvault_load tool
17
+ * 5. When Claude triggers a skill, it calls skillvault_load → decrypts on demand
18
+ * 6. Decrypted content lives only in conversation context — never on disk
19
+ * 7. License is validated on every load — revoked license = dead skill
17
20
  */
18
21
  export {};
package/dist/cli.js CHANGED
@@ -6,21 +6,26 @@
6
6
  * npx skillvault --invite CODE # Setup / add publisher
7
7
  * npx skillvault --status # Show publishers & skills
8
8
  * npx skillvault --refresh # Re-authenticate tokens + sync
9
- * npx skillvault --sync # Sync & install skills
10
- * npx skillvault # Same as --sync
9
+ * npx skillvault --sync # Sync vaults + install stubs
10
+ * npx skillvault # Start MCP server (after setup)
11
11
  *
12
- * What it does:
13
- * 1. Redeems invite code (if provided) additive across publishers
14
- * 2. Downloads encrypted skill vaults from the server
15
- * 3. Decrypts and installs skills as native Claude Code skills (~/.claude/skills/)
16
- * 4. Claude Code discovers and uses them directly — no MCP intermediary
12
+ * How it works:
13
+ * 1. Redeems invite code gets publisher token
14
+ * 2. Downloads encrypted skill vaults to ~/.skillvault/vaults/
15
+ * 3. Installs stub SKILL.md files in ~/.claude/skills/ (Claude discovers these)
16
+ * 4. Starts MCP server exposing skillvault_load tool
17
+ * 5. When Claude triggers a skill, it calls skillvault_load → decrypts on demand
18
+ * 6. Decrypted content lives only in conversation context — never on disk
19
+ * 7. License is validated on every load — revoked license = dead skill
17
20
  */
21
+ import { createServer } from 'node:http';
18
22
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
19
23
  import { join } from 'node:path';
20
24
  import { createDecipheriv, createPublicKey, diffieHellman, hkdfSync, generateKeyPairSync, } from 'node:crypto';
21
- const VERSION = '0.3.0';
25
+ const VERSION = '0.4.1';
22
26
  const HOME = process.env.HOME || process.env.USERPROFILE || '~';
23
27
  const API_URL = process.env.SKILLVAULT_API_URL || 'https://api.getskillvault.com';
28
+ const MCP_PORT = parseInt(process.env.SKILLVAULT_MCP_PORT || '9877', 10);
24
29
  const CONFIG_DIR = join(HOME, '.skillvault');
25
30
  const CONFIG_PATH = join(CONFIG_DIR, 'agent-config.json');
26
31
  const VAULT_DIR = join(CONFIG_DIR, 'vaults');
@@ -46,13 +51,18 @@ if (helpFlag) {
46
51
  npx skillvault --invite CODE Setup or add a new publisher
47
52
  npx skillvault --status Show all publishers, skills, and statuses
48
53
  npx skillvault --refresh Re-authenticate expired tokens + sync skills
49
- npx skillvault --sync Sync and install skills from all publishers
50
- npx skillvault Same as --sync
54
+ npx skillvault --sync Sync vaults and install skill stubs
55
+ npx skillvault Start MCP server (default, after setup)
51
56
  npx skillvault --help Show this help
52
57
  npx skillvault --version Show version
53
58
 
59
+ Skills are encrypted at rest. When Claude Code triggers a skill, the
60
+ MCP server decrypts it on demand and returns the content to Claude's
61
+ session. The decrypted content is never written to disk.
62
+
54
63
  Environment:
55
64
  SKILLVAULT_API_URL Server URL (default: https://api.getskillvault.com)
65
+ SKILLVAULT_MCP_PORT MCP server port (default: 9877)
56
66
  `);
57
67
  process.exit(0);
58
68
  }
@@ -60,7 +70,6 @@ function loadConfig() {
60
70
  try {
61
71
  if (existsSync(CONFIG_PATH)) {
62
72
  const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
63
- // Migrate legacy single-publisher config
64
73
  if (raw.token && !raw.publishers) {
65
74
  const migrated = {
66
75
  customer_token: raw.token,
@@ -87,11 +96,10 @@ function saveConfig(config) {
87
96
  mkdirSync(CONFIG_DIR, { recursive: true });
88
97
  writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
89
98
  }
90
- // ── Setup: Redeem Invite + Sync Skills ──
99
+ // ── Setup: Redeem Invite + Configure MCP + Sync ──
91
100
  async function setup(code) {
92
101
  console.log('🔐 SkillVault Setup');
93
102
  console.log(` Redeeming invite code: ${code}`);
94
- // Redeem invite code for a token
95
103
  const response = await fetch(`${API_URL}/auth/companion/token`, {
96
104
  method: 'POST',
97
105
  headers: { 'Content-Type': 'application/json' },
@@ -117,7 +125,6 @@ async function setup(code) {
117
125
  added_at: new Date().toISOString(),
118
126
  };
119
127
  if (existingConfig) {
120
- // Additive: merge new publisher into existing config
121
128
  const existingIdx = existingConfig.publishers.findIndex(p => p.id === data.publisher_id);
122
129
  if (existingIdx >= 0) {
123
130
  existingConfig.publishers[existingIdx] = publisherEntry;
@@ -127,56 +134,60 @@ async function setup(code) {
127
134
  existingConfig.publishers.push(publisherEntry);
128
135
  console.log(` ➕ Added publisher: ${publisherEntry.name}. You now have skills from ${existingConfig.publishers.length} publishers.`);
129
136
  }
130
- if (data.customer_token) {
137
+ if (data.customer_token)
131
138
  existingConfig.customer_token = data.customer_token;
132
- }
133
- if (data.email) {
139
+ if (data.email)
134
140
  existingConfig.customer_email = data.email;
135
- }
136
141
  saveConfig(existingConfig);
137
142
  }
138
143
  else {
139
- const config = {
144
+ saveConfig({
140
145
  customer_token: data.customer_token || data.token,
141
146
  customer_email: data.email,
142
147
  publishers: [publisherEntry],
143
148
  api_url: API_URL,
144
149
  setup_at: new Date().toISOString(),
145
- };
146
- saveConfig(config);
150
+ });
147
151
  }
148
- // Create publisher-scoped vault cache directory
149
152
  mkdirSync(join(VAULT_DIR, data.publisher_id), { recursive: true });
150
- // Clean up legacy MCP config if present
151
- cleanupMCPConfig();
152
- // Sync and install skills immediately
153
+ // Configure MCP server in Claude Code
154
+ configureMCP();
155
+ // Sync vaults and install stubs
153
156
  console.log('');
154
157
  console.log(' Syncing skills...');
155
- const syncResult = await syncSkills();
156
- const installResult = await installDecryptedSkills();
158
+ await syncSkills();
159
+ const installResult = await installSkillStubs();
157
160
  console.log('');
158
161
  if (installResult.installed > 0) {
159
- console.log(` ✅ ${installResult.installed} skill${installResult.installed !== 1 ? 's' : ''} installed to ~/.claude/skills/`);
162
+ console.log(` ✅ ${installResult.installed} skill${installResult.installed !== 1 ? 's' : ''} ready`);
160
163
  }
161
- console.log(' Setup complete! Restart Claude Code to use your new skills.');
164
+ console.log(' Setup complete! Start the agent with: npx skillvault');
165
+ console.log(' Then restart Claude Code to discover your skills.');
162
166
  console.log('');
163
167
  }
164
168
  /**
165
- * Remove legacy skillvault MCP server entry from ~/.claude/.mcp.json
169
+ * Configure Claude Code to connect to the SkillVault MCP server.
166
170
  */
167
- function cleanupMCPConfig() {
171
+ function configureMCP() {
168
172
  const mcpConfigPath = join(HOME, '.claude', '.mcp.json');
169
173
  try {
174
+ let mcpConfig = {};
170
175
  if (existsSync(mcpConfigPath)) {
171
- const mcpConfig = JSON.parse(readFileSync(mcpConfigPath, 'utf8'));
172
- if (mcpConfig.mcpServers?.skillvault) {
173
- delete mcpConfig.mcpServers.skillvault;
174
- writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), { mode: 0o600 });
175
- console.log(' 🧹 Removed legacy MCP server config');
176
- }
176
+ mcpConfig = JSON.parse(readFileSync(mcpConfigPath, 'utf8'));
177
177
  }
178
+ if (!mcpConfig.mcpServers)
179
+ mcpConfig.mcpServers = {};
180
+ mcpConfig.mcpServers.skillvault = {
181
+ type: 'url',
182
+ url: `http://127.0.0.1:${MCP_PORT}/mcp`,
183
+ };
184
+ mkdirSync(join(HOME, '.claude'), { recursive: true });
185
+ writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), { mode: 0o600 });
186
+ console.log(` ✅ Claude Code MCP configured`);
187
+ }
188
+ catch {
189
+ console.error(` ⚠️ Failed to configure MCP — manually add to ~/.claude/.mcp.json`);
178
190
  }
179
- catch { }
180
191
  }
181
192
  async function showStatus() {
182
193
  const config = loadConfig();
@@ -187,14 +198,12 @@ async function showStatus() {
187
198
  process.exit(1);
188
199
  }
189
200
  console.log('🔐 SkillVault Status\n');
190
- if (config.customer_email) {
201
+ if (config.customer_email)
191
202
  console.log(` Account: ${config.customer_email}`);
192
- }
193
203
  console.log(` Config: ${CONFIG_PATH}`);
194
- console.log(` Skills: ${SKILLS_DIR}`);
204
+ console.log(` Skills: ${SKILLS_DIR} (stubs — encrypted at rest)`);
195
205
  console.log(` Server: ${config.api_url}`);
196
206
  console.log('');
197
- // Try fetching live data from the server
198
207
  let skills = [];
199
208
  let online = false;
200
209
  try {
@@ -214,10 +223,7 @@ async function showStatus() {
214
223
  }
215
224
  }
216
225
  }
217
- catch {
218
- // Offline — fall back to local data
219
- }
220
- // Build publisher table
226
+ catch { }
221
227
  console.log(' Publishers:');
222
228
  console.log(' ' + '-'.repeat(60));
223
229
  console.log(` ${'Name'.padEnd(25)} ${'Skills'.padEnd(10)} ${'Status'.padEnd(15)}`);
@@ -225,8 +231,6 @@ async function showStatus() {
225
231
  let totalSkills = 0;
226
232
  for (const pub of config.publishers) {
227
233
  const pubSkills = skills.filter(s => s.publisher_id === pub.id);
228
- const skillCount = pubSkills.length;
229
- // Check for local vaults as fallback
230
234
  let localVaultCount = 0;
231
235
  const pubVaultDir = join(VAULT_DIR, pub.id);
232
236
  if (existsSync(pubVaultDir)) {
@@ -235,13 +239,12 @@ async function showStatus() {
235
239
  }
236
240
  catch { }
237
241
  }
238
- const displayCount = online ? skillCount : localVaultCount;
242
+ const displayCount = online ? pubSkills.length : localVaultCount;
239
243
  totalSkills += displayCount;
240
244
  const status = online ? 'connected' : 'offline (cached)';
241
245
  console.log(` ${pub.name.padEnd(25)} ${String(displayCount).padEnd(10)} ${status.padEnd(15)}`);
242
246
  }
243
247
  console.log(' ' + '-'.repeat(60));
244
- // Show skill details if online
245
248
  if (online && skills.length > 0) {
246
249
  console.log('\n Skills:');
247
250
  console.log(' ' + '-'.repeat(70));
@@ -276,18 +279,14 @@ async function refreshTokens() {
276
279
  try {
277
280
  const res = await fetch(`${config.api_url}/auth/companion/refresh`, {
278
281
  method: 'POST',
279
- headers: {
280
- 'Content-Type': 'application/json',
281
- 'Authorization': `Bearer ${pub.token}`,
282
- },
282
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${pub.token}` },
283
283
  signal: AbortSignal.timeout(10000),
284
284
  });
285
285
  if (res.ok) {
286
286
  const data = await res.json();
287
287
  pub.token = data.token;
288
- if (data.customer_token) {
288
+ if (data.customer_token)
289
289
  config.customer_token = data.customer_token;
290
- }
291
290
  console.log('✅ refreshed');
292
291
  anyRefreshed = true;
293
292
  }
@@ -320,7 +319,6 @@ async function syncSkills() {
320
319
  for (const pub of config.publishers) {
321
320
  const pubVaultDir = join(VAULT_DIR, pub.id);
322
321
  mkdirSync(pubVaultDir, { recursive: true });
323
- // Fetch skill list for this publisher
324
322
  let skills = [];
325
323
  try {
326
324
  const res = await fetch(`${config.api_url}/skills?publisher_id=${encodeURIComponent(pub.id)}`, {
@@ -328,12 +326,7 @@ async function syncSkills() {
328
326
  signal: AbortSignal.timeout(10000),
329
327
  });
330
328
  if (!res.ok) {
331
- if (res.status === 401) {
332
- errors.push(`${pub.name}: auth expired (${res.status})`);
333
- }
334
- else {
335
- errors.push(`${pub.name}: failed to fetch skills (${res.status})`);
336
- }
329
+ errors.push(`${pub.name}: ${res.status === 401 ? 'auth expired' : `failed (${res.status})`}`);
337
330
  continue;
338
331
  }
339
332
  const data = await res.json();
@@ -343,9 +336,8 @@ async function syncSkills() {
343
336
  errors.push(`${pub.name}: ${err instanceof Error ? err.message : 'fetch failed'}`);
344
337
  continue;
345
338
  }
346
- // Build set of remote skill names for revocation check
347
339
  const remoteSkillNames = new Set(skills.map(s => s.skill_name));
348
- // Check for revoked grants remove decrypted skills
340
+ // Revocation: remove stubs for skills no longer in the remote list
349
341
  try {
350
342
  if (existsSync(pubVaultDir)) {
351
343
  const localVaults = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault'));
@@ -353,16 +345,11 @@ async function syncSkills() {
353
345
  const localSkillName = vaultFile.replace(/\.vault$/, '');
354
346
  if (!remoteSkillNames.has(localSkillName)) {
355
347
  console.log(`[sync] Grant revoked: "${localSkillName}" from ${pub.name}`);
356
- // Remove decrypted skill from Claude Code skills directory
357
348
  const skillDir = join(SKILLS_DIR, localSkillName);
358
349
  try {
359
- if (existsSync(skillDir)) {
360
- // Verify it's a SkillVault-managed skill before deleting
361
- const manifestPath = join(skillDir, 'manifest.json');
362
- if (existsSync(manifestPath)) {
363
- rmSync(skillDir, { recursive: true, force: true });
364
- console.log(`[sync] Removed: ~/.claude/skills/${localSkillName}/`);
365
- }
350
+ if (existsSync(skillDir) && existsSync(join(skillDir, 'manifest.json'))) {
351
+ rmSync(skillDir, { recursive: true, force: true });
352
+ console.log(`[sync] Removed stub: ~/.claude/skills/${localSkillName}/`);
366
353
  }
367
354
  }
368
355
  catch { }
@@ -371,12 +358,11 @@ async function syncSkills() {
371
358
  }
372
359
  }
373
360
  catch { }
374
- // Download missing or updated vaults
361
+ // Download missing or updated vaults + write skill metadata for stubs
375
362
  let pubSynced = 0;
376
363
  for (const skill of skills) {
377
364
  const vaultPath = join(pubVaultDir, `${skill.skill_name}.vault`);
378
365
  const vaultExists = existsSync(vaultPath);
379
- // Check if we need to download: missing vault, or new version available
380
366
  let needsDownload = !vaultExists;
381
367
  if (vaultExists && skill.vault_hash) {
382
368
  const hashPath = vaultPath + '.hash';
@@ -393,15 +379,11 @@ async function syncSkills() {
393
379
  }
394
380
  if (!needsDownload)
395
381
  continue;
396
- // Download vault
397
382
  const capabilityName = skill.capability_name || skill.skill_name;
398
383
  try {
399
384
  let dlRes = await fetch(`${config.api_url}/skills/download?capability=${encodeURIComponent(capabilityName)}`, { signal: AbortSignal.timeout(15000) });
400
385
  if (!dlRes.ok) {
401
- dlRes = await fetch(`${config.api_url}/skills/download?capability=${encodeURIComponent(capabilityName)}`, {
402
- headers: { 'Authorization': `Bearer ${pub.token}` },
403
- signal: AbortSignal.timeout(15000),
404
- });
386
+ dlRes = await fetch(`${config.api_url}/skills/download?capability=${encodeURIComponent(capabilityName)}`, { headers: { 'Authorization': `Bearer ${pub.token}` }, signal: AbortSignal.timeout(15000) });
405
387
  }
406
388
  if (!dlRes.ok) {
407
389
  errors.push(`${pub.name}/${skill.skill_name}: download failed (${dlRes.status})`);
@@ -410,29 +392,115 @@ async function syncSkills() {
410
392
  const dlData = await dlRes.json();
411
393
  const vaultBuffer = Buffer.from(dlData.vault_data, 'base64');
412
394
  writeFileSync(vaultPath, vaultBuffer, { mode: 0o600 });
413
- if (dlData.vault_hash) {
395
+ if (dlData.vault_hash)
414
396
  writeFileSync(vaultPath + '.hash', dlData.vault_hash, { mode: 0o600 });
415
- }
397
+ // Store skill metadata for stub generation
398
+ writeFileSync(vaultPath + '.meta', JSON.stringify({
399
+ skill_name: skill.skill_name,
400
+ description: skill.description || '',
401
+ capability_name: skill.capability_name,
402
+ version: dlData.version || skill.version || '0.0.0',
403
+ publisher_name: pub.name,
404
+ publisher_id: pub.id,
405
+ }), { mode: 0o600 });
416
406
  pubSynced++;
417
- const action = vaultExists ? 'Updated' : 'Downloaded';
418
- console.log(`[sync] ${action}: "${skill.skill_name}" from ${pub.name}`);
407
+ console.log(`[sync] ${vaultExists ? 'Updated' : 'Downloaded'}: "${skill.skill_name}" from ${pub.name}`);
419
408
  }
420
409
  catch (err) {
421
410
  errors.push(`${pub.name}/${skill.skill_name}: ${err instanceof Error ? err.message : 'download error'}`);
422
411
  }
423
412
  }
424
- if (pubSynced > 0) {
413
+ if (pubSynced > 0)
425
414
  console.log(`[sync] Synced ${pubSynced} vault${pubSynced !== 1 ? 's' : ''} from ${pub.name}`);
426
- }
427
415
  totalSynced += pubSynced;
428
416
  }
429
417
  return { synced: totalSynced, errors };
430
418
  }
431
- // ── Vault Decryption ──
432
- const VAULT_MAGIC = Buffer.from([0x00, 0x53, 0x56, 0x54]);
433
419
  /**
434
- * Decrypt a vault and return all files with their full content (frontmatter preserved).
420
+ * Install lightweight stub SKILL.md files in ~/.claude/skills/ for each vault.
421
+ * These stubs tell Claude to call the skillvault_load MCP tool to decrypt on demand.
422
+ * No decrypted content is written to disk.
435
423
  */
424
+ async function installSkillStubs() {
425
+ const config = loadConfig();
426
+ if (!config || config.publishers.length === 0) {
427
+ return { installed: 0, skipped: 0, errors: ['No config or publishers found'] };
428
+ }
429
+ let installed = 0;
430
+ let skipped = 0;
431
+ const errors = [];
432
+ mkdirSync(SKILLS_DIR, { recursive: true });
433
+ for (const pub of config.publishers) {
434
+ const pubVaultDir = join(VAULT_DIR, pub.id);
435
+ if (!existsSync(pubVaultDir))
436
+ continue;
437
+ const vaultFiles = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault'));
438
+ for (const vaultFile of vaultFiles) {
439
+ const skillName = vaultFile.replace(/\.vault$/, '');
440
+ const vaultPath = join(pubVaultDir, vaultFile);
441
+ const skillDir = join(SKILLS_DIR, skillName);
442
+ const manifestPath = join(skillDir, 'manifest.json');
443
+ const hashPath = vaultPath + '.hash';
444
+ // Check if stub is already up to date
445
+ if (existsSync(manifestPath) && existsSync(hashPath)) {
446
+ try {
447
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
448
+ const currentHash = readFileSync(hashPath, 'utf8').trim();
449
+ if (manifest.vault_hash === currentHash) {
450
+ skipped++;
451
+ continue;
452
+ }
453
+ }
454
+ catch { }
455
+ }
456
+ // Read skill metadata (written during sync)
457
+ let meta = { skill_name: skillName, description: '', publisher_name: pub.name };
458
+ const metaPath = vaultPath + '.meta';
459
+ try {
460
+ if (existsSync(metaPath))
461
+ meta = JSON.parse(readFileSync(metaPath, 'utf8'));
462
+ }
463
+ catch { }
464
+ // Write stub SKILL.md
465
+ mkdirSync(skillDir, { recursive: true });
466
+ const stub = `---
467
+ name: ${skillName}
468
+ description: "${(meta.description || '').replace(/"/g, '\\"')}"
469
+ ---
470
+
471
+ # ${skillName}
472
+
473
+ This is an encrypted SkillVault skill from **${meta.publisher_name || pub.name}**.
474
+
475
+ ## How to use
476
+
477
+ 1. Make sure the SkillVault MCP server is running: \`npx skillvault\`
478
+ 2. Call the \`skillvault_load\` MCP tool with skill_name "${skillName}"
479
+ 3. Follow the decrypted instructions it returns
480
+
481
+ If the MCP server is not running or the skillvault_load tool is not available, tell the user to start it by running \`npx skillvault\` in a separate terminal.
482
+ `;
483
+ writeFileSync(join(skillDir, 'SKILL.md'), stub, { mode: 0o600 });
484
+ // Write manifest
485
+ const vaultHash = existsSync(hashPath) ? readFileSync(hashPath, 'utf8').trim() : '';
486
+ writeFileSync(manifestPath, JSON.stringify({
487
+ publisher: meta.publisher_name || pub.name,
488
+ publisher_id: pub.id,
489
+ skill_name: skillName,
490
+ capability_name: meta.capability_name || `skill/${skillName}`,
491
+ version: meta.version || '0.0.0',
492
+ vault_hash: vaultHash,
493
+ installed_at: new Date().toISOString(),
494
+ encrypted: true,
495
+ }, null, 2), { mode: 0o600 });
496
+ installed++;
497
+ console.log(`[install] Stub installed: "${skillName}" → ~/.claude/skills/${skillName}/`);
498
+ }
499
+ }
500
+ return { installed, skipped, errors };
501
+ }
502
+ // ── Vault Decryption (in-memory only) ──
503
+ const VAULT_MAGIC = Buffer.from([0x00, 0x53, 0x56, 0x54]);
436
504
  function decryptVault(data, cek) {
437
505
  let offset = 0;
438
506
  const magic = data.subarray(offset, (offset += 4));
@@ -465,15 +533,11 @@ function decryptVault(data, cek) {
465
533
  }));
466
534
  return { metadata, files };
467
535
  }
468
- /**
469
- * Resolve which publisher owns a skill by searching vault directories.
470
- */
471
536
  function resolveSkillPublisher(skillName, config) {
472
537
  for (const pub of config.publishers) {
473
538
  const vaultPath = join(VAULT_DIR, pub.id, `${skillName}.vault`);
474
- if (existsSync(vaultPath)) {
539
+ if (existsSync(vaultPath))
475
540
  return { publisher: pub, vaultPath };
476
- }
477
541
  }
478
542
  const legacyPath = join(VAULT_DIR, `${skillName}.vault`);
479
543
  if (existsSync(legacyPath) && config.publishers.length > 0) {
@@ -484,13 +548,9 @@ function resolveSkillPublisher(skillName, config) {
484
548
  async function fetchCEK(skillName, publisherToken) {
485
549
  const kp = generateKeyPairSync('x25519');
486
550
  const pub = kp.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
487
- const headers = {
488
- 'Content-Type': 'application/json',
489
- 'Authorization': `Bearer ${publisherToken}`,
490
- };
491
551
  const res = await fetch(`${API_URL}/v1/skills/${skillName}/cek`, {
492
552
  method: 'POST',
493
- headers,
553
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${publisherToken}` },
494
554
  body: JSON.stringify({ companion_public_key: pub }),
495
555
  });
496
556
  if (!res.ok)
@@ -515,103 +575,182 @@ function watermark(content, id) {
515
575
  const mark = BigInt('0x' + hex).toString(4).split('').map(d => zw[d]).join('');
516
576
  return content.split('\n').map((l, i) => (i > 0 && i % 5 === 0 ? l + mark : l)).join('\n');
517
577
  }
518
- /**
519
- * Decrypt all vaults and write them as native Claude Code skills to ~/.claude/skills/.
520
- */
521
- async function installDecryptedSkills() {
578
+ // ── On-Demand Skill Loading (MCP tool handler) ──
579
+ function validateSkillName(name) {
580
+ return /^[a-zA-Z0-9_-]+$/.test(name) && name.length > 0 && name.length <= 128;
581
+ }
582
+ async function loadSkill(skillName) {
583
+ if (!validateSkillName(skillName)) {
584
+ return { success: false, content: '', error: 'Invalid skill name. Only letters, numbers, hyphens, and underscores allowed.' };
585
+ }
522
586
  const config = loadConfig();
523
- if (!config || config.publishers.length === 0) {
524
- return { installed: 0, skipped: 0, errors: ['No config or publishers found'] };
587
+ if (!config) {
588
+ return { success: false, content: '', error: 'Not configured. Run: npx skillvault --invite CODE' };
589
+ }
590
+ const resolved = resolveSkillPublisher(skillName, config);
591
+ if (!resolved) {
592
+ return { success: false, content: '', error: `Vault not found for "${skillName}". Run: npx skillvault --sync` };
525
593
  }
526
594
  const licenseeId = config.customer_email || 'unknown';
527
- let installed = 0;
528
- let skipped = 0;
529
- const errors = [];
530
- mkdirSync(SKILLS_DIR, { recursive: true });
531
- for (const pub of config.publishers) {
532
- const pubVaultDir = join(VAULT_DIR, pub.id);
533
- if (!existsSync(pubVaultDir))
534
- continue;
535
- const vaultFiles = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault'));
536
- for (const vaultFile of vaultFiles) {
537
- const skillName = vaultFile.replace(/\.vault$/, '');
538
- const vaultPath = join(pubVaultDir, vaultFile);
539
- const skillDir = join(SKILLS_DIR, skillName);
540
- const manifestPath = join(skillDir, 'manifest.json');
541
- // Check if decryption is needed by comparing vault hash
542
- const hashPath = vaultPath + '.hash';
543
- if (existsSync(manifestPath) && existsSync(hashPath)) {
544
- try {
545
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
546
- const currentHash = readFileSync(hashPath, 'utf8').trim();
547
- if (manifest.vault_hash === currentHash) {
548
- skipped++;
549
- continue; // Already up to date
550
- }
551
- }
552
- catch { }
553
- }
554
- // Fetch CEK (validates license)
555
- let cek;
556
- try {
557
- cek = await fetchCEK(skillName, pub.token);
558
- }
559
- catch (err) {
560
- errors.push(`${pub.name}/${skillName}: license check failed — ${err instanceof Error ? err.message : 'unknown'}`);
561
- continue;
595
+ // Fetch CEK — validates license on every load
596
+ let cek;
597
+ try {
598
+ cek = await fetchCEK(skillName, resolved.publisher.token);
599
+ }
600
+ catch (err) {
601
+ return { success: false, content: '', error: `License check failed: ${err instanceof Error ? err.message : 'unknown'}. Your license may have been revoked.` };
602
+ }
603
+ // Decrypt in memory content never touches disk
604
+ try {
605
+ const vaultData = readFileSync(resolved.vaultPath);
606
+ const vault = decryptVault(vaultData, cek);
607
+ cek.fill(0);
608
+ // Combine all files into a single response, with SKILL.md first
609
+ const skillMd = vault.files.find(f => f.path === 'SKILL.md');
610
+ const otherFiles = vault.files.filter(f => f.path !== 'SKILL.md');
611
+ let content = '';
612
+ if (skillMd) {
613
+ content = watermark(skillMd.content, licenseeId);
614
+ }
615
+ for (const file of otherFiles) {
616
+ content += `\n\n---\n# File: ${file.path}\n\n${watermark(file.content, licenseeId)}`;
617
+ }
618
+ return { success: true, content };
619
+ }
620
+ catch (err) {
621
+ cek.fill(0);
622
+ return { success: false, content: '', error: `Decryption failed: ${err instanceof Error ? err.message : 'unknown'}` };
623
+ }
624
+ }
625
+ function rpcOk(id, result) { return JSON.stringify({ jsonrpc: '2.0', id, result }); }
626
+ function rpcErr(id, code, msg) { return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message: msg } }); }
627
+ async function handleRPC(req) {
628
+ switch (req.method) {
629
+ case 'initialize':
630
+ return rpcOk(req.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'skillvault', version: VERSION } });
631
+ case 'tools/list':
632
+ return rpcOk(req.id, {
633
+ tools: [{
634
+ name: 'skillvault_load',
635
+ description: 'Load and decrypt a SkillVault skill. Returns the full skill instructions for you to follow. The skill is decrypted on demand — license is validated each time. Call this when a stub SKILL.md tells you to load a skill.',
636
+ inputSchema: {
637
+ type: 'object',
638
+ properties: {
639
+ skill_name: { type: 'string', description: 'Name of the skill to load (from the stub SKILL.md)' },
640
+ },
641
+ required: ['skill_name'],
642
+ },
643
+ }],
644
+ });
645
+ case 'tools/call': {
646
+ const name = req.params?.name;
647
+ const { skill_name } = req.params?.arguments || {};
648
+ if (name !== 'skillvault_load')
649
+ return rpcErr(req.id, -32601, `Unknown tool: ${name}`);
650
+ if (!skill_name)
651
+ return rpcErr(req.id, -32602, 'skill_name required');
652
+ console.log(`[MCP] Loading: ${skill_name}`);
653
+ const result = await loadSkill(skill_name);
654
+ return rpcOk(req.id, {
655
+ content: [{ type: 'text', text: result.success ? result.content : `Error: ${result.error}` }],
656
+ isError: !result.success,
657
+ });
658
+ }
659
+ case 'notifications/initialized': return '';
660
+ default: return rpcErr(req.id || 0, -32601, `Unknown method: ${req.method}`);
661
+ }
662
+ }
663
+ function startServer() {
664
+ const server = createServer(async (req, res) => {
665
+ if (req.method === 'OPTIONS') {
666
+ res.writeHead(403);
667
+ res.end();
668
+ return;
669
+ }
670
+ if (req.method !== 'POST' || req.url !== '/mcp') {
671
+ res.writeHead(404);
672
+ res.end(JSON.stringify({ error: 'POST /mcp' }));
673
+ return;
674
+ }
675
+ const chunks = [];
676
+ let size = 0;
677
+ const MAX_BODY = 1024 * 1024;
678
+ req.on('data', (c) => { size += c.length; if (size > MAX_BODY) {
679
+ req.destroy();
680
+ return;
681
+ } chunks.push(c); });
682
+ req.on('end', async () => {
683
+ if (size > MAX_BODY) {
684
+ res.writeHead(413);
685
+ res.end('Request too large');
686
+ return;
562
687
  }
563
- // Decrypt vault
564
- let vault;
565
688
  try {
566
- const vaultData = readFileSync(vaultPath);
567
- vault = decryptVault(vaultData, cek);
568
- }
569
- catch (err) {
570
- errors.push(`${pub.name}/${skillName}: decryption failed — ${err instanceof Error ? err.message : 'unknown'}`);
571
- continue;
572
- }
573
- finally {
574
- cek.fill(0);
689
+ const rpc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
690
+ const result = await handleRPC(rpc);
691
+ if (result) {
692
+ res.writeHead(200, { 'Content-Type': 'application/json' });
693
+ res.end(result);
694
+ }
695
+ else {
696
+ res.writeHead(204);
697
+ res.end();
698
+ }
575
699
  }
576
- // Write decrypted files to skills directory
577
- mkdirSync(skillDir, { recursive: true });
578
- for (const file of vault.files) {
579
- const filePath = join(skillDir, file.path);
580
- // Create subdirectories if needed (e.g., references/api_reference.md)
581
- mkdirSync(join(filePath, '..'), { recursive: true });
582
- // Apply watermark to text files
583
- const content = file.path.endsWith('.md') || file.path.endsWith('.txt')
584
- ? watermark(file.content, licenseeId)
585
- : file.content;
586
- writeFileSync(filePath, content, { mode: 0o600 });
700
+ catch {
701
+ res.writeHead(400);
702
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }));
587
703
  }
588
- // Write manifest for tracking
589
- const vaultHash = existsSync(hashPath) ? readFileSync(hashPath, 'utf8').trim() : '';
590
- const manifest = {
591
- publisher: pub.name,
592
- publisher_id: pub.id,
593
- skill_name: skillName,
594
- capability_name: `skill/${skillName}`,
595
- version: vault.metadata.version || '0.0.0',
596
- vault_hash: vaultHash,
597
- installed_at: new Date().toISOString(),
598
- watermark_applied: true,
599
- licensee_id: licenseeId,
600
- };
601
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), { mode: 0o600 });
602
- installed++;
603
- console.log(`[install] Installed: "${skillName}" → ~/.claude/skills/${skillName}/`);
704
+ });
705
+ });
706
+ server.listen(MCP_PORT, '127.0.0.1', () => {
707
+ console.log(`🔐 SkillVault MCP server running on 127.0.0.1:${MCP_PORT}`);
708
+ const config = loadConfig();
709
+ if (config)
710
+ console.log(` Publishers: ${config.publishers.map(p => p.name).join(', ')}`);
711
+ console.log(` Skills are encrypted at rest — decrypted on demand per session.`);
712
+ console.log(` Press Ctrl+C to stop.\n`);
713
+ // Background sync
714
+ syncSkills().then(({ synced, errors }) => {
715
+ if (synced > 0)
716
+ console.log(`[sync] Startup: ${synced} vault${synced !== 1 ? 's' : ''} synced`);
717
+ if (errors.length > 0)
718
+ console.log(`[sync] Errors: ${errors.join('; ')}`);
719
+ return installSkillStubs();
720
+ }).then(({ installed }) => {
721
+ if (installed > 0)
722
+ console.log(`[install] ${installed} stub${installed !== 1 ? 's' : ''} updated`);
723
+ }).catch((err) => {
724
+ console.error('[sync] Startup sync failed:', err instanceof Error ? err.message : err);
725
+ });
726
+ // Periodic sync every 5 minutes
727
+ setInterval(() => {
728
+ syncSkills().then(({ synced, errors }) => {
729
+ if (synced > 0)
730
+ console.log(`[sync] ${synced} vault${synced !== 1 ? 's' : ''} synced`);
731
+ if (errors.length > 0)
732
+ console.log(`[sync] Errors: ${errors.join('; ')}`);
733
+ return installSkillStubs();
734
+ }).then(({ installed }) => {
735
+ if (installed > 0)
736
+ console.log(`[install] ${installed} stub${installed !== 1 ? 's' : ''} updated`);
737
+ }).catch(() => { });
738
+ }, 5 * 60 * 1000);
739
+ });
740
+ server.on('error', (err) => {
741
+ if (err.code === 'EADDRINUSE') {
742
+ console.log(` SkillVault is already running on port ${MCP_PORT}`);
743
+ process.exit(0);
604
744
  }
605
- }
606
- return { installed, skipped, errors };
745
+ console.error('Server error:', err);
746
+ });
607
747
  }
608
748
  // ── Main ──
609
749
  async function main() {
610
750
  if (inviteCode) {
611
751
  await setup(inviteCode);
612
- if (!statusFlag && !refreshFlag) {
752
+ if (!statusFlag && !refreshFlag)
613
753
  process.exit(0);
614
- }
615
754
  }
616
755
  if (statusFlag) {
617
756
  await showStatus();
@@ -619,20 +758,27 @@ async function main() {
619
758
  }
620
759
  if (refreshFlag) {
621
760
  await refreshTokens();
622
- // Also sync after refresh
623
- console.log(' Syncing skills...\n');
624
761
  await syncSkills();
625
- const result = await installDecryptedSkills();
626
- if (result.installed > 0) {
627
- console.log(`\n ✅ ${result.installed} skill${result.installed !== 1 ? 's' : ''} updated`);
628
- }
629
- if (result.errors.length > 0) {
630
- console.log(` ⚠️ ${result.errors.length} error${result.errors.length !== 1 ? 's' : ''}: ${result.errors.join('; ')}`);
762
+ const result = await installSkillStubs();
763
+ if (result.installed > 0)
764
+ console.log(` ✅ ${result.installed} stub${result.installed !== 1 ? 's' : ''} updated`);
765
+ console.log('');
766
+ process.exit(0);
767
+ }
768
+ if (syncFlag) {
769
+ const config = loadConfig();
770
+ if (!config) {
771
+ console.log('🔐 Not set up. Run: npx skillvault --invite YOUR_CODE\n');
772
+ process.exit(1);
631
773
  }
774
+ console.log('🔐 SkillVault Sync\n');
775
+ await syncSkills();
776
+ const result = await installSkillStubs();
777
+ console.log(`\n ✅ ${result.installed} installed, ${result.skipped} up to date`);
632
778
  console.log('');
633
779
  process.exit(0);
634
780
  }
635
- // Default: sync + install (replaces old MCP server start)
781
+ // Default: start MCP server
636
782
  const config = loadConfig();
637
783
  if (!config) {
638
784
  console.log('🔐 SkillVault\n');
@@ -640,25 +786,7 @@ async function main() {
640
786
  console.log(' npx skillvault --invite YOUR_CODE\n');
641
787
  process.exit(1);
642
788
  }
643
- console.log('🔐 SkillVault Sync\n');
644
- const syncResult = await syncSkills();
645
- const installResult = await installDecryptedSkills();
646
- const total = installResult.installed + installResult.skipped;
647
- if (installResult.installed > 0) {
648
- console.log(`\n ✅ ${installResult.installed} skill${installResult.installed !== 1 ? 's' : ''} installed, ${installResult.skipped} up to date`);
649
- }
650
- else if (total > 0) {
651
- console.log(`\n ✅ All ${total} skill${total !== 1 ? 's' : ''} up to date`);
652
- }
653
- else {
654
- console.log('\n No skills found. Your publishers may not have assigned any skills yet.');
655
- }
656
- if (syncResult.errors.length > 0 || installResult.errors.length > 0) {
657
- const allErrors = [...syncResult.errors, ...installResult.errors];
658
- console.log(` ⚠️ ${allErrors.length} error${allErrors.length !== 1 ? 's' : ''}: ${allErrors.join('; ')}`);
659
- }
660
- console.log('');
661
- process.exit(0);
789
+ startServer();
662
790
  }
663
791
  main().catch((err) => {
664
792
  console.error('Fatal:', err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillvault",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "SkillVault — secure skill distribution for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {