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.
- package/dist/cli.d.ts +10 -7
- package/dist/cli.js +338 -210
- 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
|
|
10
|
-
* npx skillvault #
|
|
9
|
+
* npx skillvault --sync # Sync vaults + install stubs
|
|
10
|
+
* npx skillvault # Start MCP server (after setup)
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
* 1. Redeems invite code
|
|
14
|
-
* 2. Downloads encrypted skill vaults
|
|
15
|
-
* 3.
|
|
16
|
-
* 4.
|
|
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
|
|
10
|
-
* npx skillvault #
|
|
9
|
+
* npx skillvault --sync # Sync vaults + install stubs
|
|
10
|
+
* npx skillvault # Start MCP server (after setup)
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
* 1. Redeems invite code
|
|
14
|
-
* 2. Downloads encrypted skill vaults
|
|
15
|
-
* 3.
|
|
16
|
-
* 4.
|
|
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.
|
|
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
|
|
50
|
-
npx skillvault
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
151
|
-
|
|
152
|
-
// Sync and install
|
|
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
|
-
|
|
156
|
-
const installResult = await
|
|
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' : ''}
|
|
162
|
+
console.log(` ✅ ${installResult.installed} skill${installResult.installed !== 1 ? 's' : ''} ready`);
|
|
160
163
|
}
|
|
161
|
-
console.log(' Setup complete!
|
|
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
|
-
*
|
|
169
|
+
* Configure Claude Code to connect to the SkillVault MCP server.
|
|
166
170
|
*/
|
|
167
|
-
function
|
|
171
|
+
function configureMCP() {
|
|
168
172
|
const mcpConfigPath = join(HOME, '.claude', '.mcp.json');
|
|
169
173
|
try {
|
|
174
|
+
let mcpConfig = {};
|
|
170
175
|
if (existsSync(mcpConfigPath)) {
|
|
171
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
524
|
-
return {
|
|
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
|
-
|
|
528
|
-
let
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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
|
|
626
|
-
if (result.installed > 0)
|
|
627
|
-
console.log(
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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:
|
|
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
|
-
|
|
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);
|