skillvault 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +10 -8
- package/dist/cli.js +507 -213
- package/package.json +3 -3
package/dist/cli.d.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* SkillVault Agent —
|
|
3
|
+
* SkillVault Agent — Secure skill distribution for Claude Code.
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* npx skillvault --invite CODE #
|
|
7
|
-
* npx skillvault
|
|
8
|
-
*
|
|
6
|
+
* npx skillvault --invite CODE # Setup / add publisher
|
|
7
|
+
* npx skillvault --status # Show publishers & skills
|
|
8
|
+
* npx skillvault --refresh # Re-authenticate tokens + sync
|
|
9
|
+
* npx skillvault --sync # Sync & install skills
|
|
10
|
+
* npx skillvault # Same as --sync
|
|
9
11
|
*
|
|
10
12
|
* What it does:
|
|
11
|
-
* 1. Redeems invite code (if provided)
|
|
12
|
-
* 2.
|
|
13
|
-
* 3.
|
|
14
|
-
* 4. Claude Code
|
|
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
|
|
15
17
|
*/
|
|
16
18
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,60 +1,83 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* SkillVault Agent —
|
|
3
|
+
* SkillVault Agent — Secure skill distribution for Claude Code.
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* npx skillvault --invite CODE #
|
|
7
|
-
* npx skillvault
|
|
8
|
-
*
|
|
6
|
+
* npx skillvault --invite CODE # Setup / add publisher
|
|
7
|
+
* npx skillvault --status # Show publishers & skills
|
|
8
|
+
* npx skillvault --refresh # Re-authenticate tokens + sync
|
|
9
|
+
* npx skillvault --sync # Sync & install skills
|
|
10
|
+
* npx skillvault # Same as --sync
|
|
9
11
|
*
|
|
10
12
|
* What it does:
|
|
11
|
-
* 1. Redeems invite code (if provided)
|
|
12
|
-
* 2.
|
|
13
|
-
* 3.
|
|
14
|
-
* 4. Claude Code
|
|
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
|
|
15
17
|
*/
|
|
16
|
-
import {
|
|
17
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
18
19
|
import { join } from 'node:path';
|
|
19
|
-
import { execFile } from 'node:child_process';
|
|
20
|
-
import { promisify } from 'node:util';
|
|
21
20
|
import { createDecipheriv, createPublicKey, diffieHellman, hkdfSync, generateKeyPairSync, } from 'node:crypto';
|
|
22
|
-
const
|
|
21
|
+
const VERSION = '0.3.0';
|
|
23
22
|
const HOME = process.env.HOME || process.env.USERPROFILE || '~';
|
|
24
23
|
const API_URL = process.env.SKILLVAULT_API_URL || 'https://api.getskillvault.com';
|
|
25
|
-
const MCP_PORT = parseInt(process.env.SKILLVAULT_MCP_PORT || '9877', 10);
|
|
26
24
|
const CONFIG_DIR = join(HOME, '.skillvault');
|
|
27
25
|
const CONFIG_PATH = join(CONFIG_DIR, 'agent-config.json');
|
|
28
26
|
const VAULT_DIR = join(CONFIG_DIR, 'vaults');
|
|
27
|
+
const SKILLS_DIR = join(HOME, '.claude', 'skills');
|
|
29
28
|
// ── CLI Argument Parsing ──
|
|
30
29
|
const args = process.argv.slice(2);
|
|
31
30
|
const inviteIdx = args.indexOf('--invite');
|
|
32
31
|
const inviteCode = inviteIdx >= 0 ? args[inviteIdx + 1] : null;
|
|
33
32
|
const helpFlag = args.includes('--help') || args.includes('-h');
|
|
34
33
|
const versionFlag = args.includes('--version') || args.includes('-v');
|
|
34
|
+
const statusFlag = args.includes('--status');
|
|
35
|
+
const refreshFlag = args.includes('--refresh');
|
|
36
|
+
const syncFlag = args.includes('--sync');
|
|
35
37
|
if (versionFlag) {
|
|
36
|
-
console.log(
|
|
38
|
+
console.log(`skillvault ${VERSION}`);
|
|
37
39
|
process.exit(0);
|
|
38
40
|
}
|
|
39
41
|
if (helpFlag) {
|
|
40
42
|
console.log(`
|
|
41
|
-
SkillVault
|
|
43
|
+
SkillVault — Secure skill distribution for Claude Code
|
|
42
44
|
|
|
43
45
|
Usage:
|
|
44
|
-
npx skillvault --invite CODE
|
|
45
|
-
npx skillvault
|
|
46
|
+
npx skillvault --invite CODE Setup or add a new publisher
|
|
47
|
+
npx skillvault --status Show all publishers, skills, and statuses
|
|
48
|
+
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
|
|
46
51
|
npx skillvault --help Show this help
|
|
52
|
+
npx skillvault --version Show version
|
|
47
53
|
|
|
48
54
|
Environment:
|
|
49
55
|
SKILLVAULT_API_URL Server URL (default: https://api.getskillvault.com)
|
|
50
|
-
SKILLVAULT_MCP_PORT MCP server port (default: 9877)
|
|
51
56
|
`);
|
|
52
57
|
process.exit(0);
|
|
53
58
|
}
|
|
54
59
|
function loadConfig() {
|
|
55
60
|
try {
|
|
56
61
|
if (existsSync(CONFIG_PATH)) {
|
|
57
|
-
|
|
62
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
63
|
+
// Migrate legacy single-publisher config
|
|
64
|
+
if (raw.token && !raw.publishers) {
|
|
65
|
+
const migrated = {
|
|
66
|
+
customer_token: raw.token,
|
|
67
|
+
customer_email: raw.email || null,
|
|
68
|
+
publishers: [{
|
|
69
|
+
id: raw.publisher_id,
|
|
70
|
+
name: raw.publisher_id,
|
|
71
|
+
token: raw.token,
|
|
72
|
+
added_at: raw.setup_at || new Date().toISOString(),
|
|
73
|
+
}],
|
|
74
|
+
api_url: raw.api_url || API_URL,
|
|
75
|
+
setup_at: raw.setup_at || new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
saveConfig(migrated);
|
|
78
|
+
return migrated;
|
|
79
|
+
}
|
|
80
|
+
return raw;
|
|
58
81
|
}
|
|
59
82
|
}
|
|
60
83
|
catch { }
|
|
@@ -64,7 +87,7 @@ function saveConfig(config) {
|
|
|
64
87
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
65
88
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
66
89
|
}
|
|
67
|
-
// ── Setup: Redeem Invite +
|
|
90
|
+
// ── Setup: Redeem Invite + Sync Skills ──
|
|
68
91
|
async function setup(code) {
|
|
69
92
|
console.log('🔐 SkillVault Setup');
|
|
70
93
|
console.log(` Redeeming invite code: ${code}`);
|
|
@@ -86,43 +109,330 @@ async function setup(code) {
|
|
|
86
109
|
if (data.capabilities.length > 0) {
|
|
87
110
|
console.log(` 📦 Skills: ${data.capabilities.join(', ')}`);
|
|
88
111
|
}
|
|
89
|
-
|
|
90
|
-
|
|
112
|
+
const existingConfig = loadConfig();
|
|
113
|
+
const publisherEntry = {
|
|
114
|
+
id: data.publisher_id,
|
|
115
|
+
name: data.publisher_name || data.publisher_id,
|
|
91
116
|
token: data.token,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
117
|
+
added_at: new Date().toISOString(),
|
|
118
|
+
};
|
|
119
|
+
if (existingConfig) {
|
|
120
|
+
// Additive: merge new publisher into existing config
|
|
121
|
+
const existingIdx = existingConfig.publishers.findIndex(p => p.id === data.publisher_id);
|
|
122
|
+
if (existingIdx >= 0) {
|
|
123
|
+
existingConfig.publishers[existingIdx] = publisherEntry;
|
|
124
|
+
console.log(` 🔄 Updated publisher: ${publisherEntry.name}`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
existingConfig.publishers.push(publisherEntry);
|
|
128
|
+
console.log(` ➕ Added publisher: ${publisherEntry.name}. You now have skills from ${existingConfig.publishers.length} publishers.`);
|
|
129
|
+
}
|
|
130
|
+
if (data.customer_token) {
|
|
131
|
+
existingConfig.customer_token = data.customer_token;
|
|
132
|
+
}
|
|
133
|
+
if (data.email) {
|
|
134
|
+
existingConfig.customer_email = data.email;
|
|
135
|
+
}
|
|
136
|
+
saveConfig(existingConfig);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const config = {
|
|
140
|
+
customer_token: data.customer_token || data.token,
|
|
141
|
+
customer_email: data.email,
|
|
142
|
+
publishers: [publisherEntry],
|
|
143
|
+
api_url: API_URL,
|
|
144
|
+
setup_at: new Date().toISOString(),
|
|
145
|
+
};
|
|
146
|
+
saveConfig(config);
|
|
147
|
+
}
|
|
148
|
+
// Create publisher-scoped vault cache directory
|
|
149
|
+
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
|
+
console.log('');
|
|
154
|
+
console.log(' Syncing skills...');
|
|
155
|
+
const syncResult = await syncSkills();
|
|
156
|
+
const installResult = await installDecryptedSkills();
|
|
157
|
+
console.log('');
|
|
158
|
+
if (installResult.installed > 0) {
|
|
159
|
+
console.log(` ✅ ${installResult.installed} skill${installResult.installed !== 1 ? 's' : ''} installed to ~/.claude/skills/`);
|
|
160
|
+
}
|
|
161
|
+
console.log(' Setup complete! Restart Claude Code to use your new skills.');
|
|
162
|
+
console.log('');
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Remove legacy skillvault MCP server entry from ~/.claude/.mcp.json
|
|
166
|
+
*/
|
|
167
|
+
function cleanupMCPConfig() {
|
|
98
168
|
const mcpConfigPath = join(HOME, '.claude', '.mcp.json');
|
|
99
169
|
try {
|
|
100
|
-
let mcpConfig = {};
|
|
101
170
|
if (existsSync(mcpConfigPath)) {
|
|
102
|
-
mcpConfig = JSON.parse(readFileSync(mcpConfigPath, 'utf8'));
|
|
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
|
+
}
|
|
103
177
|
}
|
|
104
|
-
if (!mcpConfig.mcpServers)
|
|
105
|
-
mcpConfig.mcpServers = {};
|
|
106
|
-
mcpConfig.mcpServers.skillvault = {
|
|
107
|
-
type: 'url',
|
|
108
|
-
url: `http://127.0.0.1:${MCP_PORT}/mcp`,
|
|
109
|
-
};
|
|
110
|
-
mkdirSync(join(HOME, '.claude'), { recursive: true });
|
|
111
|
-
writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), { mode: 0o600 });
|
|
112
|
-
console.log(` ✅ Claude Code MCP configured`);
|
|
113
178
|
}
|
|
114
|
-
catch
|
|
115
|
-
|
|
179
|
+
catch { }
|
|
180
|
+
}
|
|
181
|
+
async function showStatus() {
|
|
182
|
+
const config = loadConfig();
|
|
183
|
+
if (!config) {
|
|
184
|
+
console.log('🔐 SkillVault Agent\n');
|
|
185
|
+
console.log(' Not set up yet. Run:');
|
|
186
|
+
console.log(' npx skillvault --invite YOUR_CODE\n');
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
console.log('🔐 SkillVault Status\n');
|
|
190
|
+
if (config.customer_email) {
|
|
191
|
+
console.log(` Account: ${config.customer_email}`);
|
|
116
192
|
}
|
|
117
|
-
|
|
118
|
-
|
|
193
|
+
console.log(` Config: ${CONFIG_PATH}`);
|
|
194
|
+
console.log(` Skills: ${SKILLS_DIR}`);
|
|
195
|
+
console.log(` Server: ${config.api_url}`);
|
|
119
196
|
console.log('');
|
|
120
|
-
|
|
121
|
-
|
|
197
|
+
// Try fetching live data from the server
|
|
198
|
+
let skills = [];
|
|
199
|
+
let online = false;
|
|
200
|
+
try {
|
|
201
|
+
const token = config.customer_token || (config.publishers.length > 0 ? config.publishers[0].token : null);
|
|
202
|
+
if (token) {
|
|
203
|
+
const res = await fetch(`${config.api_url}/customer/skills`, {
|
|
204
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
205
|
+
signal: AbortSignal.timeout(5000),
|
|
206
|
+
});
|
|
207
|
+
if (res.ok) {
|
|
208
|
+
const data = await res.json();
|
|
209
|
+
skills = data.skills || [];
|
|
210
|
+
online = true;
|
|
211
|
+
}
|
|
212
|
+
else if (res.status === 401) {
|
|
213
|
+
console.log(' ⚠️ Session expired. Run: npx skillvault --refresh\n');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Offline — fall back to local data
|
|
219
|
+
}
|
|
220
|
+
// Build publisher table
|
|
221
|
+
console.log(' Publishers:');
|
|
222
|
+
console.log(' ' + '-'.repeat(60));
|
|
223
|
+
console.log(` ${'Name'.padEnd(25)} ${'Skills'.padEnd(10)} ${'Status'.padEnd(15)}`);
|
|
224
|
+
console.log(' ' + '-'.repeat(60));
|
|
225
|
+
let totalSkills = 0;
|
|
226
|
+
for (const pub of config.publishers) {
|
|
227
|
+
const pubSkills = skills.filter(s => s.publisher_id === pub.id);
|
|
228
|
+
const skillCount = pubSkills.length;
|
|
229
|
+
// Check for local vaults as fallback
|
|
230
|
+
let localVaultCount = 0;
|
|
231
|
+
const pubVaultDir = join(VAULT_DIR, pub.id);
|
|
232
|
+
if (existsSync(pubVaultDir)) {
|
|
233
|
+
try {
|
|
234
|
+
localVaultCount = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault')).length;
|
|
235
|
+
}
|
|
236
|
+
catch { }
|
|
237
|
+
}
|
|
238
|
+
const displayCount = online ? skillCount : localVaultCount;
|
|
239
|
+
totalSkills += displayCount;
|
|
240
|
+
const status = online ? 'connected' : 'offline (cached)';
|
|
241
|
+
console.log(` ${pub.name.padEnd(25)} ${String(displayCount).padEnd(10)} ${status.padEnd(15)}`);
|
|
242
|
+
}
|
|
243
|
+
console.log(' ' + '-'.repeat(60));
|
|
244
|
+
// Show skill details if online
|
|
245
|
+
if (online && skills.length > 0) {
|
|
246
|
+
console.log('\n Skills:');
|
|
247
|
+
console.log(' ' + '-'.repeat(70));
|
|
248
|
+
console.log(` ${'Skill'.padEnd(25)} ${'Publisher'.padEnd(20)} ${'Status'.padEnd(12)} ${'Expires'.padEnd(12)}`);
|
|
249
|
+
console.log(' ' + '-'.repeat(70));
|
|
250
|
+
for (const skill of skills) {
|
|
251
|
+
const pubName = config.publishers.find(p => p.id === skill.publisher_id)?.name || skill.publisher_name || skill.publisher_id;
|
|
252
|
+
const expires = skill.expires_at ? new Date(skill.expires_at).toLocaleDateString() : 'never';
|
|
253
|
+
console.log(` ${skill.skill_name.padEnd(25)} ${pubName.padEnd(20)} ${skill.status.padEnd(12)} ${expires.padEnd(12)}`);
|
|
254
|
+
}
|
|
255
|
+
console.log(' ' + '-'.repeat(70));
|
|
256
|
+
}
|
|
257
|
+
const publisherCount = config.publishers.length;
|
|
258
|
+
const allActive = skills.length > 0 && skills.every(s => s.status === 'active');
|
|
259
|
+
const statusSuffix = online ? (allActive ? ', all active' : '') : ' (offline)';
|
|
260
|
+
console.log(`\n Total: ${totalSkills} skill${totalSkills !== 1 ? 's' : ''} from ${publisherCount} publisher${publisherCount !== 1 ? 's' : ''}${statusSuffix}`);
|
|
122
261
|
console.log('');
|
|
123
262
|
}
|
|
263
|
+
// ── Refresh ──
|
|
264
|
+
async function refreshTokens() {
|
|
265
|
+
const config = loadConfig();
|
|
266
|
+
if (!config) {
|
|
267
|
+
console.log('🔐 SkillVault Agent\n');
|
|
268
|
+
console.log(' Not set up yet. Run:');
|
|
269
|
+
console.log(' npx skillvault --invite YOUR_CODE\n');
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
console.log('🔐 SkillVault Token Refresh\n');
|
|
273
|
+
let anyRefreshed = false;
|
|
274
|
+
for (const pub of config.publishers) {
|
|
275
|
+
process.stdout.write(` Refreshing ${pub.name}... `);
|
|
276
|
+
try {
|
|
277
|
+
const res = await fetch(`${config.api_url}/auth/companion/refresh`, {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: {
|
|
280
|
+
'Content-Type': 'application/json',
|
|
281
|
+
'Authorization': `Bearer ${pub.token}`,
|
|
282
|
+
},
|
|
283
|
+
signal: AbortSignal.timeout(10000),
|
|
284
|
+
});
|
|
285
|
+
if (res.ok) {
|
|
286
|
+
const data = await res.json();
|
|
287
|
+
pub.token = data.token;
|
|
288
|
+
if (data.customer_token) {
|
|
289
|
+
config.customer_token = data.customer_token;
|
|
290
|
+
}
|
|
291
|
+
console.log('✅ refreshed');
|
|
292
|
+
anyRefreshed = true;
|
|
293
|
+
}
|
|
294
|
+
else if (res.status === 401) {
|
|
295
|
+
console.log('❌ expired — re-invite required');
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
console.log(`❌ server error (${res.status})`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
console.log('❌ offline');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (anyRefreshed) {
|
|
306
|
+
saveConfig(config);
|
|
307
|
+
console.log('\n Tokens updated.\n');
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
console.log('\n No tokens refreshed. You may need to run: npx skillvault --invite CODE\n');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function syncSkills() {
|
|
314
|
+
const config = loadConfig();
|
|
315
|
+
if (!config || config.publishers.length === 0) {
|
|
316
|
+
return { synced: 0, errors: ['No config or publishers found'] };
|
|
317
|
+
}
|
|
318
|
+
let totalSynced = 0;
|
|
319
|
+
const errors = [];
|
|
320
|
+
for (const pub of config.publishers) {
|
|
321
|
+
const pubVaultDir = join(VAULT_DIR, pub.id);
|
|
322
|
+
mkdirSync(pubVaultDir, { recursive: true });
|
|
323
|
+
// Fetch skill list for this publisher
|
|
324
|
+
let skills = [];
|
|
325
|
+
try {
|
|
326
|
+
const res = await fetch(`${config.api_url}/skills?publisher_id=${encodeURIComponent(pub.id)}`, {
|
|
327
|
+
headers: { 'Authorization': `Bearer ${pub.token}` },
|
|
328
|
+
signal: AbortSignal.timeout(10000),
|
|
329
|
+
});
|
|
330
|
+
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
|
+
}
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const data = await res.json();
|
|
340
|
+
skills = data.skills || [];
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
errors.push(`${pub.name}: ${err instanceof Error ? err.message : 'fetch failed'}`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// Build set of remote skill names for revocation check
|
|
347
|
+
const remoteSkillNames = new Set(skills.map(s => s.skill_name));
|
|
348
|
+
// Check for revoked grants — remove decrypted skills
|
|
349
|
+
try {
|
|
350
|
+
if (existsSync(pubVaultDir)) {
|
|
351
|
+
const localVaults = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault'));
|
|
352
|
+
for (const vaultFile of localVaults) {
|
|
353
|
+
const localSkillName = vaultFile.replace(/\.vault$/, '');
|
|
354
|
+
if (!remoteSkillNames.has(localSkillName)) {
|
|
355
|
+
console.log(`[sync] Grant revoked: "${localSkillName}" from ${pub.name}`);
|
|
356
|
+
// Remove decrypted skill from Claude Code skills directory
|
|
357
|
+
const skillDir = join(SKILLS_DIR, localSkillName);
|
|
358
|
+
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
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch { }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
catch { }
|
|
374
|
+
// Download missing or updated vaults
|
|
375
|
+
let pubSynced = 0;
|
|
376
|
+
for (const skill of skills) {
|
|
377
|
+
const vaultPath = join(pubVaultDir, `${skill.skill_name}.vault`);
|
|
378
|
+
const vaultExists = existsSync(vaultPath);
|
|
379
|
+
// Check if we need to download: missing vault, or new version available
|
|
380
|
+
let needsDownload = !vaultExists;
|
|
381
|
+
if (vaultExists && skill.vault_hash) {
|
|
382
|
+
const hashPath = vaultPath + '.hash';
|
|
383
|
+
try {
|
|
384
|
+
if (existsSync(hashPath)) {
|
|
385
|
+
const localHash = readFileSync(hashPath, 'utf8').trim();
|
|
386
|
+
if (localHash !== skill.vault_hash) {
|
|
387
|
+
needsDownload = true;
|
|
388
|
+
console.log(`[sync] Update available: "${skill.skill_name}" from ${pub.name}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
catch { }
|
|
393
|
+
}
|
|
394
|
+
if (!needsDownload)
|
|
395
|
+
continue;
|
|
396
|
+
// Download vault
|
|
397
|
+
const capabilityName = skill.capability_name || skill.skill_name;
|
|
398
|
+
try {
|
|
399
|
+
let dlRes = await fetch(`${config.api_url}/skills/download?capability=${encodeURIComponent(capabilityName)}`, { signal: AbortSignal.timeout(15000) });
|
|
400
|
+
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
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (!dlRes.ok) {
|
|
407
|
+
errors.push(`${pub.name}/${skill.skill_name}: download failed (${dlRes.status})`);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const dlData = await dlRes.json();
|
|
411
|
+
const vaultBuffer = Buffer.from(dlData.vault_data, 'base64');
|
|
412
|
+
writeFileSync(vaultPath, vaultBuffer, { mode: 0o600 });
|
|
413
|
+
if (dlData.vault_hash) {
|
|
414
|
+
writeFileSync(vaultPath + '.hash', dlData.vault_hash, { mode: 0o600 });
|
|
415
|
+
}
|
|
416
|
+
pubSynced++;
|
|
417
|
+
const action = vaultExists ? 'Updated' : 'Downloaded';
|
|
418
|
+
console.log(`[sync] ${action}: "${skill.skill_name}" from ${pub.name}`);
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
errors.push(`${pub.name}/${skill.skill_name}: ${err instanceof Error ? err.message : 'download error'}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (pubSynced > 0) {
|
|
425
|
+
console.log(`[sync] Synced ${pubSynced} vault${pubSynced !== 1 ? 's' : ''} from ${pub.name}`);
|
|
426
|
+
}
|
|
427
|
+
totalSynced += pubSynced;
|
|
428
|
+
}
|
|
429
|
+
return { synced: totalSynced, errors };
|
|
430
|
+
}
|
|
124
431
|
// ── Vault Decryption ──
|
|
125
432
|
const VAULT_MAGIC = Buffer.from([0x00, 0x53, 0x56, 0x54]);
|
|
433
|
+
/**
|
|
434
|
+
* Decrypt a vault and return all files with their full content (frontmatter preserved).
|
|
435
|
+
*/
|
|
126
436
|
function decryptVault(data, cek) {
|
|
127
437
|
let offset = 0;
|
|
128
438
|
const magic = data.subarray(offset, (offset += 4));
|
|
@@ -148,21 +458,36 @@ function decryptVault(data, cek) {
|
|
|
148
458
|
dec.setAuthTag(authTag);
|
|
149
459
|
dec.setAAD(metadataJSON);
|
|
150
460
|
const payload = Buffer.concat([dec.update(encPayload), dec.final()]);
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return
|
|
461
|
+
const metadata = JSON.parse(metadataJSON.toString('utf8'));
|
|
462
|
+
const files = manifest.map(entry => ({
|
|
463
|
+
path: entry.path,
|
|
464
|
+
content: payload.subarray(entry.offset, entry.offset + entry.size).toString('utf8'),
|
|
465
|
+
}));
|
|
466
|
+
return { metadata, files };
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Resolve which publisher owns a skill by searching vault directories.
|
|
470
|
+
*/
|
|
471
|
+
function resolveSkillPublisher(skillName, config) {
|
|
472
|
+
for (const pub of config.publishers) {
|
|
473
|
+
const vaultPath = join(VAULT_DIR, pub.id, `${skillName}.vault`);
|
|
474
|
+
if (existsSync(vaultPath)) {
|
|
475
|
+
return { publisher: pub, vaultPath };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const legacyPath = join(VAULT_DIR, `${skillName}.vault`);
|
|
479
|
+
if (existsSync(legacyPath) && config.publishers.length > 0) {
|
|
480
|
+
return { publisher: config.publishers[0], vaultPath: legacyPath };
|
|
481
|
+
}
|
|
482
|
+
return null;
|
|
157
483
|
}
|
|
158
|
-
async function fetchCEK(skillName) {
|
|
484
|
+
async function fetchCEK(skillName, publisherToken) {
|
|
159
485
|
const kp = generateKeyPairSync('x25519');
|
|
160
486
|
const pub = kp.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
487
|
+
const headers = {
|
|
488
|
+
'Content-Type': 'application/json',
|
|
489
|
+
'Authorization': `Bearer ${publisherToken}`,
|
|
490
|
+
};
|
|
166
491
|
const res = await fetch(`${API_URL}/v1/skills/${skillName}/cek`, {
|
|
167
492
|
method: 'POST',
|
|
168
493
|
headers,
|
|
@@ -190,181 +515,150 @@ function watermark(content, id) {
|
|
|
190
515
|
const mark = BigInt('0x' + hex).toString(4).split('').map(d => zw[d]).join('');
|
|
191
516
|
return content.split('\n').map((l, i) => (i > 0 && i % 5 === 0 ? l + mark : l)).join('\n');
|
|
192
517
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (existsSync(p))
|
|
198
|
-
return p;
|
|
199
|
-
try {
|
|
200
|
-
const { execSync } = require('node:child_process');
|
|
201
|
-
return execSync('which claude', { encoding: 'utf8', timeout: 3000 }).trim() || null;
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
// ── Input Validation ──
|
|
208
|
-
function validateSkillName(name) {
|
|
209
|
-
return /^[a-zA-Z0-9_-]+$/.test(name) && name.length > 0 && name.length <= 128;
|
|
210
|
-
}
|
|
211
|
-
async function executeSkill(skillName, request) {
|
|
212
|
-
// CRITICAL: Sanitize skill_name to prevent path traversal and URL injection
|
|
213
|
-
if (!validateSkillName(skillName)) {
|
|
214
|
-
return { success: false, output: '', error: 'Invalid skill name. Only letters, numbers, hyphens, and underscores allowed.' };
|
|
215
|
-
}
|
|
518
|
+
/**
|
|
519
|
+
* Decrypt all vaults and write them as native Claude Code skills to ~/.claude/skills/.
|
|
520
|
+
*/
|
|
521
|
+
async function installDecryptedSkills() {
|
|
216
522
|
const config = loadConfig();
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const vaultPath = join(VAULT_DIR, `${skillName}.vault`);
|
|
220
|
-
if (!existsSync(vaultPath)) {
|
|
221
|
-
return { success: false, output: '', error: `Vault not found for "${skillName}". The skill may not be installed.` };
|
|
222
|
-
}
|
|
223
|
-
// Fetch + unwrap CEK
|
|
224
|
-
let cek;
|
|
225
|
-
try {
|
|
226
|
-
cek = await fetchCEK(skillName);
|
|
227
|
-
}
|
|
228
|
-
catch (err) {
|
|
229
|
-
return { success: false, output: '', error: `License check failed: ${err instanceof Error ? err.message : 'unknown'}` };
|
|
523
|
+
if (!config || config.publishers.length === 0) {
|
|
524
|
+
return { installed: 0, skipped: 0, errors: ['No config or publishers found'] };
|
|
230
525
|
}
|
|
231
|
-
|
|
232
|
-
let
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
function rpcOk(id, result) { return JSON.stringify({ jsonrpc: '2.0', id, result }); }
|
|
259
|
-
function rpcErr(id, code, msg) { return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message: msg } }); }
|
|
260
|
-
async function handleRPC(req) {
|
|
261
|
-
switch (req.method) {
|
|
262
|
-
case 'initialize':
|
|
263
|
-
return rpcOk(req.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'skillvault', version: '0.1.0' } });
|
|
264
|
-
case 'tools/list':
|
|
265
|
-
return rpcOk(req.id, {
|
|
266
|
-
tools: [{
|
|
267
|
-
name: 'skillvault_execute',
|
|
268
|
-
description: 'Execute a SkillVault protected skill securely. The skill is decrypted and executed in an isolated process — instructions are never visible to the user.',
|
|
269
|
-
inputSchema: {
|
|
270
|
-
type: 'object',
|
|
271
|
-
properties: {
|
|
272
|
-
skill_name: { type: 'string', description: 'Name of the skill to execute' },
|
|
273
|
-
request: { type: 'string', description: 'What to accomplish with this skill' },
|
|
274
|
-
},
|
|
275
|
-
required: ['skill_name', 'request'],
|
|
276
|
-
},
|
|
277
|
-
}],
|
|
278
|
-
});
|
|
279
|
-
case 'tools/call': {
|
|
280
|
-
const name = req.params?.name;
|
|
281
|
-
const { skill_name, request } = req.params?.arguments || {};
|
|
282
|
-
if (name !== 'skillvault_execute')
|
|
283
|
-
return rpcErr(req.id, -32601, `Unknown tool: ${name}`);
|
|
284
|
-
if (!skill_name || !request)
|
|
285
|
-
return rpcErr(req.id, -32602, 'skill_name and request required');
|
|
286
|
-
console.log(`[MCP] Executing: ${skill_name}`);
|
|
287
|
-
const result = await executeSkill(skill_name, request);
|
|
288
|
-
return rpcOk(req.id, { content: [{ type: 'text', text: result.success ? result.output : `Error: ${result.error}` }], isError: !result.success });
|
|
289
|
-
}
|
|
290
|
-
case 'notifications/initialized': return '';
|
|
291
|
-
default: return rpcErr(req.id || 0, -32601, `Unknown method: ${req.method}`);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
function startServer() {
|
|
295
|
-
const server = createServer(async (req, res) => {
|
|
296
|
-
// No CORS headers — MCP server is local-only, no browser access allowed
|
|
297
|
-
if (req.method === 'OPTIONS') {
|
|
298
|
-
res.writeHead(403);
|
|
299
|
-
res.end();
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
if (req.method !== 'POST' || req.url !== '/mcp') {
|
|
303
|
-
res.writeHead(404);
|
|
304
|
-
res.end(JSON.stringify({ error: 'POST /mcp' }));
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
const chunks = [];
|
|
308
|
-
let size = 0;
|
|
309
|
-
const MAX_BODY = 1024 * 1024; // 1MB limit
|
|
310
|
-
req.on('data', (c) => {
|
|
311
|
-
size += c.length;
|
|
312
|
-
if (size > MAX_BODY) {
|
|
313
|
-
req.destroy();
|
|
314
|
-
return;
|
|
526
|
+
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 { }
|
|
315
553
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
res.writeHead(413);
|
|
321
|
-
res.end('Request too large');
|
|
322
|
-
return;
|
|
554
|
+
// Fetch CEK (validates license)
|
|
555
|
+
let cek;
|
|
556
|
+
try {
|
|
557
|
+
cek = await fetchCEK(skillName, pub.token);
|
|
323
558
|
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
errors.push(`${pub.name}/${skillName}: license check failed — ${err instanceof Error ? err.message : 'unknown'}`);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
// Decrypt vault
|
|
564
|
+
let vault;
|
|
324
565
|
try {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
if (result) {
|
|
328
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
329
|
-
res.end(result);
|
|
330
|
-
}
|
|
331
|
-
else {
|
|
332
|
-
res.writeHead(204);
|
|
333
|
-
res.end();
|
|
334
|
-
}
|
|
566
|
+
const vaultData = readFileSync(vaultPath);
|
|
567
|
+
vault = decryptVault(vaultData, cek);
|
|
335
568
|
}
|
|
336
|
-
catch {
|
|
337
|
-
|
|
338
|
-
|
|
569
|
+
catch (err) {
|
|
570
|
+
errors.push(`${pub.name}/${skillName}: decryption failed — ${err instanceof Error ? err.message : 'unknown'}`);
|
|
571
|
+
continue;
|
|
339
572
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
573
|
+
finally {
|
|
574
|
+
cek.fill(0);
|
|
575
|
+
}
|
|
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 });
|
|
587
|
+
}
|
|
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}/`);
|
|
351
604
|
}
|
|
352
|
-
|
|
353
|
-
}
|
|
605
|
+
}
|
|
606
|
+
return { installed, skipped, errors };
|
|
354
607
|
}
|
|
355
608
|
// ── Main ──
|
|
356
609
|
async function main() {
|
|
357
610
|
if (inviteCode) {
|
|
358
611
|
await setup(inviteCode);
|
|
612
|
+
if (!statusFlag && !refreshFlag) {
|
|
613
|
+
process.exit(0);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (statusFlag) {
|
|
617
|
+
await showStatus();
|
|
618
|
+
process.exit(0);
|
|
619
|
+
}
|
|
620
|
+
if (refreshFlag) {
|
|
621
|
+
await refreshTokens();
|
|
622
|
+
// Also sync after refresh
|
|
623
|
+
console.log(' Syncing skills...\n');
|
|
624
|
+
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('; ')}`);
|
|
631
|
+
}
|
|
632
|
+
console.log('');
|
|
633
|
+
process.exit(0);
|
|
359
634
|
}
|
|
635
|
+
// Default: sync + install (replaces old MCP server start)
|
|
360
636
|
const config = loadConfig();
|
|
361
637
|
if (!config) {
|
|
362
|
-
console.log('🔐 SkillVault
|
|
638
|
+
console.log('🔐 SkillVault\n');
|
|
363
639
|
console.log(' Not set up yet. Run:');
|
|
364
640
|
console.log(' npx skillvault --invite YOUR_CODE\n');
|
|
365
641
|
process.exit(1);
|
|
366
642
|
}
|
|
367
|
-
|
|
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);
|
|
368
662
|
}
|
|
369
663
|
main().catch((err) => {
|
|
370
664
|
console.error('Fatal:', err);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillvault",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "SkillVault
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "SkillVault — secure skill distribution for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"skillvault": "dist/cli.js"
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
],
|
|
16
16
|
"keywords": [
|
|
17
17
|
"skillvault",
|
|
18
|
-
"mcp",
|
|
19
18
|
"claude",
|
|
20
19
|
"claude-code",
|
|
21
20
|
"ai-skills",
|
|
21
|
+
"skill-distribution",
|
|
22
22
|
"encrypted-skills"
|
|
23
23
|
],
|
|
24
24
|
"license": "MIT"
|