skillvault 0.9.2 → 0.11.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.js +1161 -262
- package/dist/credentials.js +108 -0
- package/dist/projects-registry.js +111 -0
- package/dist/prompts.js +193 -0
- package/dist/scope.js +99 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -17,15 +17,19 @@
|
|
|
17
17
|
* 5. Claude reads the output and follows the instructions
|
|
18
18
|
* 6. Decrypted content is never written to disk
|
|
19
19
|
*/
|
|
20
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
21
|
-
import { join } from 'node:path';
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, cpSync } from 'node:fs';
|
|
21
|
+
import { dirname, join, resolve as pathResolve } from 'node:path';
|
|
22
22
|
import { createDecipheriv, createHmac, createPublicKey, diffieHellman, hkdfSync, generateKeyPairSync, } from 'node:crypto';
|
|
23
|
-
|
|
23
|
+
import { resolveScope, resolveRoots, } from './scope.js';
|
|
24
|
+
import { loadCredentials, saveCredentials, loadProjectConfig, saveProjectConfig, } from './credentials.js';
|
|
25
|
+
import { registerProject, unregisterProject, getCurrentProject, listProjects, } from './projects-registry.js';
|
|
26
|
+
import { confirmInstall } from './prompts.js';
|
|
27
|
+
const VERSION = '0.11.0';
|
|
24
28
|
const HOME = process.env.HOME || process.env.USERPROFILE || '~';
|
|
25
29
|
const API_URL = process.env.SKILLVAULT_API_URL || 'https://api.getskillvault.com';
|
|
26
30
|
const CONFIG_DIR = join(HOME, '.skillvault');
|
|
27
|
-
|
|
28
|
-
const
|
|
31
|
+
/** Legacy config path. Kept for backward-compat read-only fallback in loadConfig(). */
|
|
32
|
+
const LEGACY_CONFIG_PATH = join(CONFIG_DIR, 'agent-config.json');
|
|
29
33
|
const AGENTS_SKILLS_DIR = join(HOME, '.agents', 'skills'); // agent-agnostic source of truth
|
|
30
34
|
const AGENTS_LOCK_PATH = join(HOME, '.agents', '.skill-lock.json');
|
|
31
35
|
/** Detect which AI agent platforms are installed and return their skill directories */
|
|
@@ -44,8 +48,43 @@ function detectAgentPlatforms() {
|
|
|
44
48
|
}
|
|
45
49
|
return platforms;
|
|
46
50
|
}
|
|
47
|
-
//
|
|
48
|
-
|
|
51
|
+
// ── Active install roots ──
|
|
52
|
+
//
|
|
53
|
+
// SkillVault supports project-scoped and global installs. The active roots
|
|
54
|
+
// are resolved once at startup based on flags + CWD + the projects registry.
|
|
55
|
+
// Functions that read or write per-install state (vaults, hooks, stubs)
|
|
56
|
+
// pull from currentRoots so they can be redirected by the entry point.
|
|
57
|
+
let currentRoots = resolveRoots('global');
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the active install roots from explicit flags, the projects
|
|
60
|
+
* registry, and existing-install detection.
|
|
61
|
+
*
|
|
62
|
+
* Precedence:
|
|
63
|
+
* 1. --global / --project flag
|
|
64
|
+
* 2. CWD matches a registered project → project scope
|
|
65
|
+
* 3. ~/.skillvault/credentials.json or agent-config.json exists → global
|
|
66
|
+
* 4. resolveScope() default (project unless CWD is HOME or /)
|
|
67
|
+
*/
|
|
68
|
+
function resolveActiveRoots(opts = {}) {
|
|
69
|
+
if (opts.global)
|
|
70
|
+
return resolveRoots('global');
|
|
71
|
+
if (opts.project)
|
|
72
|
+
return resolveRoots('project', process.cwd());
|
|
73
|
+
const registered = getCurrentProject(process.cwd());
|
|
74
|
+
if (registered)
|
|
75
|
+
return resolveRoots('project', registered.path);
|
|
76
|
+
if (existsSync(join(CONFIG_DIR, 'credentials.json')) || existsSync(LEGACY_CONFIG_PATH)) {
|
|
77
|
+
return resolveRoots('global');
|
|
78
|
+
}
|
|
79
|
+
const detected = resolveScope({ cwd: process.cwd() });
|
|
80
|
+
return resolveRoots(detected, process.cwd());
|
|
81
|
+
}
|
|
82
|
+
function setActiveRoots(roots) {
|
|
83
|
+
currentRoots = roots;
|
|
84
|
+
}
|
|
85
|
+
function getActiveRoots() {
|
|
86
|
+
return currentRoots;
|
|
87
|
+
}
|
|
49
88
|
// ── CLI Argument Parsing ──
|
|
50
89
|
const args = process.argv.slice(2);
|
|
51
90
|
const inviteIdx = args.indexOf('--invite');
|
|
@@ -73,6 +112,43 @@ const eventType = eventTypeIdx >= 0 ? args[eventTypeIdx + 1] : null;
|
|
|
73
112
|
const sessionCleanupFlag = args.includes('--session-cleanup');
|
|
74
113
|
const scanOutputFlag = args.includes('--scan-output');
|
|
75
114
|
const checkSessionFlag = args.includes('--check-session');
|
|
115
|
+
const globalFlag = args.includes('--global');
|
|
116
|
+
const projectFlag = args.includes('--project');
|
|
117
|
+
const dryRunFlag = args.includes('--dry-run');
|
|
118
|
+
const allPublishersFlag = args.includes('--all');
|
|
119
|
+
/** --all is shared between `add-publisher --all` and `uninstall --all`; alias for clarity. */
|
|
120
|
+
const allFlag = allPublishersFlag;
|
|
121
|
+
/** Collect all `--publisher <id>` occurrences from argv. */
|
|
122
|
+
function collectPublisherFlags() {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (let i = 0; i < args.length; i++) {
|
|
125
|
+
if (args[i] === '--publisher' && i + 1 < args.length) {
|
|
126
|
+
out.push(args[i + 1]);
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
const publisherFlagIds = collectPublisherFlags();
|
|
133
|
+
/**
|
|
134
|
+
* Subcommand parsing. SkillVault CLI mixes legacy --invite/--load/--sync flags
|
|
135
|
+
* with positional subcommands like `npx skillvault publishers`. The first
|
|
136
|
+
* non-flag arg is treated as the subcommand name; remaining positional args
|
|
137
|
+
* become its arguments.
|
|
138
|
+
*/
|
|
139
|
+
const positional = args.filter((a, i) => {
|
|
140
|
+
if (a.startsWith('--') || a.startsWith('-'))
|
|
141
|
+
return false;
|
|
142
|
+
// Skip values that are arguments to known --flag <value> pairs.
|
|
143
|
+
const prev = args[i - 1];
|
|
144
|
+
if (prev === '--invite' || prev === '--load' || prev === '--file' ||
|
|
145
|
+
prev === '--list-files' || prev === '--report' || prev === '--skill' ||
|
|
146
|
+
prev === '--detail' || prev === '--event-type' || prev === '--publisher')
|
|
147
|
+
return false;
|
|
148
|
+
return true;
|
|
149
|
+
});
|
|
150
|
+
const subcommand = positional[0] || null;
|
|
151
|
+
const subcommandArgs = positional.slice(1);
|
|
76
152
|
if (versionFlag) {
|
|
77
153
|
console.log(`skillvault ${VERSION}`);
|
|
78
154
|
process.exit(0);
|
|
@@ -83,6 +159,9 @@ if (helpFlag) {
|
|
|
83
159
|
|
|
84
160
|
Usage:
|
|
85
161
|
npx skillvault --invite CODE Setup: redeem invite, sync, install skills
|
|
162
|
+
--global Force install into ~/.claude, ~/.skillvault
|
|
163
|
+
--project Force install into ./.claude, ./.skillvault
|
|
164
|
+
--dry-run Print planned writes without applying
|
|
86
165
|
npx skillvault --load SKILL Decrypt and output a skill (used by Claude)
|
|
87
166
|
npx skillvault --status Show publishers, skills, and statuses
|
|
88
167
|
npx skillvault --refresh Re-authenticate tokens + sync skills
|
|
@@ -96,9 +175,16 @@ if (helpFlag) {
|
|
|
96
175
|
npx skillvault --help Show this help
|
|
97
176
|
npx skillvault --version Show version
|
|
98
177
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
178
|
+
Install scopes:
|
|
179
|
+
- "project" install (default in any normal CWD): files live under
|
|
180
|
+
./.claude/ and ./.skillvault/. Removed by deleting the project folder.
|
|
181
|
+
- "global" install (default at HOME or /, or with --global): files live
|
|
182
|
+
under ~/.claude/ and ~/.skillvault/. Affects every Claude Code session.
|
|
183
|
+
- Customer credentials always live at ~/.skillvault/credentials.json.
|
|
184
|
+
|
|
185
|
+
Skills are encrypted at rest in <install>/.skillvault/vaults/. When Claude
|
|
186
|
+
Code triggers a skill, it runs \`npx skillvault --load <name>\` to decrypt
|
|
187
|
+
on demand. The decrypted content goes to stdout — never written to disk.
|
|
102
188
|
License is validated on every load.
|
|
103
189
|
|
|
104
190
|
Environment:
|
|
@@ -106,39 +192,93 @@ if (helpFlag) {
|
|
|
106
192
|
`);
|
|
107
193
|
process.exit(0);
|
|
108
194
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Read both credentials.json and the active install's project.json and
|
|
197
|
+
* project them into the legacy AgentConfig view.
|
|
198
|
+
*
|
|
199
|
+
* loadCredentials() handles the one-time migration from the legacy
|
|
200
|
+
* ~/.skillvault/agent-config.json (v0.9.x format), so by the time we get
|
|
201
|
+
* here we either have new-format credentials or null.
|
|
202
|
+
*/
|
|
203
|
+
function loadConfig(roots = getActiveRoots()) {
|
|
204
|
+
const credentials = loadCredentials();
|
|
205
|
+
if (!credentials)
|
|
206
|
+
return null;
|
|
207
|
+
const projectConfig = loadProjectConfig(roots);
|
|
208
|
+
// In project mode, filter publishers down to the active list. In global
|
|
209
|
+
// mode (or when no project.json exists), include every publisher.
|
|
210
|
+
const visiblePublishers = projectConfig
|
|
211
|
+
? credentials.publishers.filter(p => projectConfig.active_publishers.includes(p.id))
|
|
212
|
+
: credentials.publishers;
|
|
213
|
+
return {
|
|
214
|
+
customer_token: credentials.customer_token,
|
|
215
|
+
customer_email: credentials.customer_email,
|
|
216
|
+
publishers: visiblePublishers,
|
|
217
|
+
api_url: credentials.api_url || API_URL,
|
|
218
|
+
setup_at: credentials.created_at,
|
|
219
|
+
__credentials: credentials,
|
|
220
|
+
__projectConfig: projectConfig,
|
|
221
|
+
};
|
|
134
222
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
223
|
+
/**
|
|
224
|
+
* Persist an AgentConfig back to disk in the new split format. The
|
|
225
|
+
* underlying credentials and project-config snapshots are reconstructed
|
|
226
|
+
* from the AgentConfig object so callers don't need to manage them.
|
|
227
|
+
*
|
|
228
|
+
* If the AgentConfig has __credentials/__projectConfig metadata attached,
|
|
229
|
+
* those are used as the base; otherwise we synthesize fresh ones.
|
|
230
|
+
*/
|
|
231
|
+
function saveConfig(config, roots = getActiveRoots()) {
|
|
232
|
+
const baseCredentials = config.__credentials ?? {
|
|
233
|
+
customer_token: config.customer_token,
|
|
234
|
+
customer_email: config.customer_email,
|
|
235
|
+
publishers: [],
|
|
236
|
+
api_url: config.api_url || API_URL,
|
|
237
|
+
created_at: config.setup_at || new Date().toISOString(),
|
|
238
|
+
};
|
|
239
|
+
// Merge: ensure every publisher in `config.publishers` exists in credentials,
|
|
240
|
+
// and update tokens/customer fields from the in-memory state.
|
|
241
|
+
const merged = new Map();
|
|
242
|
+
for (const p of baseCredentials.publishers)
|
|
243
|
+
merged.set(p.id, p);
|
|
244
|
+
for (const p of config.publishers)
|
|
245
|
+
merged.set(p.id, p);
|
|
246
|
+
const newCredentials = {
|
|
247
|
+
...baseCredentials,
|
|
248
|
+
customer_token: config.customer_token,
|
|
249
|
+
customer_email: config.customer_email,
|
|
250
|
+
publishers: [...merged.values()],
|
|
251
|
+
api_url: config.api_url || baseCredentials.api_url || API_URL,
|
|
252
|
+
created_at: baseCredentials.created_at || config.setup_at || new Date().toISOString(),
|
|
253
|
+
};
|
|
254
|
+
saveCredentials(newCredentials);
|
|
255
|
+
// Project config: write only the active publishers (the visible set).
|
|
256
|
+
const baseProject = config.__projectConfig ?? {
|
|
257
|
+
active_publishers: config.publishers.map(p => p.id),
|
|
258
|
+
installed_at: config.setup_at || new Date().toISOString(),
|
|
259
|
+
scope: roots.scope,
|
|
260
|
+
install_path: roots.scope === 'project' ? process.cwd() : HOME,
|
|
261
|
+
};
|
|
262
|
+
const newProject = {
|
|
263
|
+
...baseProject,
|
|
264
|
+
active_publishers: config.publishers.map(p => p.id),
|
|
265
|
+
scope: roots.scope,
|
|
266
|
+
install_path: baseProject.install_path,
|
|
267
|
+
};
|
|
268
|
+
saveProjectConfig(roots, newProject);
|
|
138
269
|
}
|
|
139
270
|
// ── Setup: Redeem Invite + Sync + Install ──
|
|
140
|
-
|
|
271
|
+
/**
|
|
272
|
+
* Redeem an invite code and install skills into the resolved install roots.
|
|
273
|
+
*
|
|
274
|
+
* Credentials always go to ~/.skillvault/credentials.json (global). Project
|
|
275
|
+
* config (active publishers, install metadata) goes to <roots.configDir>/project.json.
|
|
276
|
+
* Vaults and skill stubs go to roots.vaultDir and roots.claudeDir respectively.
|
|
277
|
+
*/
|
|
278
|
+
async function setup(code, roots) {
|
|
141
279
|
console.error('🔐 SkillVault Setup');
|
|
280
|
+
console.error(` Scope: ${roots.scope === 'project' ? 'project (this directory)' : 'global (~/.claude, ~/.skillvault)'}`);
|
|
281
|
+
console.error(` Location: ${roots.scope === 'project' ? process.cwd() : HOME}`);
|
|
142
282
|
console.error(` Redeeming invite code: ${code}`);
|
|
143
283
|
const response = await fetch(`${API_URL}/auth/companion/token`, {
|
|
144
284
|
method: 'POST',
|
|
@@ -171,53 +311,116 @@ async function setup(code) {
|
|
|
171
311
|
if (data.capabilities.length > 0) {
|
|
172
312
|
console.error(` 📦 Skills: ${data.capabilities.join(', ')}`);
|
|
173
313
|
}
|
|
174
|
-
|
|
314
|
+
// Update credentials.json — always global, full publisher list across all
|
|
315
|
+
// projects. The new publisher is added or its token is refreshed in place.
|
|
316
|
+
const existingCredentials = loadCredentials() ?? {
|
|
317
|
+
customer_token: data.customer_token || data.token,
|
|
318
|
+
customer_email: data.email,
|
|
319
|
+
publishers: [],
|
|
320
|
+
api_url: API_URL,
|
|
321
|
+
created_at: new Date().toISOString(),
|
|
322
|
+
};
|
|
175
323
|
const publisherEntry = {
|
|
176
324
|
id: data.publisher_id,
|
|
177
325
|
name: data.publisher_name || data.publisher_id,
|
|
178
326
|
token: data.token,
|
|
179
327
|
added_at: new Date().toISOString(),
|
|
180
328
|
};
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
329
|
+
const wasNewPublisher = !existingCredentials.publishers.some(p => p.id === data.publisher_id);
|
|
330
|
+
const updatedPublishers = existingCredentials.publishers.filter(p => p.id !== data.publisher_id);
|
|
331
|
+
updatedPublishers.push(publisherEntry);
|
|
332
|
+
if (wasNewPublisher) {
|
|
333
|
+
console.error(` ➕ Added publisher: ${publisherEntry.name}`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
console.error(` 🔄 Updated publisher: ${publisherEntry.name}`);
|
|
337
|
+
}
|
|
338
|
+
const newCredentials = {
|
|
339
|
+
customer_token: data.customer_token || existingCredentials.customer_token || data.token,
|
|
340
|
+
customer_email: data.email || existingCredentials.customer_email,
|
|
341
|
+
publishers: updatedPublishers,
|
|
342
|
+
api_url: existingCredentials.api_url || API_URL,
|
|
343
|
+
created_at: existingCredentials.created_at,
|
|
344
|
+
};
|
|
345
|
+
saveCredentials(newCredentials);
|
|
346
|
+
// Compute the new active list for THIS install. The default is:
|
|
347
|
+
// existing project active list (if any) + the publisher just invited
|
|
348
|
+
// overridden by --publisher flags or --all if provided.
|
|
349
|
+
const existingProject = loadProjectConfig(roots);
|
|
350
|
+
const previousActive = existingProject?.active_publishers ?? [];
|
|
351
|
+
let newActive;
|
|
352
|
+
if (allPublishersFlag) {
|
|
353
|
+
newActive = updatedPublishers.map(p => p.id);
|
|
354
|
+
}
|
|
355
|
+
else if (publisherFlagIds.length > 0) {
|
|
356
|
+
// Validate that every requested publisher is known.
|
|
357
|
+
const unknown = publisherFlagIds.filter(id => !updatedPublishers.some(p => p.id === id));
|
|
358
|
+
if (unknown.length > 0) {
|
|
359
|
+
console.error(` ⚠️ Unknown publisher${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`);
|
|
190
360
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
361
|
+
newActive = publisherFlagIds.filter(id => updatedPublishers.some(p => p.id === id));
|
|
362
|
+
if (!newActive.includes(data.publisher_id))
|
|
363
|
+
newActive.push(data.publisher_id);
|
|
364
|
+
}
|
|
365
|
+
else if (existingProject) {
|
|
366
|
+
// Keep existing active list and add the new publisher.
|
|
367
|
+
newActive = [...previousActive];
|
|
368
|
+
if (!newActive.includes(data.publisher_id))
|
|
369
|
+
newActive.push(data.publisher_id);
|
|
196
370
|
}
|
|
197
371
|
else {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
372
|
+
// Fresh install — only the publisher just invited is active.
|
|
373
|
+
newActive = [data.publisher_id];
|
|
374
|
+
}
|
|
375
|
+
if (existingProject && previousActive.length > 0) {
|
|
376
|
+
const newlyActivated = newActive.filter(id => !previousActive.includes(id));
|
|
377
|
+
if (newlyActivated.length > 0) {
|
|
378
|
+
console.error(` ✅ Activated in this install: ${newlyActivated.join(', ')}`);
|
|
379
|
+
}
|
|
380
|
+
// Hint about other publishers the customer has access to but isn't using here.
|
|
381
|
+
const inactive = updatedPublishers.filter(p => !newActive.includes(p.id));
|
|
382
|
+
if (inactive.length > 0) {
|
|
383
|
+
console.error(` ℹ️ Inactive in this install: ${inactive.map(p => p.name).join(', ')}`);
|
|
384
|
+
console.error(` Run \`npx skillvault add-publisher <id>\` to enable them here.`);
|
|
385
|
+
}
|
|
205
386
|
}
|
|
206
|
-
|
|
387
|
+
// Persist project.json with the new active list.
|
|
388
|
+
const newProject = {
|
|
389
|
+
active_publishers: newActive,
|
|
390
|
+
installed_at: existingProject?.installed_at || new Date().toISOString(),
|
|
391
|
+
scope: roots.scope,
|
|
392
|
+
install_path: existingProject?.install_path || (roots.scope === 'project' ? process.cwd() : HOME),
|
|
393
|
+
last_synced_at: new Date().toISOString(),
|
|
394
|
+
};
|
|
395
|
+
saveProjectConfig(roots, newProject);
|
|
396
|
+
mkdirSync(join(roots.vaultDir, data.publisher_id), { recursive: true });
|
|
207
397
|
// Clean up legacy MCP config if present
|
|
208
398
|
cleanupMCPConfig();
|
|
209
399
|
// Install session hook for auto-sync
|
|
210
|
-
configureSessionHook();
|
|
400
|
+
configureSessionHook(roots);
|
|
211
401
|
// Sync vaults and install stubs
|
|
212
402
|
console.error('');
|
|
213
403
|
console.error(' Syncing skills...');
|
|
214
|
-
await syncSkills();
|
|
215
|
-
const installResult = await installSkillStubs();
|
|
404
|
+
await syncSkills(roots);
|
|
405
|
+
const installResult = await installSkillStubs(roots);
|
|
406
|
+
// Register the install in ~/.skillvault/projects.json (project mode only)
|
|
407
|
+
if (roots.scope === 'project') {
|
|
408
|
+
const proj = loadProjectConfig(roots);
|
|
409
|
+
registerProject({
|
|
410
|
+
path: pathResolve(process.cwd()),
|
|
411
|
+
installed_at: proj?.installed_at || new Date().toISOString(),
|
|
412
|
+
last_synced_at: new Date().toISOString(),
|
|
413
|
+
active_publishers: proj?.active_publishers || [data.publisher_id],
|
|
414
|
+
});
|
|
415
|
+
}
|
|
216
416
|
console.error('');
|
|
217
417
|
if (installResult.installed > 0) {
|
|
218
|
-
|
|
418
|
+
const stubLocation = roots.scope === 'project'
|
|
419
|
+
? `${roots.claudeDir}/skills/`
|
|
420
|
+
: '~/.claude/skills/';
|
|
421
|
+
console.error(` ✅ ${installResult.installed} skill${installResult.installed !== 1 ? 's' : ''} installed to ${stubLocation}`);
|
|
219
422
|
}
|
|
220
|
-
console.error(
|
|
423
|
+
console.error(` ✅ Setup complete! Restart Claude Code to use your skills.`);
|
|
221
424
|
console.error(' Skills will auto-sync at the start of each Claude Code session.');
|
|
222
425
|
console.error('');
|
|
223
426
|
}
|
|
@@ -257,8 +460,8 @@ function hasSkillvaultCommand(entry) {
|
|
|
257
460
|
}
|
|
258
461
|
return false;
|
|
259
462
|
}
|
|
260
|
-
function configureSessionHook() {
|
|
261
|
-
const settingsPath =
|
|
463
|
+
function configureSessionHook(roots) {
|
|
464
|
+
const settingsPath = roots.settingsPath;
|
|
262
465
|
try {
|
|
263
466
|
let settings = {};
|
|
264
467
|
if (existsSync(settingsPath)) {
|
|
@@ -270,33 +473,26 @@ function configureSessionHook() {
|
|
|
270
473
|
settings.hooks.SessionStart = [];
|
|
271
474
|
if (!settings.hooks.SessionEnd)
|
|
272
475
|
settings.hooks.SessionEnd = [];
|
|
273
|
-
// ── SessionStart: auto-sync (permanent) ──
|
|
274
|
-
// Remove
|
|
275
|
-
settings.hooks.SessionStart = settings.hooks.SessionStart.filter((h) => !(h
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
settings.hooks.SessionEnd.push({
|
|
294
|
-
hooks: [{
|
|
295
|
-
type: 'command',
|
|
296
|
-
command: `npx -y skillvault@${VERSION} --session-cleanup`,
|
|
297
|
-
}],
|
|
298
|
-
});
|
|
299
|
-
}
|
|
476
|
+
// ── SessionStart: auto-sync (permanent, unpinned so it always gets latest) ──
|
|
477
|
+
// Remove all existing skillvault SessionStart hooks and re-add with current format
|
|
478
|
+
settings.hooks.SessionStart = settings.hooks.SessionStart.filter((h) => !hasSkillvaultCommand(h));
|
|
479
|
+
settings.hooks.SessionStart.push({
|
|
480
|
+
matcher: 'startup',
|
|
481
|
+
hooks: [{
|
|
482
|
+
type: 'command',
|
|
483
|
+
command: 'npx -y skillvault --sync',
|
|
484
|
+
timeout: 30,
|
|
485
|
+
}],
|
|
486
|
+
});
|
|
487
|
+
// ── SessionEnd: cleanup (permanent, pinned to current version) ──
|
|
488
|
+
// Remove all existing skillvault SessionEnd hooks and re-add with current version
|
|
489
|
+
settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter((h) => !hasSkillvaultCommand(h));
|
|
490
|
+
settings.hooks.SessionEnd.push({
|
|
491
|
+
hooks: [{
|
|
492
|
+
type: 'command',
|
|
493
|
+
command: `npx -y skillvault@${VERSION} --session-cleanup`,
|
|
494
|
+
}],
|
|
495
|
+
});
|
|
300
496
|
// ── Remove legacy PostToolCall + PreToolCall hooks (invalid event names) ──
|
|
301
497
|
let removedLegacy = false;
|
|
302
498
|
for (const legacyKey of ['PostToolCall', 'PreToolCall']) {
|
|
@@ -320,12 +516,9 @@ function configureSessionHook() {
|
|
|
320
516
|
delete settings.hooks[key];
|
|
321
517
|
}
|
|
322
518
|
}
|
|
323
|
-
mkdirSync(
|
|
519
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
324
520
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
325
|
-
|
|
326
|
-
console.error(' ✅ Auto-sync hook installed');
|
|
327
|
-
if (!hasEndHook)
|
|
328
|
-
console.error(' ✅ Session cleanup hook installed');
|
|
521
|
+
console.error(` ✅ Hooks updated (${settingsPath})`);
|
|
329
522
|
if (removedLegacy)
|
|
330
523
|
console.error(' 🧹 Removed legacy monitoring hooks (now session-scoped)');
|
|
331
524
|
}
|
|
@@ -334,7 +527,7 @@ function configureSessionHook() {
|
|
|
334
527
|
}
|
|
335
528
|
}
|
|
336
529
|
/**
|
|
337
|
-
* Inject session-scoped monitoring hooks into
|
|
530
|
+
* Inject session-scoped monitoring hooks into the active install's settings.json.
|
|
338
531
|
* Called after successful --load to enable extraction detection for this session.
|
|
339
532
|
*
|
|
340
533
|
* Creates:
|
|
@@ -342,11 +535,13 @@ function configureSessionHook() {
|
|
|
342
535
|
* - PostToolUse: n-gram fingerprint matching on tool output
|
|
343
536
|
* - Stop: n-gram fingerprint matching on Claude's response
|
|
344
537
|
*
|
|
345
|
-
* Also creates
|
|
538
|
+
* Also creates <roots.configDir>/active-session.json with session metadata
|
|
539
|
+
* including the scope and install_path so the session-keepalive and
|
|
540
|
+
* session-cleanup hooks can locate the right install when they fire.
|
|
346
541
|
*/
|
|
347
|
-
function injectMonitoringHooks(skillName) {
|
|
348
|
-
const settingsPath =
|
|
349
|
-
const activeSessionPath = join(
|
|
542
|
+
function injectMonitoringHooks(skillName, roots = getActiveRoots()) {
|
|
543
|
+
const settingsPath = roots.settingsPath;
|
|
544
|
+
const activeSessionPath = join(roots.configDir, 'active-session.json');
|
|
350
545
|
try {
|
|
351
546
|
let settings = {};
|
|
352
547
|
if (existsSync(settingsPath)) {
|
|
@@ -361,8 +556,11 @@ function injectMonitoringHooks(skillName) {
|
|
|
361
556
|
skill: skillName,
|
|
362
557
|
loaded_at: now.toISOString(),
|
|
363
558
|
expires_at: expiresAt.toISOString(),
|
|
559
|
+
scope: roots.scope,
|
|
560
|
+
install_path: roots.scope === 'project' ? process.cwd() : HOME,
|
|
561
|
+
settings_path: settingsPath,
|
|
364
562
|
};
|
|
365
|
-
mkdirSync(
|
|
563
|
+
mkdirSync(roots.configDir, { recursive: true });
|
|
366
564
|
writeFileSync(activeSessionPath, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
367
565
|
// ── Remove old-format monitoring hooks (command on entry, no hooks array) ──
|
|
368
566
|
for (const key of ['UserPromptSubmit', 'PostToolUse', 'Stop']) {
|
|
@@ -378,43 +576,31 @@ function injectMonitoringHooks(skillName) {
|
|
|
378
576
|
delete settings.hooks[legacyKey];
|
|
379
577
|
}
|
|
380
578
|
}
|
|
381
|
-
// ──
|
|
382
|
-
|
|
383
|
-
settings.hooks
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
settings.hooks.UserPromptSubmit.push({
|
|
387
|
-
hooks: [{
|
|
388
|
-
type: 'command',
|
|
389
|
-
command: `npx -y skillvault@${VERSION} --session-keepalive --event-type prompt-submit`,
|
|
390
|
-
}],
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
// ── PostToolUse hook ──
|
|
394
|
-
if (!Array.isArray(settings.hooks.PostToolUse))
|
|
395
|
-
settings.hooks.PostToolUse = [];
|
|
396
|
-
const hasToolHook = settings.hooks.PostToolUse.some((g) => g.hooks?.some((h) => h.command?.includes('skillvault') && h.command?.includes('session-keepalive')));
|
|
397
|
-
if (!hasToolHook) {
|
|
398
|
-
settings.hooks.PostToolUse.push({
|
|
399
|
-
matcher: 'Write|Bash|Edit',
|
|
400
|
-
hooks: [{
|
|
401
|
-
type: 'command',
|
|
402
|
-
command: `npx -y skillvault@${VERSION} --session-keepalive --event-type tool-use`,
|
|
403
|
-
}],
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
// ── Stop hook ──
|
|
407
|
-
if (!Array.isArray(settings.hooks.Stop))
|
|
408
|
-
settings.hooks.Stop = [];
|
|
409
|
-
const hasStopHook = settings.hooks.Stop.some((g) => g.hooks?.some((h) => h.command?.includes('skillvault') && h.command?.includes('session-keepalive')));
|
|
410
|
-
if (!hasStopHook) {
|
|
411
|
-
settings.hooks.Stop.push({
|
|
412
|
-
hooks: [{
|
|
413
|
-
type: 'command',
|
|
414
|
-
command: `npx -y skillvault@${VERSION} --session-keepalive --event-type stop`,
|
|
415
|
-
}],
|
|
416
|
-
});
|
|
579
|
+
// ── Monitoring hooks: remove existing and re-add with current version ──
|
|
580
|
+
for (const key of ['UserPromptSubmit', 'PostToolUse', 'Stop']) {
|
|
581
|
+
if (!Array.isArray(settings.hooks[key]))
|
|
582
|
+
settings.hooks[key] = [];
|
|
583
|
+
settings.hooks[key] = settings.hooks[key].filter((h) => !hasSkillvaultCommand(h));
|
|
417
584
|
}
|
|
585
|
+
settings.hooks.UserPromptSubmit.push({
|
|
586
|
+
hooks: [{
|
|
587
|
+
type: 'command',
|
|
588
|
+
command: `npx -y skillvault@${VERSION} --session-keepalive --event-type prompt-submit`,
|
|
589
|
+
}],
|
|
590
|
+
});
|
|
591
|
+
settings.hooks.PostToolUse.push({
|
|
592
|
+
matcher: 'Write|Bash|Edit',
|
|
593
|
+
hooks: [{
|
|
594
|
+
type: 'command',
|
|
595
|
+
command: `npx -y skillvault@${VERSION} --session-keepalive --event-type tool-use`,
|
|
596
|
+
}],
|
|
597
|
+
});
|
|
598
|
+
settings.hooks.Stop.push({
|
|
599
|
+
hooks: [{
|
|
600
|
+
type: 'command',
|
|
601
|
+
command: `npx -y skillvault@${VERSION} --session-keepalive --event-type stop`,
|
|
602
|
+
}],
|
|
603
|
+
});
|
|
418
604
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
419
605
|
}
|
|
420
606
|
catch {
|
|
@@ -422,7 +608,8 @@ function injectMonitoringHooks(skillName) {
|
|
|
422
608
|
}
|
|
423
609
|
}
|
|
424
610
|
async function showStatus() {
|
|
425
|
-
const
|
|
611
|
+
const roots = getActiveRoots();
|
|
612
|
+
const config = loadConfig(roots);
|
|
426
613
|
if (!config) {
|
|
427
614
|
console.log('🔐 SkillVault\n');
|
|
428
615
|
console.log(' Not set up yet. Run:');
|
|
@@ -432,8 +619,9 @@ async function showStatus() {
|
|
|
432
619
|
console.log('🔐 SkillVault Status\n');
|
|
433
620
|
if (config.customer_email)
|
|
434
621
|
console.log(` Account: ${config.customer_email}`);
|
|
435
|
-
console.log(`
|
|
436
|
-
console.log(`
|
|
622
|
+
console.log(` Scope: ${roots.scope}`);
|
|
623
|
+
console.log(` Config: ${roots.configDir}`);
|
|
624
|
+
console.log(` Skills: ${join(roots.claudeDir, 'skills')}`);
|
|
437
625
|
console.log(` Server: ${config.api_url}`);
|
|
438
626
|
console.log('');
|
|
439
627
|
let skills = [];
|
|
@@ -473,7 +661,7 @@ async function showStatus() {
|
|
|
473
661
|
for (const pub of config.publishers) {
|
|
474
662
|
const pubSkills = skills.filter(s => s.publisher_id === pub.id);
|
|
475
663
|
let localVaultCount = 0;
|
|
476
|
-
const pubVaultDir = join(
|
|
664
|
+
const pubVaultDir = join(roots.vaultDir, pub.id);
|
|
477
665
|
if (existsSync(pubVaultDir)) {
|
|
478
666
|
try {
|
|
479
667
|
localVaultCount = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault')).length;
|
|
@@ -505,8 +693,8 @@ async function showStatus() {
|
|
|
505
693
|
console.log('');
|
|
506
694
|
}
|
|
507
695
|
// ── Refresh ──
|
|
508
|
-
async function refreshTokens() {
|
|
509
|
-
const config = loadConfig();
|
|
696
|
+
async function refreshTokens(roots = getActiveRoots()) {
|
|
697
|
+
const config = loadConfig(roots);
|
|
510
698
|
if (!config) {
|
|
511
699
|
console.error('🔐 SkillVault\n Not set up. Run: npx skillvault --invite YOUR_CODE\n');
|
|
512
700
|
process.exit(1);
|
|
@@ -541,22 +729,22 @@ async function refreshTokens() {
|
|
|
541
729
|
}
|
|
542
730
|
}
|
|
543
731
|
if (anyRefreshed) {
|
|
544
|
-
saveConfig(config);
|
|
732
|
+
saveConfig(config, roots);
|
|
545
733
|
console.error('\n Tokens updated.\n');
|
|
546
734
|
}
|
|
547
735
|
else {
|
|
548
736
|
console.error('\n No tokens refreshed.\n');
|
|
549
737
|
}
|
|
550
738
|
}
|
|
551
|
-
async function syncSkills() {
|
|
552
|
-
const config = loadConfig();
|
|
739
|
+
async function syncSkills(roots = getActiveRoots()) {
|
|
740
|
+
const config = loadConfig(roots);
|
|
553
741
|
if (!config || config.publishers.length === 0) {
|
|
554
742
|
return { synced: 0, errors: ['No config or publishers found'] };
|
|
555
743
|
}
|
|
556
744
|
let totalSynced = 0;
|
|
557
745
|
const errors = [];
|
|
558
746
|
for (const pub of config.publishers) {
|
|
559
|
-
const pubVaultDir = join(
|
|
747
|
+
const pubVaultDir = join(roots.vaultDir, pub.id);
|
|
560
748
|
mkdirSync(pubVaultDir, { recursive: true });
|
|
561
749
|
let skills = [];
|
|
562
750
|
try {
|
|
@@ -576,6 +764,7 @@ async function syncSkills() {
|
|
|
576
764
|
continue;
|
|
577
765
|
}
|
|
578
766
|
const remoteSkillNames = new Set(skills.map(s => s.skill_name));
|
|
767
|
+
const localStubsDir = join(roots.claudeDir, 'skills');
|
|
579
768
|
// Revocation: remove stubs for skills no longer in remote list
|
|
580
769
|
try {
|
|
581
770
|
if (existsSync(pubVaultDir)) {
|
|
@@ -584,23 +773,32 @@ async function syncSkills() {
|
|
|
584
773
|
const localSkillName = vaultFile.replace(/\.vault$/, '');
|
|
585
774
|
if (!remoteSkillNames.has(localSkillName)) {
|
|
586
775
|
console.error(`[sync] Grant revoked: "${localSkillName}" from ${pub.name}`);
|
|
587
|
-
// Remove from
|
|
588
|
-
const
|
|
776
|
+
// Remove from this install's claude skill dir
|
|
777
|
+
const stubDir = join(localStubsDir, localSkillName);
|
|
589
778
|
try {
|
|
590
|
-
if (existsSync(
|
|
591
|
-
rmSync(
|
|
779
|
+
if (existsSync(stubDir))
|
|
780
|
+
rmSync(stubDir, { recursive: true, force: true });
|
|
592
781
|
}
|
|
593
782
|
catch { }
|
|
594
|
-
//
|
|
595
|
-
|
|
596
|
-
|
|
783
|
+
// For global installs, also clean the agent-agnostic source-of-truth
|
|
784
|
+
// and any detected secondary agent platforms.
|
|
785
|
+
if (roots.scope === 'global') {
|
|
786
|
+
const agentDir = join(AGENTS_SKILLS_DIR, localSkillName);
|
|
597
787
|
try {
|
|
598
|
-
if (existsSync(
|
|
599
|
-
rmSync(
|
|
788
|
+
if (existsSync(agentDir))
|
|
789
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
600
790
|
}
|
|
601
791
|
catch { }
|
|
792
|
+
for (const platform of detectAgentPlatforms()) {
|
|
793
|
+
const platformDir = join(platform.dir, localSkillName);
|
|
794
|
+
try {
|
|
795
|
+
if (existsSync(platformDir))
|
|
796
|
+
rmSync(platformDir, { recursive: true, force: true });
|
|
797
|
+
}
|
|
798
|
+
catch { }
|
|
799
|
+
}
|
|
602
800
|
}
|
|
603
|
-
console.error(`[sync] Removed "${localSkillName}"
|
|
801
|
+
console.error(`[sync] Removed "${localSkillName}"`);
|
|
604
802
|
}
|
|
605
803
|
}
|
|
606
804
|
}
|
|
@@ -662,38 +860,50 @@ async function syncSkills() {
|
|
|
662
860
|
}
|
|
663
861
|
return { synced: totalSynced, errors };
|
|
664
862
|
}
|
|
665
|
-
async function installSkillStubs() {
|
|
666
|
-
const config = loadConfig();
|
|
863
|
+
async function installSkillStubs(roots = getActiveRoots()) {
|
|
864
|
+
const config = loadConfig(roots);
|
|
667
865
|
if (!config || config.publishers.length === 0) {
|
|
668
866
|
return { installed: 0, skipped: 0, errors: ['No config or publishers found'] };
|
|
669
867
|
}
|
|
670
868
|
let installed = 0;
|
|
671
869
|
let skipped = 0;
|
|
672
870
|
const errors = [];
|
|
673
|
-
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
871
|
+
// Per-install Claude Code skills directory.
|
|
872
|
+
const installSkillsDir = join(roots.claudeDir, 'skills');
|
|
873
|
+
mkdirSync(installSkillsDir, { recursive: true });
|
|
874
|
+
// The agent-agnostic ~/.agents/skills/ + secondary platforms only get
|
|
875
|
+
// populated for global installs. In project mode they would leak content
|
|
876
|
+
// out of the project boundary, which is the whole point of the refactor.
|
|
877
|
+
const isGlobal = roots.scope === 'global';
|
|
878
|
+
const detectedPlatforms = isGlobal ? detectAgentPlatforms() : [];
|
|
879
|
+
if (isGlobal) {
|
|
880
|
+
mkdirSync(AGENTS_SKILLS_DIR, { recursive: true });
|
|
881
|
+
for (const platform of detectedPlatforms) {
|
|
882
|
+
mkdirSync(platform.dir, { recursive: true });
|
|
883
|
+
}
|
|
677
884
|
}
|
|
678
|
-
//
|
|
885
|
+
// Lock file location depends on scope: global → ~/.agents/.skill-lock.json,
|
|
886
|
+
// project → <roots.configDir>/skill-lock.json (per-install).
|
|
887
|
+
const lockPath = isGlobal
|
|
888
|
+
? AGENTS_LOCK_PATH
|
|
889
|
+
: join(roots.configDir, 'skill-lock.json');
|
|
679
890
|
let lockData = { version: 3, skills: {} };
|
|
680
891
|
try {
|
|
681
|
-
if (existsSync(
|
|
682
|
-
lockData = JSON.parse(readFileSync(
|
|
892
|
+
if (existsSync(lockPath)) {
|
|
893
|
+
lockData = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
683
894
|
}
|
|
684
895
|
}
|
|
685
896
|
catch { }
|
|
686
897
|
for (const pub of config.publishers) {
|
|
687
|
-
const pubVaultDir = join(
|
|
898
|
+
const pubVaultDir = join(roots.vaultDir, pub.id);
|
|
688
899
|
if (!existsSync(pubVaultDir))
|
|
689
900
|
continue;
|
|
690
901
|
const vaultFiles = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault'));
|
|
691
902
|
for (const vaultFile of vaultFiles) {
|
|
692
903
|
const skillName = vaultFile.replace(/\.vault$/, '');
|
|
693
904
|
const vaultPath = join(pubVaultDir, vaultFile);
|
|
694
|
-
const
|
|
695
|
-
const
|
|
696
|
-
const manifestPath = join(skillDir, 'manifest.json');
|
|
905
|
+
const installStubDir = join(installSkillsDir, skillName);
|
|
906
|
+
const manifestPath = join(installStubDir, 'manifest.json');
|
|
697
907
|
const hashPath = vaultPath + '.hash';
|
|
698
908
|
if (existsSync(manifestPath) && existsSync(hashPath)) {
|
|
699
909
|
try {
|
|
@@ -797,26 +1007,35 @@ ${multiFileSection}`;
|
|
|
797
1007
|
installed_at: new Date().toISOString(),
|
|
798
1008
|
encrypted: true,
|
|
799
1009
|
}, null, 2);
|
|
800
|
-
// Write
|
|
801
|
-
mkdirSync(
|
|
802
|
-
writeFileSync(join(
|
|
803
|
-
writeFileSync(join(
|
|
804
|
-
// Write file manifest cache for --list-files
|
|
1010
|
+
// Write the stub into this install's claude skills directory.
|
|
1011
|
+
mkdirSync(installStubDir, { recursive: true });
|
|
1012
|
+
writeFileSync(join(installStubDir, 'SKILL.md'), stub, { mode: 0o600 });
|
|
1013
|
+
writeFileSync(join(installStubDir, 'manifest.json'), manifestData, { mode: 0o600 });
|
|
805
1014
|
if (vaultFileList.length > 0) {
|
|
806
|
-
writeFileSync(join(
|
|
1015
|
+
writeFileSync(join(installStubDir, 'files.json'), JSON.stringify(vaultFileList, null, 2), { mode: 0o600 });
|
|
807
1016
|
}
|
|
808
|
-
//
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1017
|
+
// Global mode only: also write to the agent-agnostic source-of-truth
|
|
1018
|
+
// and any other detected agent platforms (Cursor, Windsurf, Codex).
|
|
1019
|
+
if (isGlobal) {
|
|
1020
|
+
const agentSkillDir = join(AGENTS_SKILLS_DIR, skillName);
|
|
1021
|
+
mkdirSync(agentSkillDir, { recursive: true });
|
|
1022
|
+
writeFileSync(join(agentSkillDir, 'SKILL.md'), stub, { mode: 0o600 });
|
|
1023
|
+
writeFileSync(join(agentSkillDir, 'manifest.json'), manifestData, { mode: 0o600 });
|
|
1024
|
+
if (vaultFileList.length > 0) {
|
|
1025
|
+
writeFileSync(join(agentSkillDir, 'files.json'), JSON.stringify(vaultFileList, null, 2), { mode: 0o600 });
|
|
1026
|
+
}
|
|
1027
|
+
for (const platform of detectedPlatforms) {
|
|
1028
|
+
const platformSkillDir = join(platform.dir, skillName);
|
|
1029
|
+
try {
|
|
1030
|
+
mkdirSync(platformSkillDir, { recursive: true });
|
|
1031
|
+
writeFileSync(join(platformSkillDir, 'SKILL.md'), stub, { mode: 0o600 });
|
|
1032
|
+
writeFileSync(join(platformSkillDir, 'manifest.json'), manifestData, { mode: 0o600 });
|
|
1033
|
+
if (vaultFileList.length > 0) {
|
|
1034
|
+
writeFileSync(join(platformSkillDir, 'files.json'), JSON.stringify(vaultFileList, null, 2), { mode: 0o600 });
|
|
1035
|
+
}
|
|
817
1036
|
}
|
|
1037
|
+
catch { }
|
|
818
1038
|
}
|
|
819
|
-
catch { }
|
|
820
1039
|
}
|
|
821
1040
|
// Update lock file
|
|
822
1041
|
lockData.skills[skillName] = {
|
|
@@ -832,18 +1051,527 @@ ${multiFileSection}`;
|
|
|
832
1051
|
encrypted: true,
|
|
833
1052
|
};
|
|
834
1053
|
installed++;
|
|
835
|
-
|
|
836
|
-
|
|
1054
|
+
if (isGlobal) {
|
|
1055
|
+
const platformNames = detectedPlatforms.map(p => p.name).join(', ') || 'none detected';
|
|
1056
|
+
console.error(`[install] "${skillName}" → ~/.agents/skills/ + ${platformNames}`);
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
console.error(`[install] "${skillName}" → ${installStubDir}`);
|
|
1060
|
+
}
|
|
837
1061
|
}
|
|
838
1062
|
}
|
|
839
1063
|
// Persist lock file
|
|
840
1064
|
try {
|
|
841
|
-
mkdirSync(
|
|
842
|
-
writeFileSync(
|
|
1065
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
1066
|
+
writeFileSync(lockPath, JSON.stringify(lockData, null, 2), { mode: 0o600 });
|
|
843
1067
|
}
|
|
844
1068
|
catch { }
|
|
845
1069
|
return { installed, skipped, errors };
|
|
846
1070
|
}
|
|
1071
|
+
function removePublisherStubs(publisherId, roots) {
|
|
1072
|
+
const removed = [];
|
|
1073
|
+
const skillsDir = join(roots.claudeDir, 'skills');
|
|
1074
|
+
if (!existsSync(skillsDir))
|
|
1075
|
+
return removed;
|
|
1076
|
+
for (const entry of readdirSync(skillsDir)) {
|
|
1077
|
+
const stubDir = join(skillsDir, entry);
|
|
1078
|
+
const manifestPath = join(stubDir, 'manifest.json');
|
|
1079
|
+
if (!existsSync(manifestPath))
|
|
1080
|
+
continue;
|
|
1081
|
+
try {
|
|
1082
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
1083
|
+
if (manifest.publisher_id === publisherId) {
|
|
1084
|
+
rmSync(stubDir, { recursive: true, force: true });
|
|
1085
|
+
removed.push({ skillName: manifest.skill_name || entry, publisherId });
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
catch { }
|
|
1089
|
+
}
|
|
1090
|
+
return removed;
|
|
1091
|
+
}
|
|
1092
|
+
// ── Publisher management commands ──
|
|
1093
|
+
//
|
|
1094
|
+
// `publishers` list active publishers in current install
|
|
1095
|
+
// `add-publisher <id>` enable a publisher in current install + sync its skills
|
|
1096
|
+
// `remove-publisher <id>` disable + remove its stubs (vault data preserved)
|
|
1097
|
+
async function handlePublishersCommand() {
|
|
1098
|
+
const roots = getActiveRoots();
|
|
1099
|
+
const credentials = loadCredentials();
|
|
1100
|
+
if (!credentials || credentials.publishers.length === 0) {
|
|
1101
|
+
console.log('🔐 SkillVault — no publishers configured.');
|
|
1102
|
+
console.log(' Run: npx skillvault --invite YOUR_CODE');
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const project = loadProjectConfig(roots);
|
|
1106
|
+
const active = new Set(project?.active_publishers ?? credentials.publishers.map(p => p.id));
|
|
1107
|
+
console.log(`🔐 Publishers (${roots.scope} install at ${roots.scope === 'project' ? process.cwd() : HOME})\n`);
|
|
1108
|
+
console.log(` ${'Active'.padEnd(8)} ${'ID'.padEnd(20)} Name`);
|
|
1109
|
+
console.log(' ' + '-'.repeat(60));
|
|
1110
|
+
for (const pub of credentials.publishers) {
|
|
1111
|
+
const tag = active.has(pub.id) ? ' ✓ ' : ' ';
|
|
1112
|
+
console.log(` ${tag} ${pub.id.padEnd(20)} ${pub.name}`);
|
|
1113
|
+
}
|
|
1114
|
+
console.log('');
|
|
1115
|
+
console.log(` ${active.size} of ${credentials.publishers.length} publishers active.\n`);
|
|
1116
|
+
}
|
|
1117
|
+
async function handleAddPublisherCommand(publisherId) {
|
|
1118
|
+
if (!publisherId) {
|
|
1119
|
+
console.error('Usage: npx skillvault add-publisher <publisher_id>');
|
|
1120
|
+
process.exit(1);
|
|
1121
|
+
}
|
|
1122
|
+
const roots = getActiveRoots();
|
|
1123
|
+
const credentials = loadCredentials();
|
|
1124
|
+
if (!credentials) {
|
|
1125
|
+
console.error('🔐 SkillVault not configured. Run: npx skillvault --invite YOUR_CODE');
|
|
1126
|
+
process.exit(1);
|
|
1127
|
+
}
|
|
1128
|
+
const pub = credentials.publishers.find(p => p.id === publisherId);
|
|
1129
|
+
if (!pub) {
|
|
1130
|
+
console.error(`Error: publisher "${publisherId}" not in your credentials.`);
|
|
1131
|
+
console.error('Available publishers:');
|
|
1132
|
+
for (const p of credentials.publishers)
|
|
1133
|
+
console.error(` - ${p.id} (${p.name})`);
|
|
1134
|
+
console.error('');
|
|
1135
|
+
console.error('To gain access to a new publisher, redeem an invite:');
|
|
1136
|
+
console.error(' npx skillvault --invite NEW_INVITE_CODE');
|
|
1137
|
+
process.exit(1);
|
|
1138
|
+
}
|
|
1139
|
+
const project = loadProjectConfig(roots) ?? {
|
|
1140
|
+
active_publishers: [],
|
|
1141
|
+
installed_at: new Date().toISOString(),
|
|
1142
|
+
scope: roots.scope,
|
|
1143
|
+
install_path: roots.scope === 'project' ? process.cwd() : HOME,
|
|
1144
|
+
};
|
|
1145
|
+
if (project.active_publishers.includes(publisherId)) {
|
|
1146
|
+
console.log(`🔐 ${pub.name} is already active in this install.`);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
project.active_publishers = [...project.active_publishers, publisherId];
|
|
1150
|
+
project.last_synced_at = new Date().toISOString();
|
|
1151
|
+
saveProjectConfig(roots, project);
|
|
1152
|
+
console.log(`🔐 Activated ${pub.name} in this install.`);
|
|
1153
|
+
console.log(' Syncing skills...');
|
|
1154
|
+
await syncSkills(roots);
|
|
1155
|
+
const result = await installSkillStubs(roots);
|
|
1156
|
+
console.log(` ✅ ${result.installed} skill${result.installed !== 1 ? 's' : ''} installed.\n`);
|
|
1157
|
+
if (roots.scope === 'project') {
|
|
1158
|
+
registerProject({
|
|
1159
|
+
path: pathResolve(process.cwd()),
|
|
1160
|
+
installed_at: project.installed_at,
|
|
1161
|
+
last_synced_at: project.last_synced_at,
|
|
1162
|
+
active_publishers: project.active_publishers,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
async function handleRemovePublisherCommand(publisherId) {
|
|
1167
|
+
if (!publisherId) {
|
|
1168
|
+
console.error('Usage: npx skillvault remove-publisher <publisher_id>');
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
}
|
|
1171
|
+
const roots = getActiveRoots();
|
|
1172
|
+
const project = loadProjectConfig(roots);
|
|
1173
|
+
if (!project) {
|
|
1174
|
+
console.error('🔐 No SkillVault install detected at this location.');
|
|
1175
|
+
process.exit(1);
|
|
1176
|
+
}
|
|
1177
|
+
if (!project.active_publishers.includes(publisherId)) {
|
|
1178
|
+
console.log(`🔐 Publisher "${publisherId}" is not active in this install.`);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
// Remove from active list
|
|
1182
|
+
project.active_publishers = project.active_publishers.filter(id => id !== publisherId);
|
|
1183
|
+
saveProjectConfig(roots, project);
|
|
1184
|
+
// Remove stubs whose manifest.publisher_id matches. Vault data is preserved
|
|
1185
|
+
// in case the publisher is added back later.
|
|
1186
|
+
const removed = removePublisherStubs(publisherId, roots);
|
|
1187
|
+
const credentials = loadCredentials();
|
|
1188
|
+
const pubName = credentials?.publishers.find(p => p.id === publisherId)?.name || publisherId;
|
|
1189
|
+
console.log(`🔐 Deactivated ${pubName} in this install.`);
|
|
1190
|
+
if (removed.length > 0) {
|
|
1191
|
+
console.log(` Removed ${removed.length} skill stub${removed.length !== 1 ? 's' : ''}:`);
|
|
1192
|
+
for (const r of removed)
|
|
1193
|
+
console.log(` - ${r.skillName}`);
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
console.log(' (no skill stubs to remove)');
|
|
1197
|
+
}
|
|
1198
|
+
console.log('');
|
|
1199
|
+
if (roots.scope === 'project') {
|
|
1200
|
+
registerProject({
|
|
1201
|
+
path: pathResolve(process.cwd()),
|
|
1202
|
+
installed_at: project.installed_at,
|
|
1203
|
+
last_synced_at: project.last_synced_at || new Date().toISOString(),
|
|
1204
|
+
active_publishers: project.active_publishers,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Strip skillvault hook entries from a settings.json file. Preserves every
|
|
1210
|
+
* other key (plugins, effortLevel, user-defined hooks, …) so the customer's
|
|
1211
|
+
* existing Claude Code configuration survives an uninstall.
|
|
1212
|
+
*
|
|
1213
|
+
* Returns the number of skillvault hook entries removed.
|
|
1214
|
+
*/
|
|
1215
|
+
function scrubSkillvaultHooks(settingsPath) {
|
|
1216
|
+
if (!existsSync(settingsPath))
|
|
1217
|
+
return 0;
|
|
1218
|
+
let removed = 0;
|
|
1219
|
+
try {
|
|
1220
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1221
|
+
if (!settings.hooks)
|
|
1222
|
+
return 0;
|
|
1223
|
+
const eventKeys = [
|
|
1224
|
+
'SessionStart', 'SessionEnd', 'UserPromptSubmit',
|
|
1225
|
+
'PostToolUse', 'Stop', 'PostToolCall', 'PreToolCall',
|
|
1226
|
+
];
|
|
1227
|
+
for (const key of eventKeys) {
|
|
1228
|
+
if (!Array.isArray(settings.hooks[key]))
|
|
1229
|
+
continue;
|
|
1230
|
+
const before = settings.hooks[key].length;
|
|
1231
|
+
settings.hooks[key] = settings.hooks[key].filter((h) => !hasSkillvaultCommand(h));
|
|
1232
|
+
removed += before - settings.hooks[key].length;
|
|
1233
|
+
if (settings.hooks[key].length === 0)
|
|
1234
|
+
delete settings.hooks[key];
|
|
1235
|
+
}
|
|
1236
|
+
// If hooks is now empty, drop the key entirely.
|
|
1237
|
+
if (Object.keys(settings.hooks).length === 0)
|
|
1238
|
+
delete settings.hooks;
|
|
1239
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
1240
|
+
}
|
|
1241
|
+
catch { }
|
|
1242
|
+
return removed;
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Walk <roots.claudeDir>/skills/ and remove every stub whose manifest.json
|
|
1246
|
+
* has an `encrypted: true` flag (the marker we set during install). Returns
|
|
1247
|
+
* the list of removed skill names. Vault data is left in place so a future
|
|
1248
|
+
* --invite or migrate can reuse it.
|
|
1249
|
+
*/
|
|
1250
|
+
function removeAllSkillvaultStubs(roots) {
|
|
1251
|
+
const skillsDir = join(roots.claudeDir, 'skills');
|
|
1252
|
+
if (!existsSync(skillsDir))
|
|
1253
|
+
return [];
|
|
1254
|
+
const removed = [];
|
|
1255
|
+
for (const entry of readdirSync(skillsDir)) {
|
|
1256
|
+
const stubDir = join(skillsDir, entry);
|
|
1257
|
+
const manifestPath = join(stubDir, 'manifest.json');
|
|
1258
|
+
if (!existsSync(manifestPath))
|
|
1259
|
+
continue;
|
|
1260
|
+
try {
|
|
1261
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
1262
|
+
if (manifest.encrypted === true || manifest.publisher_id) {
|
|
1263
|
+
rmSync(stubDir, { recursive: true, force: true });
|
|
1264
|
+
removed.push(manifest.skill_name || entry);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
catch { }
|
|
1268
|
+
}
|
|
1269
|
+
return removed;
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Uninstall a single SkillVault install at the given roots.
|
|
1273
|
+
*
|
|
1274
|
+
* Removes:
|
|
1275
|
+
* - <roots.configDir>/ (vaults, project.json, skill-lock.json, fingerprints,
|
|
1276
|
+
* active-session.json, …) — but NOT credentials.json or projects.json,
|
|
1277
|
+
* which always live at the global ~/.skillvault/.
|
|
1278
|
+
* - skillvault hook entries from <roots.settingsPath>
|
|
1279
|
+
* - skill stubs from <roots.claudeDir>/skills/ that were installed by us
|
|
1280
|
+
* - the registry entry (project mode)
|
|
1281
|
+
*
|
|
1282
|
+
* Other settings.json keys, ~/.skillvault/credentials.json, and the projects
|
|
1283
|
+
* registry are left intact.
|
|
1284
|
+
*/
|
|
1285
|
+
function uninstallInstall(roots) {
|
|
1286
|
+
const summary = {
|
|
1287
|
+
scope: roots.scope,
|
|
1288
|
+
installPath: roots.scope === 'project' ? roots.configDir.replace(/\/\.skillvault$/, '') : HOME,
|
|
1289
|
+
removedFiles: [],
|
|
1290
|
+
removedHooks: 0,
|
|
1291
|
+
removedStubs: [],
|
|
1292
|
+
};
|
|
1293
|
+
// Per-install configDir contents (vaults, project.json, …). For global
|
|
1294
|
+
// mode, this is ~/.skillvault/ — we must NOT delete credentials.json or
|
|
1295
|
+
// projects.json, so walk the entries instead of rm -rf.
|
|
1296
|
+
if (existsSync(roots.configDir)) {
|
|
1297
|
+
if (roots.scope === 'project') {
|
|
1298
|
+
// Project install — safe to nuke the whole .skillvault directory.
|
|
1299
|
+
try {
|
|
1300
|
+
rmSync(roots.configDir, { recursive: true, force: true });
|
|
1301
|
+
summary.removedFiles.push(roots.configDir);
|
|
1302
|
+
}
|
|
1303
|
+
catch { }
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
// Global install — preserve credentials.json + projects.json.
|
|
1307
|
+
const PRESERVE = new Set(['credentials.json', 'projects.json']);
|
|
1308
|
+
for (const entry of readdirSync(roots.configDir)) {
|
|
1309
|
+
if (PRESERVE.has(entry))
|
|
1310
|
+
continue;
|
|
1311
|
+
const path = join(roots.configDir, entry);
|
|
1312
|
+
try {
|
|
1313
|
+
rmSync(path, { recursive: true, force: true });
|
|
1314
|
+
summary.removedFiles.push(path);
|
|
1315
|
+
}
|
|
1316
|
+
catch { }
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// Skill stubs in <roots.claudeDir>/skills/
|
|
1321
|
+
summary.removedStubs = removeAllSkillvaultStubs(roots);
|
|
1322
|
+
// Hooks in settings.json
|
|
1323
|
+
summary.removedHooks = scrubSkillvaultHooks(roots.settingsPath);
|
|
1324
|
+
// Registry: drop the entry if this was a project install. Use the install
|
|
1325
|
+
// path encoded in roots (not the literal CWD) so callers can uninstall a
|
|
1326
|
+
// different project's install too.
|
|
1327
|
+
if (roots.scope === 'project') {
|
|
1328
|
+
const installPath = roots.configDir.replace(/\/\.skillvault$/, '');
|
|
1329
|
+
try {
|
|
1330
|
+
unregisterProject(installPath);
|
|
1331
|
+
}
|
|
1332
|
+
catch { }
|
|
1333
|
+
}
|
|
1334
|
+
return summary;
|
|
1335
|
+
}
|
|
1336
|
+
async function handleUninstallCommand() {
|
|
1337
|
+
if (allFlag)
|
|
1338
|
+
return handleUninstallAllCommand();
|
|
1339
|
+
const roots = getActiveRoots();
|
|
1340
|
+
// If we're not in any registered install, refuse rather than scribbling.
|
|
1341
|
+
if (roots.scope === 'project' && !existsSync(roots.configDir)) {
|
|
1342
|
+
console.error(`🔐 No SkillVault project install at ${process.cwd()}.`);
|
|
1343
|
+
console.error(' (To uninstall global, run: npx skillvault uninstall --global)');
|
|
1344
|
+
process.exit(1);
|
|
1345
|
+
}
|
|
1346
|
+
const summary = uninstallInstall(roots);
|
|
1347
|
+
printUninstallSummary(summary);
|
|
1348
|
+
}
|
|
1349
|
+
async function handleUninstallAllCommand() {
|
|
1350
|
+
console.log('🔐 SkillVault uninstall --all\n');
|
|
1351
|
+
const summaries = [];
|
|
1352
|
+
// Walk every registered project install
|
|
1353
|
+
for (const entry of listProjects()) {
|
|
1354
|
+
const roots = resolveRoots('project', entry.path);
|
|
1355
|
+
console.log(` Uninstalling project: ${entry.path}`);
|
|
1356
|
+
summaries.push(uninstallInstall(roots));
|
|
1357
|
+
}
|
|
1358
|
+
// Also uninstall global, if there's anything to clean up.
|
|
1359
|
+
const globalRoots = resolveRoots('global');
|
|
1360
|
+
if (existsSync(globalRoots.configDir) || scrubSkillvaultHooks(globalRoots.settingsPath) > 0) {
|
|
1361
|
+
console.log(' Uninstalling global install: ~/.claude, ~/.skillvault');
|
|
1362
|
+
summaries.push(uninstallInstall(globalRoots));
|
|
1363
|
+
}
|
|
1364
|
+
// Finally remove the global credentials and the projects registry.
|
|
1365
|
+
// The customer is fully resetting; their identity comes back from a
|
|
1366
|
+
// fresh --invite next time.
|
|
1367
|
+
const credPath = join(HOME, '.skillvault', 'credentials.json');
|
|
1368
|
+
const regPath = join(HOME, '.skillvault', 'projects.json');
|
|
1369
|
+
for (const p of [credPath, regPath]) {
|
|
1370
|
+
try {
|
|
1371
|
+
if (existsSync(p)) {
|
|
1372
|
+
rmSync(p);
|
|
1373
|
+
console.log(` Removed ${p}`);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
catch { }
|
|
1377
|
+
}
|
|
1378
|
+
// Drop the now-empty ~/.skillvault dir if it's actually empty.
|
|
1379
|
+
try {
|
|
1380
|
+
const dir = join(HOME, '.skillvault');
|
|
1381
|
+
if (existsSync(dir) && readdirSync(dir).length === 0)
|
|
1382
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1383
|
+
}
|
|
1384
|
+
catch { }
|
|
1385
|
+
console.log('');
|
|
1386
|
+
console.log(`Uninstalled ${summaries.length} install${summaries.length !== 1 ? 's' : ''}.`);
|
|
1387
|
+
}
|
|
1388
|
+
function printUninstallSummary(s) {
|
|
1389
|
+
console.log(`🔐 Uninstalled SkillVault (${s.scope}) at ${s.installPath}`);
|
|
1390
|
+
if (s.removedHooks > 0)
|
|
1391
|
+
console.log(` ✅ Removed ${s.removedHooks} hook entr${s.removedHooks !== 1 ? 'ies' : 'y'} from settings.json`);
|
|
1392
|
+
if (s.removedStubs.length > 0) {
|
|
1393
|
+
console.log(` ✅ Removed ${s.removedStubs.length} skill stub${s.removedStubs.length !== 1 ? 's' : ''}:`);
|
|
1394
|
+
for (const name of s.removedStubs)
|
|
1395
|
+
console.log(` - ${name}`);
|
|
1396
|
+
}
|
|
1397
|
+
if (s.removedFiles.length > 0)
|
|
1398
|
+
console.log(` ✅ Removed ${s.removedFiles.length} file${s.removedFiles.length !== 1 ? 's' : ''}/dir${s.removedFiles.length !== 1 ? 's' : ''}`);
|
|
1399
|
+
if (s.scope === 'global') {
|
|
1400
|
+
console.log(' ℹ️ ~/.skillvault/credentials.json and projects.json were preserved.');
|
|
1401
|
+
console.log(' ℹ️ Run `uninstall --all` to wipe those too.');
|
|
1402
|
+
}
|
|
1403
|
+
console.log('');
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* `npx skillvault status` — extended version of --status with hook health
|
|
1407
|
+
* and an explicit scope/location header.
|
|
1408
|
+
*/
|
|
1409
|
+
async function handleStatusSubcommand() {
|
|
1410
|
+
const roots = getActiveRoots();
|
|
1411
|
+
const credentials = loadCredentials();
|
|
1412
|
+
if (!credentials) {
|
|
1413
|
+
console.log('🔐 SkillVault — not configured.');
|
|
1414
|
+
console.log('');
|
|
1415
|
+
console.log(' To get started, run:');
|
|
1416
|
+
console.log(' npx skillvault --invite YOUR_INVITE_CODE');
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
const project = loadProjectConfig(roots);
|
|
1420
|
+
const registryEntry = roots.scope === 'project' ? getCurrentProject() : null;
|
|
1421
|
+
console.log('🔐 SkillVault Status\n');
|
|
1422
|
+
console.log(` Account: ${credentials.customer_email || '(unknown)'}`);
|
|
1423
|
+
console.log(` Scope: ${roots.scope}`);
|
|
1424
|
+
console.log(` Install path: ${roots.scope === 'project' ? process.cwd() : HOME}`);
|
|
1425
|
+
console.log(` Config dir: ${roots.configDir}`);
|
|
1426
|
+
console.log(` Settings: ${roots.settingsPath}`);
|
|
1427
|
+
console.log(` Server: ${credentials.api_url}`);
|
|
1428
|
+
if (registryEntry?.last_synced_at) {
|
|
1429
|
+
console.log(` Last sync: ${registryEntry.last_synced_at}`);
|
|
1430
|
+
}
|
|
1431
|
+
else if (project?.last_synced_at) {
|
|
1432
|
+
console.log(` Last sync: ${project.last_synced_at}`);
|
|
1433
|
+
}
|
|
1434
|
+
console.log('');
|
|
1435
|
+
// Hook health
|
|
1436
|
+
let hookHealth = 'no settings.json';
|
|
1437
|
+
if (existsSync(roots.settingsPath)) {
|
|
1438
|
+
try {
|
|
1439
|
+
const settings = JSON.parse(readFileSync(roots.settingsPath, 'utf8'));
|
|
1440
|
+
const hooks = settings.hooks || {};
|
|
1441
|
+
const skillvaultEvents = Object.entries(hooks)
|
|
1442
|
+
.filter(([_, entries]) => Array.isArray(entries) && entries.some(hasSkillvaultCommand))
|
|
1443
|
+
.map(([k]) => k);
|
|
1444
|
+
hookHealth = skillvaultEvents.length > 0
|
|
1445
|
+
? `${skillvaultEvents.join(', ')}`
|
|
1446
|
+
: 'no skillvault hooks installed';
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
hookHealth = 'invalid (could not parse)';
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
console.log(` Hook health: ${hookHealth}`);
|
|
1453
|
+
console.log('');
|
|
1454
|
+
// Active publishers
|
|
1455
|
+
const activeIds = project?.active_publishers ?? credentials.publishers.map(p => p.id);
|
|
1456
|
+
console.log(` Active publishers (${activeIds.length} of ${credentials.publishers.length}):`);
|
|
1457
|
+
for (const pub of credentials.publishers) {
|
|
1458
|
+
const tag = activeIds.includes(pub.id) ? '✓' : ' ';
|
|
1459
|
+
console.log(` ${tag} ${pub.id.padEnd(20)} ${pub.name}`);
|
|
1460
|
+
}
|
|
1461
|
+
console.log('');
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Move a global install into the current project. Confirmation is required
|
|
1465
|
+
* before any destructive moves. Vault data, project.json, hooks, and skill
|
|
1466
|
+
* stubs are migrated; credentials and the registry are not touched (they're
|
|
1467
|
+
* already global).
|
|
1468
|
+
*/
|
|
1469
|
+
async function handleMigrateToProjectCommand() {
|
|
1470
|
+
const globalRoots = resolveRoots('global');
|
|
1471
|
+
const projectRoots = resolveRoots('project', process.cwd());
|
|
1472
|
+
if (!existsSync(globalRoots.configDir) && !existsSync(globalRoots.settingsPath)) {
|
|
1473
|
+
console.error('🔐 No global install detected. Nothing to migrate.');
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
if (existsSync(projectRoots.configDir) && existsSync(join(projectRoots.configDir, 'project.json'))) {
|
|
1477
|
+
console.error(`🔐 Project install already exists at ${process.cwd()}.`);
|
|
1478
|
+
console.error(' Refusing to overwrite. Run `npx skillvault uninstall` first if you want to start fresh.');
|
|
1479
|
+
process.exit(1);
|
|
1480
|
+
}
|
|
1481
|
+
console.log('🔐 SkillVault migrate-to-project');
|
|
1482
|
+
console.log('');
|
|
1483
|
+
console.log(` Source: ${globalRoots.configDir}`);
|
|
1484
|
+
console.log(` ${globalRoots.settingsPath}`);
|
|
1485
|
+
console.log(` Target: ${projectRoots.configDir}`);
|
|
1486
|
+
console.log(` ${projectRoots.settingsPath}`);
|
|
1487
|
+
console.log('');
|
|
1488
|
+
if (!process.stdin.isTTY) {
|
|
1489
|
+
console.log(' (non-TTY: auto-confirming)');
|
|
1490
|
+
}
|
|
1491
|
+
else {
|
|
1492
|
+
process.stderr.write('Continue? [y/N]: ');
|
|
1493
|
+
const key = await new Promise((resolve) => {
|
|
1494
|
+
process.stdin.setRawMode(true);
|
|
1495
|
+
process.stdin.resume();
|
|
1496
|
+
process.stdin.setEncoding('utf8');
|
|
1497
|
+
process.stdin.once('data', (k) => {
|
|
1498
|
+
process.stdin.setRawMode(false);
|
|
1499
|
+
process.stdin.pause();
|
|
1500
|
+
resolve(k);
|
|
1501
|
+
});
|
|
1502
|
+
});
|
|
1503
|
+
console.error('');
|
|
1504
|
+
if (key !== 'y' && key !== 'Y') {
|
|
1505
|
+
console.log('Cancelled.');
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
// Move <globalConfigDir>/{vaults,active-session.json,fingerprints} → projectConfigDir
|
|
1510
|
+
mkdirSync(projectRoots.configDir, { recursive: true });
|
|
1511
|
+
const moveItems = ['vaults', 'active-session.json', 'fingerprints', 'skill-lock.json'];
|
|
1512
|
+
for (const item of moveItems) {
|
|
1513
|
+
const src = join(globalRoots.configDir, item);
|
|
1514
|
+
const dst = join(projectRoots.configDir, item);
|
|
1515
|
+
if (!existsSync(src))
|
|
1516
|
+
continue;
|
|
1517
|
+
try {
|
|
1518
|
+
// Cross-device-safe: try rename, fall back to copy + remove.
|
|
1519
|
+
cpSync(src, dst, { recursive: true });
|
|
1520
|
+
rmSync(src, { recursive: true, force: true });
|
|
1521
|
+
console.log(` Moved: ${src} → ${dst}`);
|
|
1522
|
+
}
|
|
1523
|
+
catch (err) {
|
|
1524
|
+
console.error(` ⚠️ Could not move ${item}: ${err instanceof Error ? err.message : 'unknown'}`);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
// Move skill stubs from ~/.claude/skills/ to ./.claude/skills/
|
|
1528
|
+
const globalStubs = join(globalRoots.claudeDir, 'skills');
|
|
1529
|
+
const projectStubs = join(projectRoots.claudeDir, 'skills');
|
|
1530
|
+
mkdirSync(projectStubs, { recursive: true });
|
|
1531
|
+
if (existsSync(globalStubs)) {
|
|
1532
|
+
for (const entry of readdirSync(globalStubs)) {
|
|
1533
|
+
const src = join(globalStubs, entry);
|
|
1534
|
+
const manifestPath = join(src, 'manifest.json');
|
|
1535
|
+
if (!existsSync(manifestPath))
|
|
1536
|
+
continue;
|
|
1537
|
+
try {
|
|
1538
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
1539
|
+
if (manifest.encrypted === true || manifest.publisher_id) {
|
|
1540
|
+
const dst = join(projectStubs, entry);
|
|
1541
|
+
cpSync(src, dst, { recursive: true });
|
|
1542
|
+
rmSync(src, { recursive: true, force: true });
|
|
1543
|
+
console.log(` Moved stub: ${entry}`);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
catch { }
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
// Build project.json reflecting the migrated install. The active list is
|
|
1550
|
+
// every publisher in credentials (since global mode had them all active).
|
|
1551
|
+
const credentials = loadCredentials();
|
|
1552
|
+
const newProject = {
|
|
1553
|
+
active_publishers: credentials?.publishers.map(p => p.id) ?? [],
|
|
1554
|
+
installed_at: new Date().toISOString(),
|
|
1555
|
+
scope: 'project',
|
|
1556
|
+
install_path: pathResolve(process.cwd()),
|
|
1557
|
+
last_synced_at: new Date().toISOString(),
|
|
1558
|
+
};
|
|
1559
|
+
saveProjectConfig(projectRoots, newProject);
|
|
1560
|
+
// Reinstall hooks into the project settings.json (with current version).
|
|
1561
|
+
configureSessionHook(projectRoots);
|
|
1562
|
+
// Strip the old global hooks now that the project has them.
|
|
1563
|
+
scrubSkillvaultHooks(globalRoots.settingsPath);
|
|
1564
|
+
// Register the new project install.
|
|
1565
|
+
registerProject({
|
|
1566
|
+
path: pathResolve(process.cwd()),
|
|
1567
|
+
installed_at: newProject.installed_at,
|
|
1568
|
+
last_synced_at: newProject.last_synced_at,
|
|
1569
|
+
active_publishers: newProject.active_publishers,
|
|
1570
|
+
});
|
|
1571
|
+
console.log('');
|
|
1572
|
+
console.log(' ✅ Migration complete. Restart Claude Code to pick up the new hooks.');
|
|
1573
|
+
console.log('');
|
|
1574
|
+
}
|
|
847
1575
|
// ── Vault Decryption (in-memory only, output to stdout) ──
|
|
848
1576
|
const VAULT_MAGIC = Buffer.from([0x00, 0x53, 0x56, 0x54]);
|
|
849
1577
|
/** Detect binary content by checking for null bytes or high ratio of non-printable chars */
|
|
@@ -897,16 +1625,30 @@ function decryptVault(data, cek) {
|
|
|
897
1625
|
});
|
|
898
1626
|
return { metadata, files };
|
|
899
1627
|
}
|
|
900
|
-
function resolveSkillPublisher(skillName, config) {
|
|
1628
|
+
function resolveSkillPublisher(skillName, config, roots = getActiveRoots()) {
|
|
901
1629
|
for (const pub of config.publishers) {
|
|
902
|
-
const vaultPath = join(
|
|
1630
|
+
const vaultPath = join(roots.vaultDir, pub.id, `${skillName}.vault`);
|
|
903
1631
|
if (existsSync(vaultPath))
|
|
904
1632
|
return { publisher: pub, vaultPath };
|
|
905
1633
|
}
|
|
906
|
-
|
|
1634
|
+
// Legacy fallback: vaults that landed at <vaultDir>/<skill>.vault before the
|
|
1635
|
+
// per-publisher subdirectory layout existed.
|
|
1636
|
+
const legacyPath = join(roots.vaultDir, `${skillName}.vault`);
|
|
907
1637
|
if (existsSync(legacyPath) && config.publishers.length > 0) {
|
|
908
1638
|
return { publisher: config.publishers[0], vaultPath: legacyPath };
|
|
909
1639
|
}
|
|
1640
|
+
// Cross-scope fallback: a customer running --load from CWD that's not the
|
|
1641
|
+
// install directory should still be able to find a vault from any known
|
|
1642
|
+
// install. Try the global roots if we're currently looking at a project,
|
|
1643
|
+
// and try every registered project if currently global.
|
|
1644
|
+
if (roots.scope === 'project') {
|
|
1645
|
+
const globalRoots = resolveRoots('global');
|
|
1646
|
+
for (const pub of config.publishers) {
|
|
1647
|
+
const vaultPath = join(globalRoots.vaultDir, pub.id, `${skillName}.vault`);
|
|
1648
|
+
if (existsSync(vaultPath))
|
|
1649
|
+
return { publisher: pub, vaultPath };
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
910
1652
|
return null;
|
|
911
1653
|
}
|
|
912
1654
|
async function fetchCEK(skillName, publisherToken, apiUrl) {
|
|
@@ -1178,7 +1920,7 @@ function validateSkillName(name) {
|
|
|
1178
1920
|
* Quick sync for a single skill — checks for vault update before decrypting.
|
|
1179
1921
|
* Returns true if the vault was updated. Status goes to stderr.
|
|
1180
1922
|
*/
|
|
1181
|
-
async function syncSingleSkill(skillName, pub, config) {
|
|
1923
|
+
async function syncSingleSkill(skillName, pub, config, roots = getActiveRoots()) {
|
|
1182
1924
|
try {
|
|
1183
1925
|
const capabilityName = `skill/${skillName.toLowerCase()}`;
|
|
1184
1926
|
const res = await fetch(`${config.api_url}/skills/check-update?capability=${encodeURIComponent(capabilityName)}¤t_version=0.0.0`, { signal: AbortSignal.timeout(5000) });
|
|
@@ -1186,7 +1928,7 @@ async function syncSingleSkill(skillName, pub, config) {
|
|
|
1186
1928
|
return false;
|
|
1187
1929
|
const data = await res.json();
|
|
1188
1930
|
// Check if local vault hash matches
|
|
1189
|
-
const vaultPath = join(
|
|
1931
|
+
const vaultPath = join(roots.vaultDir, pub.id, `${skillName}.vault`);
|
|
1190
1932
|
const hashPath = vaultPath + '.hash';
|
|
1191
1933
|
if (existsSync(hashPath) && data.vault_hash) {
|
|
1192
1934
|
const localHash = readFileSync(hashPath, 'utf8').trim();
|
|
@@ -1199,7 +1941,7 @@ async function syncSingleSkill(skillName, pub, config) {
|
|
|
1199
1941
|
return false;
|
|
1200
1942
|
const dlData = await dlRes.json();
|
|
1201
1943
|
const vaultBuffer = Buffer.from(dlData.vault_data, 'base64');
|
|
1202
|
-
mkdirSync(join(
|
|
1944
|
+
mkdirSync(join(roots.vaultDir, pub.id), { recursive: true });
|
|
1203
1945
|
writeFileSync(vaultPath, vaultBuffer, { mode: 0o600 });
|
|
1204
1946
|
if (dlData.vault_hash)
|
|
1205
1947
|
writeFileSync(hashPath, dlData.vault_hash, { mode: 0o600 });
|
|
@@ -1223,10 +1965,10 @@ async function syncSingleSkill(skillName, pub, config) {
|
|
|
1223
1965
|
* Discovers new skills the customer has been granted since last sync.
|
|
1224
1966
|
* Runs async — doesn't block the load operation.
|
|
1225
1967
|
*/
|
|
1226
|
-
async function backgroundSyncAll(
|
|
1968
|
+
async function backgroundSyncAll(_config, roots = getActiveRoots()) {
|
|
1227
1969
|
try {
|
|
1228
|
-
await syncSkills();
|
|
1229
|
-
await installSkillStubs();
|
|
1970
|
+
await syncSkills(roots);
|
|
1971
|
+
await installSkillStubs(roots);
|
|
1230
1972
|
}
|
|
1231
1973
|
catch { } // non-fatal
|
|
1232
1974
|
}
|
|
@@ -1238,14 +1980,15 @@ async function listSkillFiles(skillName) {
|
|
|
1238
1980
|
console.error('Error: Invalid skill name.');
|
|
1239
1981
|
process.exit(1);
|
|
1240
1982
|
}
|
|
1241
|
-
const
|
|
1983
|
+
const roots = getActiveRoots();
|
|
1984
|
+
const config = loadConfig(roots);
|
|
1242
1985
|
if (!config) {
|
|
1243
1986
|
console.error('Error: SkillVault is not configured. Run: npx skillvault --invite YOUR_CODE');
|
|
1244
1987
|
process.exit(1);
|
|
1245
1988
|
}
|
|
1246
|
-
// Try local manifest cache first
|
|
1247
|
-
const
|
|
1248
|
-
const filesJsonPath = join(
|
|
1989
|
+
// Try local manifest cache first — look in this install's claude skills dir
|
|
1990
|
+
const installStubDir = join(roots.claudeDir, 'skills', skillName);
|
|
1991
|
+
const filesJsonPath = join(installStubDir, 'files.json');
|
|
1249
1992
|
try {
|
|
1250
1993
|
if (existsSync(filesJsonPath)) {
|
|
1251
1994
|
const filesData = JSON.parse(readFileSync(filesJsonPath, 'utf8'));
|
|
@@ -1296,30 +2039,32 @@ async function loadSkill(skillName) {
|
|
|
1296
2039
|
console.error('Example: npx skillvault --load my-skill-name');
|
|
1297
2040
|
process.exit(1);
|
|
1298
2041
|
}
|
|
1299
|
-
const
|
|
2042
|
+
const roots = getActiveRoots();
|
|
2043
|
+
const config = loadConfig(roots);
|
|
1300
2044
|
if (!config) {
|
|
1301
2045
|
console.error('Error: SkillVault is not configured on this machine.');
|
|
1302
2046
|
console.error('');
|
|
1303
2047
|
console.error('To set up, you need an invite code from a skill publisher.');
|
|
1304
2048
|
console.error('Run: npx skillvault --invite YOUR_INVITE_CODE');
|
|
1305
2049
|
console.error('');
|
|
1306
|
-
console.error('If you already set up SkillVault, the config
|
|
1307
|
-
console.error(` Expected: ${
|
|
2050
|
+
console.error('If you already set up SkillVault, the config files may be missing:');
|
|
2051
|
+
console.error(` Expected: ${join(roots.configDir, 'project.json')}`);
|
|
2052
|
+
console.error(` ~/.skillvault/credentials.json`);
|
|
1308
2053
|
process.exit(1);
|
|
1309
2054
|
}
|
|
1310
2055
|
// Pre-load sync: ensure we have the latest vault for this skill
|
|
1311
|
-
let resolved = resolveSkillPublisher(skillName, config);
|
|
2056
|
+
let resolved = resolveSkillPublisher(skillName, config, roots);
|
|
1312
2057
|
if (resolved) {
|
|
1313
|
-
await syncSingleSkill(skillName, resolved.publisher, config);
|
|
2058
|
+
await syncSingleSkill(skillName, resolved.publisher, config, roots);
|
|
1314
2059
|
// Re-resolve in case the vault was just downloaded
|
|
1315
|
-
resolved = resolveSkillPublisher(skillName, config);
|
|
2060
|
+
resolved = resolveSkillPublisher(skillName, config, roots);
|
|
1316
2061
|
}
|
|
1317
2062
|
else {
|
|
1318
2063
|
// Skill not found locally — try a full sync first (may be a newly granted skill)
|
|
1319
2064
|
console.error(`[sync] Skill "${skillName}" not found locally, syncing...`);
|
|
1320
|
-
await syncSkills();
|
|
1321
|
-
await installSkillStubs();
|
|
1322
|
-
resolved = resolveSkillPublisher(skillName, config);
|
|
2065
|
+
await syncSkills(roots);
|
|
2066
|
+
await installSkillStubs(roots);
|
|
2067
|
+
resolved = resolveSkillPublisher(skillName, config, roots);
|
|
1323
2068
|
}
|
|
1324
2069
|
if (!resolved) {
|
|
1325
2070
|
console.error(`Error: Skill "${skillName}" not found after syncing with server.`);
|
|
@@ -1330,10 +2075,10 @@ async function loadSkill(skillName) {
|
|
|
1330
2075
|
console.error(' 3. Your token expired — refresh with: npx skillvault --refresh');
|
|
1331
2076
|
console.error('');
|
|
1332
2077
|
console.error('Available skills on this machine:');
|
|
1333
|
-
const localConfig = loadConfig();
|
|
2078
|
+
const localConfig = loadConfig(roots);
|
|
1334
2079
|
if (localConfig) {
|
|
1335
2080
|
for (const pub of localConfig.publishers) {
|
|
1336
|
-
const pubVaultDir = join(
|
|
2081
|
+
const pubVaultDir = join(roots.vaultDir, pub.id);
|
|
1337
2082
|
try {
|
|
1338
2083
|
if (existsSync(pubVaultDir)) {
|
|
1339
2084
|
const vaults = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault'));
|
|
@@ -1349,7 +2094,7 @@ async function loadSkill(skillName) {
|
|
|
1349
2094
|
process.exit(1);
|
|
1350
2095
|
}
|
|
1351
2096
|
// Kick off background sync for all other skills (non-blocking)
|
|
1352
|
-
backgroundSyncAll(config).catch(() => { });
|
|
2097
|
+
backgroundSyncAll(config, roots).catch(() => { });
|
|
1353
2098
|
// Fetch CEK — validates license on every load
|
|
1354
2099
|
let cek;
|
|
1355
2100
|
let licenseeId;
|
|
@@ -1473,7 +2218,7 @@ async function loadSkill(skillName) {
|
|
|
1473
2218
|
}
|
|
1474
2219
|
}
|
|
1475
2220
|
// After successful decryption and output, inject session-scoped monitoring hooks
|
|
1476
|
-
injectMonitoringHooks(skillName);
|
|
2221
|
+
injectMonitoringHooks(skillName, roots);
|
|
1477
2222
|
}
|
|
1478
2223
|
catch (err) {
|
|
1479
2224
|
cek.fill(0);
|
|
@@ -1518,32 +2263,85 @@ async function reportSecurityEvent(eventType, skill, detail) {
|
|
|
1518
2263
|
}
|
|
1519
2264
|
}
|
|
1520
2265
|
// ── Session-scoped hook handlers ──
|
|
1521
|
-
const ACTIVE_SESSION_PATH = join(CONFIG_DIR, 'active-session.json');
|
|
1522
|
-
const FINGERPRINT_DIR = join(CONFIG_DIR, 'fingerprints');
|
|
1523
2266
|
const SESSION_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
1524
2267
|
/**
|
|
1525
|
-
*
|
|
2268
|
+
* Search for an active session at the active roots, then at every registered
|
|
2269
|
+
* project, then at the global location. Returns the first match (or null).
|
|
2270
|
+
*
|
|
2271
|
+
* Project-scope sessions guard themselves: if the install_path doesn't match
|
|
2272
|
+
* Claude Code's CWD, the session is treated as not-applicable for this hook
|
|
2273
|
+
* invocation (returned as null) so the hook exits silently.
|
|
1526
2274
|
*/
|
|
1527
|
-
function
|
|
2275
|
+
function findActiveSessionForCwd(cwd = process.cwd()) {
|
|
2276
|
+
const candidates = [];
|
|
2277
|
+
// Active roots (resolved from current CWD)
|
|
2278
|
+
const active = getActiveRoots();
|
|
2279
|
+
candidates.push(active);
|
|
2280
|
+
// All registered projects
|
|
1528
2281
|
try {
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
2282
|
+
const reg = listProjectsRegistry();
|
|
2283
|
+
for (const p of reg) {
|
|
2284
|
+
candidates.push(resolveRoots('project', p.path));
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
catch { }
|
|
2288
|
+
// Global location as a final fallback
|
|
2289
|
+
candidates.push(resolveRoots('global'));
|
|
2290
|
+
const seen = new Set();
|
|
2291
|
+
for (const roots of candidates) {
|
|
2292
|
+
const path = join(roots.configDir, 'active-session.json');
|
|
2293
|
+
if (seen.has(path))
|
|
2294
|
+
continue;
|
|
2295
|
+
seen.add(path);
|
|
2296
|
+
if (!existsSync(path))
|
|
2297
|
+
continue;
|
|
2298
|
+
try {
|
|
2299
|
+
const session = JSON.parse(readFileSync(path, 'utf8'));
|
|
2300
|
+
if (!session.skill || !session.expires_at)
|
|
2301
|
+
continue;
|
|
2302
|
+
if (new Date(session.expires_at).getTime() < Date.now()) {
|
|
2303
|
+
try {
|
|
2304
|
+
rmSync(path);
|
|
2305
|
+
}
|
|
2306
|
+
catch { }
|
|
2307
|
+
continue;
|
|
1537
2308
|
}
|
|
1538
|
-
|
|
1539
|
-
|
|
2309
|
+
// Project-scope guard: only honor sessions that belong to this CWD's
|
|
2310
|
+
// install. A session in /tmp/proj-a should not fire when Claude Code
|
|
2311
|
+
// happens to be running in /tmp/proj-b.
|
|
2312
|
+
if (session.scope === 'project' && session.install_path) {
|
|
2313
|
+
if (pathResolve(session.install_path) !== pathResolve(cwd))
|
|
2314
|
+
continue;
|
|
2315
|
+
}
|
|
2316
|
+
return { data: session, path, roots };
|
|
2317
|
+
}
|
|
2318
|
+
catch {
|
|
2319
|
+
// Malformed session file — skip
|
|
1540
2320
|
}
|
|
1541
|
-
|
|
2321
|
+
}
|
|
2322
|
+
return null;
|
|
2323
|
+
}
|
|
2324
|
+
/** Re-export of projects-registry.listProjects so the helper above can read it. */
|
|
2325
|
+
function listProjectsRegistry() {
|
|
2326
|
+
// Lazy import-style read so this module compiles even when projects-registry
|
|
2327
|
+
// is not yet built. Falls back to an empty array on any error.
|
|
2328
|
+
try {
|
|
2329
|
+
return listProjects();
|
|
1542
2330
|
}
|
|
1543
2331
|
catch {
|
|
1544
|
-
return
|
|
2332
|
+
return [];
|
|
1545
2333
|
}
|
|
1546
2334
|
}
|
|
2335
|
+
/**
|
|
2336
|
+
* Load active session in a backwards-compatible way.
|
|
2337
|
+
*
|
|
2338
|
+
* Returns the session data if a valid session exists for the current CWD.
|
|
2339
|
+
* Returns null if no valid session is found, the session has expired, or
|
|
2340
|
+
* the session belongs to a different project install.
|
|
2341
|
+
*/
|
|
2342
|
+
function loadActiveSession() {
|
|
2343
|
+
return findActiveSessionForCwd()?.data ?? null;
|
|
2344
|
+
}
|
|
1547
2345
|
/**
|
|
1548
2346
|
* Extraction-intent keyword patterns for prompt-submit detection.
|
|
1549
2347
|
*/
|
|
@@ -1604,17 +2402,19 @@ function extractKeywords(text, max = 30) {
|
|
|
1604
2402
|
return [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, max).map(([w]) => w);
|
|
1605
2403
|
}
|
|
1606
2404
|
/**
|
|
1607
|
-
* Load fingerprint files from cache.
|
|
2405
|
+
* Load fingerprint files from cache. Looks under the active install's
|
|
2406
|
+
* configDir/fingerprints/ directory.
|
|
1608
2407
|
*/
|
|
1609
|
-
function loadFingerprintCache() {
|
|
2408
|
+
function loadFingerprintCache(roots = getActiveRoots()) {
|
|
2409
|
+
const fingerprintDir = join(roots.configDir, 'fingerprints');
|
|
1610
2410
|
try {
|
|
1611
|
-
if (!existsSync(
|
|
2411
|
+
if (!existsSync(fingerprintDir))
|
|
1612
2412
|
return [];
|
|
1613
|
-
const files = readdirSync(
|
|
2413
|
+
const files = readdirSync(fingerprintDir).filter(f => f.endsWith('.json'));
|
|
1614
2414
|
const fps = [];
|
|
1615
2415
|
for (const file of files) {
|
|
1616
2416
|
try {
|
|
1617
|
-
const data = JSON.parse(readFileSync(join(
|
|
2417
|
+
const data = JSON.parse(readFileSync(join(fingerprintDir, file), 'utf8'));
|
|
1618
2418
|
// Handle both encrypted and unencrypted formats
|
|
1619
2419
|
if (data.capability && data.ngrams) {
|
|
1620
2420
|
fps.push(data);
|
|
@@ -1751,11 +2551,23 @@ async function handleSessionKeepalive(evtType) {
|
|
|
1751
2551
|
}
|
|
1752
2552
|
/**
|
|
1753
2553
|
* Session cleanup handler.
|
|
1754
|
-
*
|
|
2554
|
+
*
|
|
2555
|
+
* Locates the active session for Claude Code's CWD via findActiveSessionForCwd,
|
|
2556
|
+
* then removes monitoring hooks from THAT install's settings.json (not always
|
|
2557
|
+
* ~/.claude/settings.json — that was the bug in v0.9.x). Also deletes the
|
|
2558
|
+
* session marker and fingerprint cache for that install.
|
|
2559
|
+
*
|
|
2560
|
+
* If no session is found (e.g. session expired between load and cleanup, or
|
|
2561
|
+
* the user moved between projects), falls back to global ~/.claude so we
|
|
2562
|
+
* still scrub any leftover hooks from old global installs.
|
|
1755
2563
|
*/
|
|
1756
2564
|
function handleSessionCleanup() {
|
|
1757
2565
|
try {
|
|
1758
|
-
const
|
|
2566
|
+
const located = findActiveSessionForCwd();
|
|
2567
|
+
const settingsPath = located?.data.settings_path
|
|
2568
|
+
|| located?.roots.settingsPath
|
|
2569
|
+
|| join(HOME, '.claude', 'settings.json');
|
|
2570
|
+
const cleanupRoots = located?.roots ?? resolveRoots('global');
|
|
1759
2571
|
if (existsSync(settingsPath)) {
|
|
1760
2572
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1761
2573
|
if (settings.hooks) {
|
|
@@ -1787,19 +2599,22 @@ function handleSessionCleanup() {
|
|
|
1787
2599
|
}
|
|
1788
2600
|
}
|
|
1789
2601
|
}
|
|
1790
|
-
// Delete active session
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2602
|
+
// Delete the active session marker we located (if any).
|
|
2603
|
+
if (located?.path) {
|
|
2604
|
+
try {
|
|
2605
|
+
if (existsSync(located.path))
|
|
2606
|
+
rmSync(located.path);
|
|
2607
|
+
}
|
|
2608
|
+
catch { }
|
|
1794
2609
|
}
|
|
1795
|
-
|
|
1796
|
-
|
|
2610
|
+
// Delete fingerprint cache for this install.
|
|
2611
|
+
const fingerprintDir = join(cleanupRoots.configDir, 'fingerprints');
|
|
1797
2612
|
try {
|
|
1798
|
-
if (existsSync(
|
|
1799
|
-
const files = readdirSync(
|
|
2613
|
+
if (existsSync(fingerprintDir)) {
|
|
2614
|
+
const files = readdirSync(fingerprintDir);
|
|
1800
2615
|
for (const file of files) {
|
|
1801
2616
|
try {
|
|
1802
|
-
rmSync(join(
|
|
2617
|
+
rmSync(join(fingerprintDir, file));
|
|
1803
2618
|
}
|
|
1804
2619
|
catch { }
|
|
1805
2620
|
}
|
|
@@ -1813,6 +2628,30 @@ function handleSessionCleanup() {
|
|
|
1813
2628
|
}
|
|
1814
2629
|
// ── Main ──
|
|
1815
2630
|
async function main() {
|
|
2631
|
+
// Resolve active install roots from flags + CWD before dispatching commands.
|
|
2632
|
+
// Commands that don't pass roots explicitly will fall through to currentRoots.
|
|
2633
|
+
setActiveRoots(resolveActiveRoots({ global: globalFlag, project: projectFlag }));
|
|
2634
|
+
// Positional subcommands for management operations.
|
|
2635
|
+
switch (subcommand) {
|
|
2636
|
+
case 'publishers':
|
|
2637
|
+
await handlePublishersCommand();
|
|
2638
|
+
process.exit(0);
|
|
2639
|
+
case 'add-publisher':
|
|
2640
|
+
await handleAddPublisherCommand(subcommandArgs[0]);
|
|
2641
|
+
process.exit(0);
|
|
2642
|
+
case 'remove-publisher':
|
|
2643
|
+
await handleRemovePublisherCommand(subcommandArgs[0]);
|
|
2644
|
+
process.exit(0);
|
|
2645
|
+
case 'uninstall':
|
|
2646
|
+
await handleUninstallCommand();
|
|
2647
|
+
process.exit(0);
|
|
2648
|
+
case 'status':
|
|
2649
|
+
await handleStatusSubcommand();
|
|
2650
|
+
process.exit(0);
|
|
2651
|
+
case 'migrate-to-project':
|
|
2652
|
+
await handleMigrateToProjectCommand();
|
|
2653
|
+
process.exit(0);
|
|
2654
|
+
}
|
|
1816
2655
|
// --session-keepalive: unified session-scoped hook handler
|
|
1817
2656
|
if (sessionKeepaliveFlag) {
|
|
1818
2657
|
await handleSessionKeepalive(eventType);
|
|
@@ -1849,7 +2688,32 @@ async function main() {
|
|
|
1849
2688
|
process.exit(0);
|
|
1850
2689
|
}
|
|
1851
2690
|
if (inviteCode) {
|
|
1852
|
-
|
|
2691
|
+
// For --invite, scope is decided by explicit flags first, then CWD heuristics.
|
|
2692
|
+
// We do NOT use the existing-install detection here — `--invite` is the
|
|
2693
|
+
// moment the customer is choosing where to install.
|
|
2694
|
+
let inviteRoots = resolveRoots(resolveScope({ global: globalFlag, project: projectFlag, cwd: process.cwd() }), process.cwd());
|
|
2695
|
+
if (dryRunFlag) {
|
|
2696
|
+
setActiveRoots(inviteRoots);
|
|
2697
|
+
printDryRunPlan(inviteRoots);
|
|
2698
|
+
process.exit(0);
|
|
2699
|
+
}
|
|
2700
|
+
// Interactive confirmation. In a TTY, the customer sees the planned
|
|
2701
|
+
// writes and can press Y/n/g. In a non-TTY (CI, audit harness, scripts)
|
|
2702
|
+
// confirmInstall short-circuits to 'yes'. If --global or --project was
|
|
2703
|
+
// explicitly passed we trust the customer and skip the prompt.
|
|
2704
|
+
if (!globalFlag && !projectFlag) {
|
|
2705
|
+
const choice = await confirmInstall(inviteRoots.scope, inviteRoots);
|
|
2706
|
+
if (choice === 'no') {
|
|
2707
|
+
console.error('Cancelled. No files written.');
|
|
2708
|
+
process.exit(0);
|
|
2709
|
+
}
|
|
2710
|
+
if (choice === 'global' && inviteRoots.scope === 'project') {
|
|
2711
|
+
inviteRoots = resolveRoots('global');
|
|
2712
|
+
console.error('Switched to global install.');
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
setActiveRoots(inviteRoots);
|
|
2716
|
+
await setup(inviteCode, inviteRoots);
|
|
1853
2717
|
if (!statusFlag && !refreshFlag)
|
|
1854
2718
|
process.exit(0);
|
|
1855
2719
|
}
|
|
@@ -1858,24 +2722,38 @@ async function main() {
|
|
|
1858
2722
|
process.exit(0);
|
|
1859
2723
|
}
|
|
1860
2724
|
if (refreshFlag) {
|
|
1861
|
-
|
|
2725
|
+
const roots = getActiveRoots();
|
|
2726
|
+
await refreshTokens(roots);
|
|
1862
2727
|
console.error(' Syncing skills...\n');
|
|
1863
|
-
await syncSkills();
|
|
1864
|
-
const result = await installSkillStubs();
|
|
2728
|
+
await syncSkills(roots);
|
|
2729
|
+
const result = await installSkillStubs(roots);
|
|
1865
2730
|
if (result.installed > 0)
|
|
1866
2731
|
console.error(` ✅ ${result.installed} skill${result.installed !== 1 ? 's' : ''} updated`);
|
|
1867
2732
|
console.error('');
|
|
1868
2733
|
process.exit(0);
|
|
1869
2734
|
}
|
|
1870
2735
|
if (syncFlag) {
|
|
1871
|
-
const
|
|
2736
|
+
const roots = getActiveRoots();
|
|
2737
|
+
const config = loadConfig(roots);
|
|
1872
2738
|
if (!config) {
|
|
1873
2739
|
console.error('🔐 Not set up. Run: npx skillvault --invite YOUR_CODE\n');
|
|
1874
2740
|
process.exit(1);
|
|
1875
2741
|
}
|
|
1876
|
-
console.error(
|
|
1877
|
-
|
|
1878
|
-
|
|
2742
|
+
console.error(`🔐 SkillVault Sync (${roots.scope})\n`);
|
|
2743
|
+
// Re-install hooks with current version (auto-upgrades pinned commands)
|
|
2744
|
+
configureSessionHook(roots);
|
|
2745
|
+
await syncSkills(roots);
|
|
2746
|
+
const result = await installSkillStubs(roots);
|
|
2747
|
+
// Update the registry's last_synced_at if this is a project install.
|
|
2748
|
+
if (roots.scope === 'project') {
|
|
2749
|
+
const proj = loadProjectConfig(roots);
|
|
2750
|
+
registerProject({
|
|
2751
|
+
path: pathResolve(process.cwd()),
|
|
2752
|
+
installed_at: proj?.installed_at || new Date().toISOString(),
|
|
2753
|
+
last_synced_at: new Date().toISOString(),
|
|
2754
|
+
active_publishers: proj?.active_publishers || config.publishers.map(p => p.id),
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
1879
2757
|
console.error(`\n ✅ ${result.installed} installed, ${result.skipped} up to date\n`);
|
|
1880
2758
|
process.exit(0);
|
|
1881
2759
|
}
|
|
@@ -1897,6 +2775,27 @@ async function main() {
|
|
|
1897
2775
|
console.log(' Learn more: https://app.getskillvault.com\n');
|
|
1898
2776
|
}
|
|
1899
2777
|
}
|
|
2778
|
+
/**
|
|
2779
|
+
* Print the planned writes for an invite without performing them. Used by
|
|
2780
|
+
* --dry-run for scripts and the audit suite to verify scope detection.
|
|
2781
|
+
*/
|
|
2782
|
+
function printDryRunPlan(roots) {
|
|
2783
|
+
console.error('🔐 SkillVault Setup (DRY RUN — no files will be written)');
|
|
2784
|
+
console.error('');
|
|
2785
|
+
console.error(`Scope: ${roots.scope}`);
|
|
2786
|
+
console.error(`Location: ${roots.scope === 'project' ? process.cwd() : HOME}`);
|
|
2787
|
+
console.error('');
|
|
2788
|
+
console.error('Would write:');
|
|
2789
|
+
console.error(` ${roots.settingsPath} (Claude Code hooks)`);
|
|
2790
|
+
console.error(` ${join(roots.claudeDir, 'skills')}/<skill>/ (skill stubs)`);
|
|
2791
|
+
console.error(` ${join(roots.configDir, 'project.json')} (project config)`);
|
|
2792
|
+
console.error(` ${roots.vaultDir}/<publisher_id>/ (encrypted vaults)`);
|
|
2793
|
+
console.error('');
|
|
2794
|
+
console.error('Plus user-level (always global):');
|
|
2795
|
+
console.error(` ~/.skillvault/credentials.json (customer identity)`);
|
|
2796
|
+
console.error(` ~/.skillvault/projects.json (project install registry)`);
|
|
2797
|
+
console.error('');
|
|
2798
|
+
}
|
|
1900
2799
|
main().catch((err) => {
|
|
1901
2800
|
console.error('Fatal:', err);
|
|
1902
2801
|
process.exit(1);
|