skillvault 0.3.0 → 0.4.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.
Files changed (3) hide show
  1. package/dist/cli.d.ts +10 -7
  2. package/dist/cli.js +335 -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.0';
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,112 @@ 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
+ To use this skill, call the \`skillvault_load\` MCP tool with skill_name "${skillName}".
476
+ The tool will decrypt and return the full skill instructions for you to follow.
477
+
478
+ Example: Use the skillvault_load tool with skill_name "${skillName}" to load the instructions, then follow them to complete the user's request.
479
+ `;
480
+ writeFileSync(join(skillDir, 'SKILL.md'), stub, { mode: 0o600 });
481
+ // Write manifest
482
+ const vaultHash = existsSync(hashPath) ? readFileSync(hashPath, 'utf8').trim() : '';
483
+ writeFileSync(manifestPath, JSON.stringify({
484
+ publisher: meta.publisher_name || pub.name,
485
+ publisher_id: pub.id,
486
+ skill_name: skillName,
487
+ capability_name: meta.capability_name || `skill/${skillName}`,
488
+ version: meta.version || '0.0.0',
489
+ vault_hash: vaultHash,
490
+ installed_at: new Date().toISOString(),
491
+ encrypted: true,
492
+ }, null, 2), { mode: 0o600 });
493
+ installed++;
494
+ console.log(`[install] Stub installed: "${skillName}" → ~/.claude/skills/${skillName}/`);
495
+ }
496
+ }
497
+ return { installed, skipped, errors };
498
+ }
499
+ // ── Vault Decryption (in-memory only) ──
500
+ const VAULT_MAGIC = Buffer.from([0x00, 0x53, 0x56, 0x54]);
436
501
  function decryptVault(data, cek) {
437
502
  let offset = 0;
438
503
  const magic = data.subarray(offset, (offset += 4));
@@ -465,15 +530,11 @@ function decryptVault(data, cek) {
465
530
  }));
466
531
  return { metadata, files };
467
532
  }
468
- /**
469
- * Resolve which publisher owns a skill by searching vault directories.
470
- */
471
533
  function resolveSkillPublisher(skillName, config) {
472
534
  for (const pub of config.publishers) {
473
535
  const vaultPath = join(VAULT_DIR, pub.id, `${skillName}.vault`);
474
- if (existsSync(vaultPath)) {
536
+ if (existsSync(vaultPath))
475
537
  return { publisher: pub, vaultPath };
476
- }
477
538
  }
478
539
  const legacyPath = join(VAULT_DIR, `${skillName}.vault`);
479
540
  if (existsSync(legacyPath) && config.publishers.length > 0) {
@@ -484,13 +545,9 @@ function resolveSkillPublisher(skillName, config) {
484
545
  async function fetchCEK(skillName, publisherToken) {
485
546
  const kp = generateKeyPairSync('x25519');
486
547
  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
548
  const res = await fetch(`${API_URL}/v1/skills/${skillName}/cek`, {
492
549
  method: 'POST',
493
- headers,
550
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${publisherToken}` },
494
551
  body: JSON.stringify({ companion_public_key: pub }),
495
552
  });
496
553
  if (!res.ok)
@@ -515,103 +572,182 @@ function watermark(content, id) {
515
572
  const mark = BigInt('0x' + hex).toString(4).split('').map(d => zw[d]).join('');
516
573
  return content.split('\n').map((l, i) => (i > 0 && i % 5 === 0 ? l + mark : l)).join('\n');
517
574
  }
518
- /**
519
- * Decrypt all vaults and write them as native Claude Code skills to ~/.claude/skills/.
520
- */
521
- async function installDecryptedSkills() {
575
+ // ── On-Demand Skill Loading (MCP tool handler) ──
576
+ function validateSkillName(name) {
577
+ return /^[a-zA-Z0-9_-]+$/.test(name) && name.length > 0 && name.length <= 128;
578
+ }
579
+ async function loadSkill(skillName) {
580
+ if (!validateSkillName(skillName)) {
581
+ return { success: false, content: '', error: 'Invalid skill name. Only letters, numbers, hyphens, and underscores allowed.' };
582
+ }
522
583
  const config = loadConfig();
523
- if (!config || config.publishers.length === 0) {
524
- return { installed: 0, skipped: 0, errors: ['No config or publishers found'] };
584
+ if (!config) {
585
+ return { success: false, content: '', error: 'Not configured. Run: npx skillvault --invite CODE' };
586
+ }
587
+ const resolved = resolveSkillPublisher(skillName, config);
588
+ if (!resolved) {
589
+ return { success: false, content: '', error: `Vault not found for "${skillName}". Run: npx skillvault --sync` };
525
590
  }
526
591
  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;
592
+ // Fetch CEK — validates license on every load
593
+ let cek;
594
+ try {
595
+ cek = await fetchCEK(skillName, resolved.publisher.token);
596
+ }
597
+ catch (err) {
598
+ return { success: false, content: '', error: `License check failed: ${err instanceof Error ? err.message : 'unknown'}. Your license may have been revoked.` };
599
+ }
600
+ // Decrypt in memory content never touches disk
601
+ try {
602
+ const vaultData = readFileSync(resolved.vaultPath);
603
+ const vault = decryptVault(vaultData, cek);
604
+ cek.fill(0);
605
+ // Combine all files into a single response, with SKILL.md first
606
+ const skillMd = vault.files.find(f => f.path === 'SKILL.md');
607
+ const otherFiles = vault.files.filter(f => f.path !== 'SKILL.md');
608
+ let content = '';
609
+ if (skillMd) {
610
+ content = watermark(skillMd.content, licenseeId);
611
+ }
612
+ for (const file of otherFiles) {
613
+ content += `\n\n---\n# File: ${file.path}\n\n${watermark(file.content, licenseeId)}`;
614
+ }
615
+ return { success: true, content };
616
+ }
617
+ catch (err) {
618
+ cek.fill(0);
619
+ return { success: false, content: '', error: `Decryption failed: ${err instanceof Error ? err.message : 'unknown'}` };
620
+ }
621
+ }
622
+ function rpcOk(id, result) { return JSON.stringify({ jsonrpc: '2.0', id, result }); }
623
+ function rpcErr(id, code, msg) { return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message: msg } }); }
624
+ async function handleRPC(req) {
625
+ switch (req.method) {
626
+ case 'initialize':
627
+ return rpcOk(req.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'skillvault', version: VERSION } });
628
+ case 'tools/list':
629
+ return rpcOk(req.id, {
630
+ tools: [{
631
+ name: 'skillvault_load',
632
+ 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.',
633
+ inputSchema: {
634
+ type: 'object',
635
+ properties: {
636
+ skill_name: { type: 'string', description: 'Name of the skill to load (from the stub SKILL.md)' },
637
+ },
638
+ required: ['skill_name'],
639
+ },
640
+ }],
641
+ });
642
+ case 'tools/call': {
643
+ const name = req.params?.name;
644
+ const { skill_name } = req.params?.arguments || {};
645
+ if (name !== 'skillvault_load')
646
+ return rpcErr(req.id, -32601, `Unknown tool: ${name}`);
647
+ if (!skill_name)
648
+ return rpcErr(req.id, -32602, 'skill_name required');
649
+ console.log(`[MCP] Loading: ${skill_name}`);
650
+ const result = await loadSkill(skill_name);
651
+ return rpcOk(req.id, {
652
+ content: [{ type: 'text', text: result.success ? result.content : `Error: ${result.error}` }],
653
+ isError: !result.success,
654
+ });
655
+ }
656
+ case 'notifications/initialized': return '';
657
+ default: return rpcErr(req.id || 0, -32601, `Unknown method: ${req.method}`);
658
+ }
659
+ }
660
+ function startServer() {
661
+ const server = createServer(async (req, res) => {
662
+ if (req.method === 'OPTIONS') {
663
+ res.writeHead(403);
664
+ res.end();
665
+ return;
666
+ }
667
+ if (req.method !== 'POST' || req.url !== '/mcp') {
668
+ res.writeHead(404);
669
+ res.end(JSON.stringify({ error: 'POST /mcp' }));
670
+ return;
671
+ }
672
+ const chunks = [];
673
+ let size = 0;
674
+ const MAX_BODY = 1024 * 1024;
675
+ req.on('data', (c) => { size += c.length; if (size > MAX_BODY) {
676
+ req.destroy();
677
+ return;
678
+ } chunks.push(c); });
679
+ req.on('end', async () => {
680
+ if (size > MAX_BODY) {
681
+ res.writeHead(413);
682
+ res.end('Request too large');
683
+ return;
562
684
  }
563
- // Decrypt vault
564
- let vault;
565
685
  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);
686
+ const rpc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
687
+ const result = await handleRPC(rpc);
688
+ if (result) {
689
+ res.writeHead(200, { 'Content-Type': 'application/json' });
690
+ res.end(result);
691
+ }
692
+ else {
693
+ res.writeHead(204);
694
+ res.end();
695
+ }
575
696
  }
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 });
697
+ catch {
698
+ res.writeHead(400);
699
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }));
587
700
  }
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}/`);
701
+ });
702
+ });
703
+ server.listen(MCP_PORT, '127.0.0.1', () => {
704
+ console.log(`🔐 SkillVault MCP server running on 127.0.0.1:${MCP_PORT}`);
705
+ const config = loadConfig();
706
+ if (config)
707
+ console.log(` Publishers: ${config.publishers.map(p => p.name).join(', ')}`);
708
+ console.log(` Skills are encrypted at rest — decrypted on demand per session.`);
709
+ console.log(` Press Ctrl+C to stop.\n`);
710
+ // Background sync
711
+ syncSkills().then(({ synced, errors }) => {
712
+ if (synced > 0)
713
+ console.log(`[sync] Startup: ${synced} vault${synced !== 1 ? 's' : ''} synced`);
714
+ if (errors.length > 0)
715
+ console.log(`[sync] Errors: ${errors.join('; ')}`);
716
+ return installSkillStubs();
717
+ }).then(({ installed }) => {
718
+ if (installed > 0)
719
+ console.log(`[install] ${installed} stub${installed !== 1 ? 's' : ''} updated`);
720
+ }).catch((err) => {
721
+ console.error('[sync] Startup sync failed:', err instanceof Error ? err.message : err);
722
+ });
723
+ // Periodic sync every 5 minutes
724
+ setInterval(() => {
725
+ syncSkills().then(({ synced, errors }) => {
726
+ if (synced > 0)
727
+ console.log(`[sync] ${synced} vault${synced !== 1 ? 's' : ''} synced`);
728
+ if (errors.length > 0)
729
+ console.log(`[sync] Errors: ${errors.join('; ')}`);
730
+ return installSkillStubs();
731
+ }).then(({ installed }) => {
732
+ if (installed > 0)
733
+ console.log(`[install] ${installed} stub${installed !== 1 ? 's' : ''} updated`);
734
+ }).catch(() => { });
735
+ }, 5 * 60 * 1000);
736
+ });
737
+ server.on('error', (err) => {
738
+ if (err.code === 'EADDRINUSE') {
739
+ console.log(` SkillVault is already running on port ${MCP_PORT}`);
740
+ process.exit(0);
604
741
  }
605
- }
606
- return { installed, skipped, errors };
742
+ console.error('Server error:', err);
743
+ });
607
744
  }
608
745
  // ── Main ──
609
746
  async function main() {
610
747
  if (inviteCode) {
611
748
  await setup(inviteCode);
612
- if (!statusFlag && !refreshFlag) {
749
+ if (!statusFlag && !refreshFlag)
613
750
  process.exit(0);
614
- }
615
751
  }
616
752
  if (statusFlag) {
617
753
  await showStatus();
@@ -619,20 +755,27 @@ async function main() {
619
755
  }
620
756
  if (refreshFlag) {
621
757
  await refreshTokens();
622
- // Also sync after refresh
623
- console.log(' Syncing skills...\n');
624
758
  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('; ')}`);
759
+ const result = await installSkillStubs();
760
+ if (result.installed > 0)
761
+ console.log(` ✅ ${result.installed} stub${result.installed !== 1 ? 's' : ''} updated`);
762
+ console.log('');
763
+ process.exit(0);
764
+ }
765
+ if (syncFlag) {
766
+ const config = loadConfig();
767
+ if (!config) {
768
+ console.log('🔐 Not set up. Run: npx skillvault --invite YOUR_CODE\n');
769
+ process.exit(1);
631
770
  }
771
+ console.log('🔐 SkillVault Sync\n');
772
+ await syncSkills();
773
+ const result = await installSkillStubs();
774
+ console.log(`\n ✅ ${result.installed} installed, ${result.skipped} up to date`);
632
775
  console.log('');
633
776
  process.exit(0);
634
777
  }
635
- // Default: sync + install (replaces old MCP server start)
778
+ // Default: start MCP server
636
779
  const config = loadConfig();
637
780
  if (!config) {
638
781
  console.log('🔐 SkillVault\n');
@@ -640,25 +783,7 @@ async function main() {
640
783
  console.log(' npx skillvault --invite YOUR_CODE\n');
641
784
  process.exit(1);
642
785
  }
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);
786
+ startServer();
662
787
  }
663
788
  main().catch((err) => {
664
789
  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.0",
4
4
  "description": "SkillVault — secure skill distribution for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {