skillvault 0.9.3 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.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
- const VERSION = '0.9.3';
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.1';
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
- const CONFIG_PATH = join(CONFIG_DIR, 'agent-config.json');
28
- const VAULT_DIR = join(CONFIG_DIR, 'vaults');
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
- // Legacy: SKILLS_DIR points to Claude for backward compat in other functions
48
- const SKILLS_DIR = join(HOME, '.claude', 'skills');
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
- Skills are encrypted at rest in ~/.skillvault/vaults/. When Claude Code
100
- triggers a skill, it runs \`npx skillvault --load <name>\` to decrypt on
101
- demand. The decrypted content goes to stdout never written to disk.
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
- function loadConfig() {
110
- try {
111
- if (existsSync(CONFIG_PATH)) {
112
- const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
113
- if (raw.token && !raw.publishers) {
114
- const migrated = {
115
- customer_token: raw.token,
116
- customer_email: raw.email || null,
117
- publishers: [{
118
- id: raw.publisher_id,
119
- name: raw.publisher_id,
120
- token: raw.token,
121
- added_at: raw.setup_at || new Date().toISOString(),
122
- }],
123
- api_url: raw.api_url || API_URL,
124
- setup_at: raw.setup_at || new Date().toISOString(),
125
- };
126
- saveConfig(migrated);
127
- return migrated;
128
- }
129
- return raw;
130
- }
131
- }
132
- catch { }
133
- return null;
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
- function saveConfig(config) {
136
- mkdirSync(CONFIG_DIR, { recursive: true });
137
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
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
- async function setup(code) {
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
- const existingConfig = loadConfig();
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
- if (existingConfig) {
182
- const existingIdx = existingConfig.publishers.findIndex(p => p.id === data.publisher_id);
183
- if (existingIdx >= 0) {
184
- existingConfig.publishers[existingIdx] = publisherEntry;
185
- console.error(` 🔄 Updated publisher: ${publisherEntry.name}`);
186
- }
187
- else {
188
- existingConfig.publishers.push(publisherEntry);
189
- console.error(` ➕ Added publisher: ${publisherEntry.name}`);
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
- if (data.customer_token)
192
- existingConfig.customer_token = data.customer_token;
193
- if (data.email)
194
- existingConfig.customer_email = data.email;
195
- saveConfig(existingConfig);
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
- saveConfig({
199
- customer_token: data.customer_token || data.token,
200
- customer_email: data.email,
201
- publishers: [publisherEntry],
202
- api_url: API_URL,
203
- setup_at: new Date().toISOString(),
204
- });
372
+ // Fresh install — only the publisher just invited is active.
373
+ newActive = [data.publisher_id];
205
374
  }
206
- mkdirSync(join(VAULT_DIR, data.publisher_id), { recursive: true });
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
+ }
386
+ }
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
- console.error(` ✅ ${installResult.installed} skill${installResult.installed !== 1 ? 's' : ''} installed to ~/.claude/skills/`);
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(' ✅ Setup complete! Restart Claude Code to use your skills.');
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 = join(HOME, '.claude', 'settings.json');
463
+ function configureSessionHook(roots) {
464
+ const settingsPath = roots.settingsPath;
262
465
  try {
263
466
  let settings = {};
264
467
  if (existsSync(settingsPath)) {
@@ -313,9 +516,9 @@ function configureSessionHook() {
313
516
  delete settings.hooks[key];
314
517
  }
315
518
  }
316
- mkdirSync(join(HOME, '.claude'), { recursive: true });
519
+ mkdirSync(dirname(settingsPath), { recursive: true });
317
520
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
318
- console.error(' ✅ Hooks updated');
521
+ console.error(` ✅ Hooks updated (${settingsPath})`);
319
522
  if (removedLegacy)
320
523
  console.error(' 🧹 Removed legacy monitoring hooks (now session-scoped)');
321
524
  }
@@ -324,7 +527,7 @@ function configureSessionHook() {
324
527
  }
325
528
  }
326
529
  /**
327
- * Inject session-scoped monitoring hooks into ~/.claude/settings.json.
530
+ * Inject session-scoped monitoring hooks into the active install's settings.json.
328
531
  * Called after successful --load to enable extraction detection for this session.
329
532
  *
330
533
  * Creates:
@@ -332,11 +535,13 @@ function configureSessionHook() {
332
535
  * - PostToolUse: n-gram fingerprint matching on tool output
333
536
  * - Stop: n-gram fingerprint matching on Claude's response
334
537
  *
335
- * Also creates ~/.skillvault/active-session.json with session metadata.
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.
336
541
  */
337
- function injectMonitoringHooks(skillName) {
338
- const settingsPath = join(HOME, '.claude', 'settings.json');
339
- const activeSessionPath = join(CONFIG_DIR, 'active-session.json');
542
+ function injectMonitoringHooks(skillName, roots = getActiveRoots()) {
543
+ const settingsPath = roots.settingsPath;
544
+ const activeSessionPath = join(roots.configDir, 'active-session.json');
340
545
  try {
341
546
  let settings = {};
342
547
  if (existsSync(settingsPath)) {
@@ -351,8 +556,11 @@ function injectMonitoringHooks(skillName) {
351
556
  skill: skillName,
352
557
  loaded_at: now.toISOString(),
353
558
  expires_at: expiresAt.toISOString(),
559
+ scope: roots.scope,
560
+ install_path: roots.scope === 'project' ? process.cwd() : HOME,
561
+ settings_path: settingsPath,
354
562
  };
355
- mkdirSync(CONFIG_DIR, { recursive: true });
563
+ mkdirSync(roots.configDir, { recursive: true });
356
564
  writeFileSync(activeSessionPath, JSON.stringify(session, null, 2), { mode: 0o600 });
357
565
  // ── Remove old-format monitoring hooks (command on entry, no hooks array) ──
358
566
  for (const key of ['UserPromptSubmit', 'PostToolUse', 'Stop']) {
@@ -400,7 +608,8 @@ function injectMonitoringHooks(skillName) {
400
608
  }
401
609
  }
402
610
  async function showStatus() {
403
- const config = loadConfig();
611
+ const roots = getActiveRoots();
612
+ const config = loadConfig(roots);
404
613
  if (!config) {
405
614
  console.log('🔐 SkillVault\n');
406
615
  console.log(' Not set up yet. Run:');
@@ -410,8 +619,9 @@ async function showStatus() {
410
619
  console.log('🔐 SkillVault Status\n');
411
620
  if (config.customer_email)
412
621
  console.log(` Account: ${config.customer_email}`);
413
- console.log(` Config: ${CONFIG_PATH}`);
414
- console.log(` Skills: ${SKILLS_DIR}`);
622
+ console.log(` Scope: ${roots.scope}`);
623
+ console.log(` Config: ${roots.configDir}`);
624
+ console.log(` Skills: ${join(roots.claudeDir, 'skills')}`);
415
625
  console.log(` Server: ${config.api_url}`);
416
626
  console.log('');
417
627
  let skills = [];
@@ -451,7 +661,7 @@ async function showStatus() {
451
661
  for (const pub of config.publishers) {
452
662
  const pubSkills = skills.filter(s => s.publisher_id === pub.id);
453
663
  let localVaultCount = 0;
454
- const pubVaultDir = join(VAULT_DIR, pub.id);
664
+ const pubVaultDir = join(roots.vaultDir, pub.id);
455
665
  if (existsSync(pubVaultDir)) {
456
666
  try {
457
667
  localVaultCount = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault')).length;
@@ -483,8 +693,8 @@ async function showStatus() {
483
693
  console.log('');
484
694
  }
485
695
  // ── Refresh ──
486
- async function refreshTokens() {
487
- const config = loadConfig();
696
+ async function refreshTokens(roots = getActiveRoots()) {
697
+ const config = loadConfig(roots);
488
698
  if (!config) {
489
699
  console.error('🔐 SkillVault\n Not set up. Run: npx skillvault --invite YOUR_CODE\n');
490
700
  process.exit(1);
@@ -519,22 +729,22 @@ async function refreshTokens() {
519
729
  }
520
730
  }
521
731
  if (anyRefreshed) {
522
- saveConfig(config);
732
+ saveConfig(config, roots);
523
733
  console.error('\n Tokens updated.\n');
524
734
  }
525
735
  else {
526
736
  console.error('\n No tokens refreshed.\n');
527
737
  }
528
738
  }
529
- async function syncSkills() {
530
- const config = loadConfig();
739
+ async function syncSkills(roots = getActiveRoots()) {
740
+ const config = loadConfig(roots);
531
741
  if (!config || config.publishers.length === 0) {
532
742
  return { synced: 0, errors: ['No config or publishers found'] };
533
743
  }
534
744
  let totalSynced = 0;
535
745
  const errors = [];
536
746
  for (const pub of config.publishers) {
537
- const pubVaultDir = join(VAULT_DIR, pub.id);
747
+ const pubVaultDir = join(roots.vaultDir, pub.id);
538
748
  mkdirSync(pubVaultDir, { recursive: true });
539
749
  let skills = [];
540
750
  try {
@@ -554,6 +764,7 @@ async function syncSkills() {
554
764
  continue;
555
765
  }
556
766
  const remoteSkillNames = new Set(skills.map(s => s.skill_name));
767
+ const localStubsDir = join(roots.claudeDir, 'skills');
557
768
  // Revocation: remove stubs for skills no longer in remote list
558
769
  try {
559
770
  if (existsSync(pubVaultDir)) {
@@ -562,23 +773,32 @@ async function syncSkills() {
562
773
  const localSkillName = vaultFile.replace(/\.vault$/, '');
563
774
  if (!remoteSkillNames.has(localSkillName)) {
564
775
  console.error(`[sync] Grant revoked: "${localSkillName}" from ${pub.name}`);
565
- // Remove from agent-agnostic dir
566
- const agentDir = join(AGENTS_SKILLS_DIR, localSkillName);
776
+ // Remove from this install's claude skill dir
777
+ const stubDir = join(localStubsDir, localSkillName);
567
778
  try {
568
- if (existsSync(agentDir))
569
- rmSync(agentDir, { recursive: true, force: true });
779
+ if (existsSync(stubDir))
780
+ rmSync(stubDir, { recursive: true, force: true });
570
781
  }
571
782
  catch { }
572
- // Remove from all detected agent platforms
573
- for (const platform of detectAgentPlatforms()) {
574
- const platformDir = join(platform.dir, localSkillName);
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);
575
787
  try {
576
- if (existsSync(platformDir))
577
- rmSync(platformDir, { recursive: true, force: true });
788
+ if (existsSync(agentDir))
789
+ rmSync(agentDir, { recursive: true, force: true });
578
790
  }
579
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
+ }
580
800
  }
581
- console.error(`[sync] Removed "${localSkillName}" from all agent platforms`);
801
+ console.error(`[sync] Removed "${localSkillName}"`);
582
802
  }
583
803
  }
584
804
  }
@@ -640,38 +860,50 @@ async function syncSkills() {
640
860
  }
641
861
  return { synced: totalSynced, errors };
642
862
  }
643
- async function installSkillStubs() {
644
- const config = loadConfig();
863
+ async function installSkillStubs(roots = getActiveRoots()) {
864
+ const config = loadConfig(roots);
645
865
  if (!config || config.publishers.length === 0) {
646
866
  return { installed: 0, skipped: 0, errors: ['No config or publishers found'] };
647
867
  }
648
868
  let installed = 0;
649
869
  let skipped = 0;
650
870
  const errors = [];
651
- mkdirSync(AGENTS_SKILLS_DIR, { recursive: true });
652
- const detectedPlatforms = detectAgentPlatforms();
653
- for (const platform of detectedPlatforms) {
654
- mkdirSync(platform.dir, { recursive: true });
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
+ }
655
884
  }
656
- // Load existing lock file
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');
657
890
  let lockData = { version: 3, skills: {} };
658
891
  try {
659
- if (existsSync(AGENTS_LOCK_PATH)) {
660
- lockData = JSON.parse(readFileSync(AGENTS_LOCK_PATH, 'utf8'));
892
+ if (existsSync(lockPath)) {
893
+ lockData = JSON.parse(readFileSync(lockPath, 'utf8'));
661
894
  }
662
895
  }
663
896
  catch { }
664
897
  for (const pub of config.publishers) {
665
- const pubVaultDir = join(VAULT_DIR, pub.id);
898
+ const pubVaultDir = join(roots.vaultDir, pub.id);
666
899
  if (!existsSync(pubVaultDir))
667
900
  continue;
668
901
  const vaultFiles = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault'));
669
902
  for (const vaultFile of vaultFiles) {
670
903
  const skillName = vaultFile.replace(/\.vault$/, '');
671
904
  const vaultPath = join(pubVaultDir, vaultFile);
672
- const agentSkillDir = join(AGENTS_SKILLS_DIR, skillName);
673
- const skillDir = agentSkillDir;
674
- const manifestPath = join(skillDir, 'manifest.json');
905
+ const installStubDir = join(installSkillsDir, skillName);
906
+ const manifestPath = join(installStubDir, 'manifest.json');
675
907
  const hashPath = vaultPath + '.hash';
676
908
  if (existsSync(manifestPath) && existsSync(hashPath)) {
677
909
  try {
@@ -775,26 +1007,35 @@ ${multiFileSection}`;
775
1007
  installed_at: new Date().toISOString(),
776
1008
  encrypted: true,
777
1009
  }, null, 2);
778
- // Write to ~/.agents/skills/ (agent-agnostic source of truth)
779
- mkdirSync(agentSkillDir, { recursive: true });
780
- writeFileSync(join(agentSkillDir, 'SKILL.md'), stub, { mode: 0o600 });
781
- writeFileSync(join(agentSkillDir, 'manifest.json'), manifestData, { mode: 0o600 });
782
- // 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 });
783
1014
  if (vaultFileList.length > 0) {
784
- writeFileSync(join(agentSkillDir, 'files.json'), JSON.stringify(vaultFileList, null, 2), { mode: 0o600 });
1015
+ writeFileSync(join(installStubDir, 'files.json'), JSON.stringify(vaultFileList, null, 2), { mode: 0o600 });
785
1016
  }
786
- // Copy to each detected agent platform's skill directory
787
- for (const platform of detectedPlatforms) {
788
- const platformSkillDir = join(platform.dir, skillName);
789
- try {
790
- mkdirSync(platformSkillDir, { recursive: true });
791
- writeFileSync(join(platformSkillDir, 'SKILL.md'), stub, { mode: 0o600 });
792
- writeFileSync(join(platformSkillDir, 'manifest.json'), manifestData, { mode: 0o600 });
793
- if (vaultFileList.length > 0) {
794
- writeFileSync(join(platformSkillDir, 'files.json'), JSON.stringify(vaultFileList, null, 2), { mode: 0o600 });
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
+ }
795
1036
  }
1037
+ catch { }
796
1038
  }
797
- catch { }
798
1039
  }
799
1040
  // Update lock file
800
1041
  lockData.skills[skillName] = {
@@ -810,18 +1051,605 @@ ${multiFileSection}`;
810
1051
  encrypted: true,
811
1052
  };
812
1053
  installed++;
813
- const platformNames = detectedPlatforms.map(p => p.name).join(', ') || 'none detected';
814
- console.error(`[install] "${skillName}" ~/.agents/skills/ + ${platformNames}`);
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
+ }
815
1061
  }
816
1062
  }
817
1063
  // Persist lock file
818
1064
  try {
819
- mkdirSync(join(HOME, '.agents'), { recursive: true });
820
- writeFileSync(AGENTS_LOCK_PATH, JSON.stringify(lockData, null, 2), { mode: 0o600 });
1065
+ mkdirSync(dirname(lockPath), { recursive: true });
1066
+ writeFileSync(lockPath, JSON.stringify(lockData, null, 2), { mode: 0o600 });
821
1067
  }
822
1068
  catch { }
823
1069
  return { installed, skipped, errors };
824
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 a single skills directory and remove every stub whose manifest.json
1246
+ * has an `encrypted: true` flag or a `publisher_id` (the markers we set during
1247
+ * install). Returns the list of removed skill names. Pre-existing skills from
1248
+ * other tools (no manifest, or non-skillvault manifest) are left untouched.
1249
+ */
1250
+ function removeStubsFromDir(skillsDir) {
1251
+ if (!existsSync(skillsDir))
1252
+ return [];
1253
+ const removed = [];
1254
+ for (const entry of readdirSync(skillsDir)) {
1255
+ const stubDir = join(skillsDir, entry);
1256
+ const manifestPath = join(stubDir, 'manifest.json');
1257
+ if (!existsSync(manifestPath))
1258
+ continue;
1259
+ try {
1260
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1261
+ if (manifest.encrypted === true || manifest.publisher_id) {
1262
+ rmSync(stubDir, { recursive: true, force: true });
1263
+ removed.push(manifest.skill_name || entry);
1264
+ }
1265
+ }
1266
+ catch { }
1267
+ }
1268
+ return removed;
1269
+ }
1270
+ /**
1271
+ * Walk every directory where install would have written a stub and remove
1272
+ * the SkillVault-managed ones. For project mode this is just
1273
+ * <roots.claudeDir>/skills/. For global mode it also covers ~/.agents/skills/
1274
+ * and the agent-agnostic platform mirrors (Cursor, Windsurf, Codex). Vault
1275
+ * data in <roots.vaultDir> is left in place so a future --invite or
1276
+ * migrate-to-project can reuse it.
1277
+ */
1278
+ function removeAllSkillvaultStubs(roots) {
1279
+ const all = [];
1280
+ // Always: <roots.claudeDir>/skills/ (the install's primary stub location)
1281
+ all.push(...removeStubsFromDir(join(roots.claudeDir, 'skills')));
1282
+ // Global only: ~/.agents/skills/ + every detected platform mirror.
1283
+ // installSkillStubs writes to all of these in global mode, so the global
1284
+ // uninstall has to clean all of them up too.
1285
+ if (roots.scope === 'global') {
1286
+ all.push(...removeStubsFromDir(AGENTS_SKILLS_DIR));
1287
+ for (const platform of detectAgentPlatforms()) {
1288
+ all.push(...removeStubsFromDir(platform.dir));
1289
+ }
1290
+ }
1291
+ // Dedupe — the same skill_name can appear in multiple mirrors.
1292
+ return [...new Set(all)];
1293
+ }
1294
+ /**
1295
+ * Remove SkillVault entries from a shared skill-lock.json file, preserving
1296
+ * any entries that belong to other tools (e.g. skill-creator's
1297
+ * sourceType=github wallet skills coexist with our sourceType=skillvault
1298
+ * entries in ~/.agents/.skill-lock.json). If the file ends up with zero
1299
+ * entries, the whole file is deleted.
1300
+ */
1301
+ function pruneSkillvaultLockEntries(lockPath) {
1302
+ if (!existsSync(lockPath))
1303
+ return 0;
1304
+ try {
1305
+ const data = JSON.parse(readFileSync(lockPath, 'utf8'));
1306
+ if (!data.skills || typeof data.skills !== 'object')
1307
+ return 0;
1308
+ let removed = 0;
1309
+ for (const [name, entry] of Object.entries(data.skills)) {
1310
+ if (entry && typeof entry === 'object' && (entry.sourceType === 'skillvault' || entry.encrypted === true)) {
1311
+ delete data.skills[name];
1312
+ removed++;
1313
+ }
1314
+ }
1315
+ if (removed === 0)
1316
+ return 0;
1317
+ if (Object.keys(data.skills).length === 0) {
1318
+ try {
1319
+ rmSync(lockPath);
1320
+ }
1321
+ catch { }
1322
+ }
1323
+ else {
1324
+ writeFileSync(lockPath, JSON.stringify(data, null, 2), { mode: 0o600 });
1325
+ }
1326
+ return removed;
1327
+ }
1328
+ catch {
1329
+ return 0;
1330
+ }
1331
+ }
1332
+ /**
1333
+ * Uninstall a single SkillVault install at the given roots.
1334
+ *
1335
+ * Removes:
1336
+ * - <roots.configDir>/ (vaults, project.json, skill-lock.json, fingerprints,
1337
+ * active-session.json, …) — but NOT credentials.json or projects.json,
1338
+ * which always live at the global ~/.skillvault/.
1339
+ * - skillvault hook entries from <roots.settingsPath>
1340
+ * - skill stubs from <roots.claudeDir>/skills/ that were installed by us
1341
+ * - the registry entry (project mode)
1342
+ *
1343
+ * Other settings.json keys, ~/.skillvault/credentials.json, and the projects
1344
+ * registry are left intact.
1345
+ */
1346
+ function uninstallInstall(roots) {
1347
+ const summary = {
1348
+ scope: roots.scope,
1349
+ installPath: roots.scope === 'project' ? roots.configDir.replace(/\/\.skillvault$/, '') : HOME,
1350
+ removedFiles: [],
1351
+ removedHooks: 0,
1352
+ removedStubs: [],
1353
+ };
1354
+ // Per-install configDir contents (vaults, project.json, …). For global
1355
+ // mode, this is ~/.skillvault/ — we must NOT delete credentials.json or
1356
+ // projects.json, so walk the entries instead of rm -rf.
1357
+ if (existsSync(roots.configDir)) {
1358
+ if (roots.scope === 'project') {
1359
+ // Project install — safe to nuke the whole .skillvault directory.
1360
+ try {
1361
+ rmSync(roots.configDir, { recursive: true, force: true });
1362
+ summary.removedFiles.push(roots.configDir);
1363
+ }
1364
+ catch { }
1365
+ }
1366
+ else {
1367
+ // Global install — preserve customer identity + publisher CLI state.
1368
+ // credentials.json + projects.json are the customer-side files we
1369
+ // documented as always-preserved. config.json + grants-cache.json are
1370
+ // owned by the publisher CLI (skillvault-publisher) — the customer-
1371
+ // side uninstall has no business deleting publisher login state.
1372
+ const PRESERVE = new Set([
1373
+ 'credentials.json',
1374
+ 'projects.json',
1375
+ 'config.json', // publisher CLI session
1376
+ 'grants-cache.json', // publisher CLI grant cache
1377
+ ]);
1378
+ for (const entry of readdirSync(roots.configDir)) {
1379
+ if (PRESERVE.has(entry))
1380
+ continue;
1381
+ const path = join(roots.configDir, entry);
1382
+ try {
1383
+ rmSync(path, { recursive: true, force: true });
1384
+ summary.removedFiles.push(path);
1385
+ }
1386
+ catch { }
1387
+ }
1388
+ }
1389
+ }
1390
+ // Skill stubs in every location install would have written them: always
1391
+ // <roots.claudeDir>/skills/, and (global mode) ~/.agents/skills/ + each
1392
+ // detected platform mirror.
1393
+ summary.removedStubs = removeAllSkillvaultStubs(roots);
1394
+ // Prune SkillVault entries from the shared lock file. In global mode this
1395
+ // is ~/.agents/.skill-lock.json which can also hold non-skillvault entries
1396
+ // (e.g. from skill-creator), so we preserve other entries.
1397
+ if (roots.scope === 'global') {
1398
+ pruneSkillvaultLockEntries(AGENTS_LOCK_PATH);
1399
+ }
1400
+ // Hooks in settings.json
1401
+ summary.removedHooks = scrubSkillvaultHooks(roots.settingsPath);
1402
+ // Registry: drop the entry if this was a project install. Use the install
1403
+ // path encoded in roots (not the literal CWD) so callers can uninstall a
1404
+ // different project's install too.
1405
+ if (roots.scope === 'project') {
1406
+ const installPath = roots.configDir.replace(/\/\.skillvault$/, '');
1407
+ try {
1408
+ unregisterProject(installPath);
1409
+ }
1410
+ catch { }
1411
+ }
1412
+ return summary;
1413
+ }
1414
+ async function handleUninstallCommand() {
1415
+ if (allFlag)
1416
+ return handleUninstallAllCommand();
1417
+ const roots = getActiveRoots();
1418
+ // If we're not in any registered install, refuse rather than scribbling.
1419
+ if (roots.scope === 'project' && !existsSync(roots.configDir)) {
1420
+ console.error(`🔐 No SkillVault project install at ${process.cwd()}.`);
1421
+ console.error(' (To uninstall global, run: npx skillvault uninstall --global)');
1422
+ process.exit(1);
1423
+ }
1424
+ const summary = uninstallInstall(roots);
1425
+ printUninstallSummary(summary);
1426
+ }
1427
+ async function handleUninstallAllCommand() {
1428
+ console.log('🔐 SkillVault uninstall --all\n');
1429
+ const summaries = [];
1430
+ // Walk every registered project install
1431
+ for (const entry of listProjects()) {
1432
+ const roots = resolveRoots('project', entry.path);
1433
+ console.log(` Uninstalling project: ${entry.path}`);
1434
+ summaries.push(uninstallInstall(roots));
1435
+ }
1436
+ // Also uninstall global, if there's anything to clean up.
1437
+ const globalRoots = resolveRoots('global');
1438
+ if (existsSync(globalRoots.configDir) || scrubSkillvaultHooks(globalRoots.settingsPath) > 0) {
1439
+ console.log(' Uninstalling global install: ~/.claude, ~/.skillvault');
1440
+ summaries.push(uninstallInstall(globalRoots));
1441
+ }
1442
+ // Finally remove the global credentials and the projects registry.
1443
+ // The customer is fully resetting; their identity comes back from a
1444
+ // fresh --invite next time.
1445
+ const credPath = join(HOME, '.skillvault', 'credentials.json');
1446
+ const regPath = join(HOME, '.skillvault', 'projects.json');
1447
+ for (const p of [credPath, regPath]) {
1448
+ try {
1449
+ if (existsSync(p)) {
1450
+ rmSync(p);
1451
+ console.log(` Removed ${p}`);
1452
+ }
1453
+ }
1454
+ catch { }
1455
+ }
1456
+ // Drop the now-empty ~/.skillvault dir if it's actually empty.
1457
+ try {
1458
+ const dir = join(HOME, '.skillvault');
1459
+ if (existsSync(dir) && readdirSync(dir).length === 0)
1460
+ rmSync(dir, { recursive: true, force: true });
1461
+ }
1462
+ catch { }
1463
+ console.log('');
1464
+ console.log(`Uninstalled ${summaries.length} install${summaries.length !== 1 ? 's' : ''}.`);
1465
+ }
1466
+ function printUninstallSummary(s) {
1467
+ console.log(`🔐 Uninstalled SkillVault (${s.scope}) at ${s.installPath}`);
1468
+ if (s.removedHooks > 0)
1469
+ console.log(` ✅ Removed ${s.removedHooks} hook entr${s.removedHooks !== 1 ? 'ies' : 'y'} from settings.json`);
1470
+ if (s.removedStubs.length > 0) {
1471
+ console.log(` ✅ Removed ${s.removedStubs.length} skill stub${s.removedStubs.length !== 1 ? 's' : ''}:`);
1472
+ for (const name of s.removedStubs)
1473
+ console.log(` - ${name}`);
1474
+ }
1475
+ if (s.removedFiles.length > 0)
1476
+ console.log(` ✅ Removed ${s.removedFiles.length} file${s.removedFiles.length !== 1 ? 's' : ''}/dir${s.removedFiles.length !== 1 ? 's' : ''}`);
1477
+ if (s.scope === 'global') {
1478
+ console.log(' ℹ️ ~/.skillvault/credentials.json and projects.json were preserved.');
1479
+ console.log(' ℹ️ Run `uninstall --all` to wipe those too.');
1480
+ }
1481
+ console.log('');
1482
+ }
1483
+ /**
1484
+ * `npx skillvault status` — extended version of --status with hook health
1485
+ * and an explicit scope/location header.
1486
+ */
1487
+ async function handleStatusSubcommand() {
1488
+ const roots = getActiveRoots();
1489
+ const credentials = loadCredentials();
1490
+ if (!credentials) {
1491
+ console.log('🔐 SkillVault — not configured.');
1492
+ console.log('');
1493
+ console.log(' To get started, run:');
1494
+ console.log(' npx skillvault --invite YOUR_INVITE_CODE');
1495
+ return;
1496
+ }
1497
+ const project = loadProjectConfig(roots);
1498
+ const registryEntry = roots.scope === 'project' ? getCurrentProject() : null;
1499
+ console.log('🔐 SkillVault Status\n');
1500
+ console.log(` Account: ${credentials.customer_email || '(unknown)'}`);
1501
+ console.log(` Scope: ${roots.scope}`);
1502
+ console.log(` Install path: ${roots.scope === 'project' ? process.cwd() : HOME}`);
1503
+ console.log(` Config dir: ${roots.configDir}`);
1504
+ console.log(` Settings: ${roots.settingsPath}`);
1505
+ console.log(` Server: ${credentials.api_url}`);
1506
+ if (registryEntry?.last_synced_at) {
1507
+ console.log(` Last sync: ${registryEntry.last_synced_at}`);
1508
+ }
1509
+ else if (project?.last_synced_at) {
1510
+ console.log(` Last sync: ${project.last_synced_at}`);
1511
+ }
1512
+ console.log('');
1513
+ // Hook health
1514
+ let hookHealth = 'no settings.json';
1515
+ if (existsSync(roots.settingsPath)) {
1516
+ try {
1517
+ const settings = JSON.parse(readFileSync(roots.settingsPath, 'utf8'));
1518
+ const hooks = settings.hooks || {};
1519
+ const skillvaultEvents = Object.entries(hooks)
1520
+ .filter(([_, entries]) => Array.isArray(entries) && entries.some(hasSkillvaultCommand))
1521
+ .map(([k]) => k);
1522
+ hookHealth = skillvaultEvents.length > 0
1523
+ ? `${skillvaultEvents.join(', ')}`
1524
+ : 'no skillvault hooks installed';
1525
+ }
1526
+ catch {
1527
+ hookHealth = 'invalid (could not parse)';
1528
+ }
1529
+ }
1530
+ console.log(` Hook health: ${hookHealth}`);
1531
+ console.log('');
1532
+ // Active publishers
1533
+ const activeIds = project?.active_publishers ?? credentials.publishers.map(p => p.id);
1534
+ console.log(` Active publishers (${activeIds.length} of ${credentials.publishers.length}):`);
1535
+ for (const pub of credentials.publishers) {
1536
+ const tag = activeIds.includes(pub.id) ? '✓' : ' ';
1537
+ console.log(` ${tag} ${pub.id.padEnd(20)} ${pub.name}`);
1538
+ }
1539
+ console.log('');
1540
+ }
1541
+ /**
1542
+ * Move a global install into the current project. Confirmation is required
1543
+ * before any destructive moves. Vault data, project.json, hooks, and skill
1544
+ * stubs are migrated; credentials and the registry are not touched (they're
1545
+ * already global).
1546
+ */
1547
+ async function handleMigrateToProjectCommand() {
1548
+ const globalRoots = resolveRoots('global');
1549
+ const projectRoots = resolveRoots('project', process.cwd());
1550
+ if (!existsSync(globalRoots.configDir) && !existsSync(globalRoots.settingsPath)) {
1551
+ console.error('🔐 No global install detected. Nothing to migrate.');
1552
+ return;
1553
+ }
1554
+ if (existsSync(projectRoots.configDir) && existsSync(join(projectRoots.configDir, 'project.json'))) {
1555
+ console.error(`🔐 Project install already exists at ${process.cwd()}.`);
1556
+ console.error(' Refusing to overwrite. Run `npx skillvault uninstall` first if you want to start fresh.');
1557
+ process.exit(1);
1558
+ }
1559
+ console.log('🔐 SkillVault migrate-to-project');
1560
+ console.log('');
1561
+ console.log(` Source: ${globalRoots.configDir}`);
1562
+ console.log(` ${globalRoots.settingsPath}`);
1563
+ console.log(` Target: ${projectRoots.configDir}`);
1564
+ console.log(` ${projectRoots.settingsPath}`);
1565
+ console.log('');
1566
+ if (!process.stdin.isTTY) {
1567
+ console.log(' (non-TTY: auto-confirming)');
1568
+ }
1569
+ else {
1570
+ process.stderr.write('Continue? [y/N]: ');
1571
+ const key = await new Promise((resolve) => {
1572
+ process.stdin.setRawMode(true);
1573
+ process.stdin.resume();
1574
+ process.stdin.setEncoding('utf8');
1575
+ process.stdin.once('data', (k) => {
1576
+ process.stdin.setRawMode(false);
1577
+ process.stdin.pause();
1578
+ resolve(k);
1579
+ });
1580
+ });
1581
+ console.error('');
1582
+ if (key !== 'y' && key !== 'Y') {
1583
+ console.log('Cancelled.');
1584
+ return;
1585
+ }
1586
+ }
1587
+ // Move <globalConfigDir>/{vaults,active-session.json,fingerprints} → projectConfigDir
1588
+ mkdirSync(projectRoots.configDir, { recursive: true });
1589
+ const moveItems = ['vaults', 'active-session.json', 'fingerprints', 'skill-lock.json'];
1590
+ for (const item of moveItems) {
1591
+ const src = join(globalRoots.configDir, item);
1592
+ const dst = join(projectRoots.configDir, item);
1593
+ if (!existsSync(src))
1594
+ continue;
1595
+ try {
1596
+ // Cross-device-safe: try rename, fall back to copy + remove.
1597
+ cpSync(src, dst, { recursive: true });
1598
+ rmSync(src, { recursive: true, force: true });
1599
+ console.log(` Moved: ${src} → ${dst}`);
1600
+ }
1601
+ catch (err) {
1602
+ console.error(` ⚠️ Could not move ${item}: ${err instanceof Error ? err.message : 'unknown'}`);
1603
+ }
1604
+ }
1605
+ // Move skill stubs from ~/.claude/skills/ to ./.claude/skills/
1606
+ const globalStubs = join(globalRoots.claudeDir, 'skills');
1607
+ const projectStubs = join(projectRoots.claudeDir, 'skills');
1608
+ mkdirSync(projectStubs, { recursive: true });
1609
+ if (existsSync(globalStubs)) {
1610
+ for (const entry of readdirSync(globalStubs)) {
1611
+ const src = join(globalStubs, entry);
1612
+ const manifestPath = join(src, 'manifest.json');
1613
+ if (!existsSync(manifestPath))
1614
+ continue;
1615
+ try {
1616
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1617
+ if (manifest.encrypted === true || manifest.publisher_id) {
1618
+ const dst = join(projectStubs, entry);
1619
+ cpSync(src, dst, { recursive: true });
1620
+ rmSync(src, { recursive: true, force: true });
1621
+ console.log(` Moved stub: ${entry}`);
1622
+ }
1623
+ }
1624
+ catch { }
1625
+ }
1626
+ }
1627
+ // Build project.json reflecting the migrated install. The active list is
1628
+ // every publisher in credentials (since global mode had them all active).
1629
+ const credentials = loadCredentials();
1630
+ const newProject = {
1631
+ active_publishers: credentials?.publishers.map(p => p.id) ?? [],
1632
+ installed_at: new Date().toISOString(),
1633
+ scope: 'project',
1634
+ install_path: pathResolve(process.cwd()),
1635
+ last_synced_at: new Date().toISOString(),
1636
+ };
1637
+ saveProjectConfig(projectRoots, newProject);
1638
+ // Reinstall hooks into the project settings.json (with current version).
1639
+ configureSessionHook(projectRoots);
1640
+ // Strip the old global hooks now that the project has them.
1641
+ scrubSkillvaultHooks(globalRoots.settingsPath);
1642
+ // Register the new project install.
1643
+ registerProject({
1644
+ path: pathResolve(process.cwd()),
1645
+ installed_at: newProject.installed_at,
1646
+ last_synced_at: newProject.last_synced_at,
1647
+ active_publishers: newProject.active_publishers,
1648
+ });
1649
+ console.log('');
1650
+ console.log(' ✅ Migration complete. Restart Claude Code to pick up the new hooks.');
1651
+ console.log('');
1652
+ }
825
1653
  // ── Vault Decryption (in-memory only, output to stdout) ──
826
1654
  const VAULT_MAGIC = Buffer.from([0x00, 0x53, 0x56, 0x54]);
827
1655
  /** Detect binary content by checking for null bytes or high ratio of non-printable chars */
@@ -875,16 +1703,30 @@ function decryptVault(data, cek) {
875
1703
  });
876
1704
  return { metadata, files };
877
1705
  }
878
- function resolveSkillPublisher(skillName, config) {
1706
+ function resolveSkillPublisher(skillName, config, roots = getActiveRoots()) {
879
1707
  for (const pub of config.publishers) {
880
- const vaultPath = join(VAULT_DIR, pub.id, `${skillName}.vault`);
1708
+ const vaultPath = join(roots.vaultDir, pub.id, `${skillName}.vault`);
881
1709
  if (existsSync(vaultPath))
882
1710
  return { publisher: pub, vaultPath };
883
1711
  }
884
- const legacyPath = join(VAULT_DIR, `${skillName}.vault`);
1712
+ // Legacy fallback: vaults that landed at <vaultDir>/<skill>.vault before the
1713
+ // per-publisher subdirectory layout existed.
1714
+ const legacyPath = join(roots.vaultDir, `${skillName}.vault`);
885
1715
  if (existsSync(legacyPath) && config.publishers.length > 0) {
886
1716
  return { publisher: config.publishers[0], vaultPath: legacyPath };
887
1717
  }
1718
+ // Cross-scope fallback: a customer running --load from CWD that's not the
1719
+ // install directory should still be able to find a vault from any known
1720
+ // install. Try the global roots if we're currently looking at a project,
1721
+ // and try every registered project if currently global.
1722
+ if (roots.scope === 'project') {
1723
+ const globalRoots = resolveRoots('global');
1724
+ for (const pub of config.publishers) {
1725
+ const vaultPath = join(globalRoots.vaultDir, pub.id, `${skillName}.vault`);
1726
+ if (existsSync(vaultPath))
1727
+ return { publisher: pub, vaultPath };
1728
+ }
1729
+ }
888
1730
  return null;
889
1731
  }
890
1732
  async function fetchCEK(skillName, publisherToken, apiUrl) {
@@ -1156,7 +1998,7 @@ function validateSkillName(name) {
1156
1998
  * Quick sync for a single skill — checks for vault update before decrypting.
1157
1999
  * Returns true if the vault was updated. Status goes to stderr.
1158
2000
  */
1159
- async function syncSingleSkill(skillName, pub, config) {
2001
+ async function syncSingleSkill(skillName, pub, config, roots = getActiveRoots()) {
1160
2002
  try {
1161
2003
  const capabilityName = `skill/${skillName.toLowerCase()}`;
1162
2004
  const res = await fetch(`${config.api_url}/skills/check-update?capability=${encodeURIComponent(capabilityName)}&current_version=0.0.0`, { signal: AbortSignal.timeout(5000) });
@@ -1164,7 +2006,7 @@ async function syncSingleSkill(skillName, pub, config) {
1164
2006
  return false;
1165
2007
  const data = await res.json();
1166
2008
  // Check if local vault hash matches
1167
- const vaultPath = join(VAULT_DIR, pub.id, `${skillName}.vault`);
2009
+ const vaultPath = join(roots.vaultDir, pub.id, `${skillName}.vault`);
1168
2010
  const hashPath = vaultPath + '.hash';
1169
2011
  if (existsSync(hashPath) && data.vault_hash) {
1170
2012
  const localHash = readFileSync(hashPath, 'utf8').trim();
@@ -1177,7 +2019,7 @@ async function syncSingleSkill(skillName, pub, config) {
1177
2019
  return false;
1178
2020
  const dlData = await dlRes.json();
1179
2021
  const vaultBuffer = Buffer.from(dlData.vault_data, 'base64');
1180
- mkdirSync(join(VAULT_DIR, pub.id), { recursive: true });
2022
+ mkdirSync(join(roots.vaultDir, pub.id), { recursive: true });
1181
2023
  writeFileSync(vaultPath, vaultBuffer, { mode: 0o600 });
1182
2024
  if (dlData.vault_hash)
1183
2025
  writeFileSync(hashPath, dlData.vault_hash, { mode: 0o600 });
@@ -1201,10 +2043,10 @@ async function syncSingleSkill(skillName, pub, config) {
1201
2043
  * Discovers new skills the customer has been granted since last sync.
1202
2044
  * Runs async — doesn't block the load operation.
1203
2045
  */
1204
- async function backgroundSyncAll(config) {
2046
+ async function backgroundSyncAll(_config, roots = getActiveRoots()) {
1205
2047
  try {
1206
- await syncSkills();
1207
- await installSkillStubs();
2048
+ await syncSkills(roots);
2049
+ await installSkillStubs(roots);
1208
2050
  }
1209
2051
  catch { } // non-fatal
1210
2052
  }
@@ -1216,14 +2058,15 @@ async function listSkillFiles(skillName) {
1216
2058
  console.error('Error: Invalid skill name.');
1217
2059
  process.exit(1);
1218
2060
  }
1219
- const config = loadConfig();
2061
+ const roots = getActiveRoots();
2062
+ const config = loadConfig(roots);
1220
2063
  if (!config) {
1221
2064
  console.error('Error: SkillVault is not configured. Run: npx skillvault --invite YOUR_CODE');
1222
2065
  process.exit(1);
1223
2066
  }
1224
- // Try local manifest cache first
1225
- const agentSkillDir = join(AGENTS_SKILLS_DIR, skillName);
1226
- const filesJsonPath = join(agentSkillDir, 'files.json');
2067
+ // Try local manifest cache first — look in this install's claude skills dir
2068
+ const installStubDir = join(roots.claudeDir, 'skills', skillName);
2069
+ const filesJsonPath = join(installStubDir, 'files.json');
1227
2070
  try {
1228
2071
  if (existsSync(filesJsonPath)) {
1229
2072
  const filesData = JSON.parse(readFileSync(filesJsonPath, 'utf8'));
@@ -1274,30 +2117,32 @@ async function loadSkill(skillName) {
1274
2117
  console.error('Example: npx skillvault --load my-skill-name');
1275
2118
  process.exit(1);
1276
2119
  }
1277
- const config = loadConfig();
2120
+ const roots = getActiveRoots();
2121
+ const config = loadConfig(roots);
1278
2122
  if (!config) {
1279
2123
  console.error('Error: SkillVault is not configured on this machine.');
1280
2124
  console.error('');
1281
2125
  console.error('To set up, you need an invite code from a skill publisher.');
1282
2126
  console.error('Run: npx skillvault --invite YOUR_INVITE_CODE');
1283
2127
  console.error('');
1284
- console.error('If you already set up SkillVault, the config file may be missing:');
1285
- console.error(` Expected: ${CONFIG_PATH}`);
2128
+ console.error('If you already set up SkillVault, the config files may be missing:');
2129
+ console.error(` Expected: ${join(roots.configDir, 'project.json')}`);
2130
+ console.error(` ~/.skillvault/credentials.json`);
1286
2131
  process.exit(1);
1287
2132
  }
1288
2133
  // Pre-load sync: ensure we have the latest vault for this skill
1289
- let resolved = resolveSkillPublisher(skillName, config);
2134
+ let resolved = resolveSkillPublisher(skillName, config, roots);
1290
2135
  if (resolved) {
1291
- await syncSingleSkill(skillName, resolved.publisher, config);
2136
+ await syncSingleSkill(skillName, resolved.publisher, config, roots);
1292
2137
  // Re-resolve in case the vault was just downloaded
1293
- resolved = resolveSkillPublisher(skillName, config);
2138
+ resolved = resolveSkillPublisher(skillName, config, roots);
1294
2139
  }
1295
2140
  else {
1296
2141
  // Skill not found locally — try a full sync first (may be a newly granted skill)
1297
2142
  console.error(`[sync] Skill "${skillName}" not found locally, syncing...`);
1298
- await syncSkills();
1299
- await installSkillStubs();
1300
- resolved = resolveSkillPublisher(skillName, config);
2143
+ await syncSkills(roots);
2144
+ await installSkillStubs(roots);
2145
+ resolved = resolveSkillPublisher(skillName, config, roots);
1301
2146
  }
1302
2147
  if (!resolved) {
1303
2148
  console.error(`Error: Skill "${skillName}" not found after syncing with server.`);
@@ -1308,10 +2153,10 @@ async function loadSkill(skillName) {
1308
2153
  console.error(' 3. Your token expired — refresh with: npx skillvault --refresh');
1309
2154
  console.error('');
1310
2155
  console.error('Available skills on this machine:');
1311
- const localConfig = loadConfig();
2156
+ const localConfig = loadConfig(roots);
1312
2157
  if (localConfig) {
1313
2158
  for (const pub of localConfig.publishers) {
1314
- const pubVaultDir = join(VAULT_DIR, pub.id);
2159
+ const pubVaultDir = join(roots.vaultDir, pub.id);
1315
2160
  try {
1316
2161
  if (existsSync(pubVaultDir)) {
1317
2162
  const vaults = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault'));
@@ -1327,7 +2172,7 @@ async function loadSkill(skillName) {
1327
2172
  process.exit(1);
1328
2173
  }
1329
2174
  // Kick off background sync for all other skills (non-blocking)
1330
- backgroundSyncAll(config).catch(() => { });
2175
+ backgroundSyncAll(config, roots).catch(() => { });
1331
2176
  // Fetch CEK — validates license on every load
1332
2177
  let cek;
1333
2178
  let licenseeId;
@@ -1451,7 +2296,7 @@ async function loadSkill(skillName) {
1451
2296
  }
1452
2297
  }
1453
2298
  // After successful decryption and output, inject session-scoped monitoring hooks
1454
- injectMonitoringHooks(skillName);
2299
+ injectMonitoringHooks(skillName, roots);
1455
2300
  }
1456
2301
  catch (err) {
1457
2302
  cek.fill(0);
@@ -1496,32 +2341,85 @@ async function reportSecurityEvent(eventType, skill, detail) {
1496
2341
  }
1497
2342
  }
1498
2343
  // ── Session-scoped hook handlers ──
1499
- const ACTIVE_SESSION_PATH = join(CONFIG_DIR, 'active-session.json');
1500
- const FINGERPRINT_DIR = join(CONFIG_DIR, 'fingerprints');
1501
2344
  const SESSION_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
1502
2345
  /**
1503
- * Load active session. Returns null if missing or expired.
2346
+ * Search for an active session at the active roots, then at every registered
2347
+ * project, then at the global location. Returns the first match (or null).
2348
+ *
2349
+ * Project-scope sessions guard themselves: if the install_path doesn't match
2350
+ * Claude Code's CWD, the session is treated as not-applicable for this hook
2351
+ * invocation (returned as null) so the hook exits silently.
1504
2352
  */
1505
- function loadActiveSession() {
2353
+ function findActiveSessionForCwd(cwd = process.cwd()) {
2354
+ const candidates = [];
2355
+ // Active roots (resolved from current CWD)
2356
+ const active = getActiveRoots();
2357
+ candidates.push(active);
2358
+ // All registered projects
1506
2359
  try {
1507
- if (!existsSync(ACTIVE_SESSION_PATH))
1508
- return null;
1509
- const session = JSON.parse(readFileSync(ACTIVE_SESSION_PATH, 'utf8'));
1510
- if (!session.skill || !session.expires_at)
1511
- return null;
1512
- if (new Date(session.expires_at).getTime() < Date.now()) {
1513
- try {
1514
- rmSync(ACTIVE_SESSION_PATH);
2360
+ const reg = listProjectsRegistry();
2361
+ for (const p of reg) {
2362
+ candidates.push(resolveRoots('project', p.path));
2363
+ }
2364
+ }
2365
+ catch { }
2366
+ // Global location as a final fallback
2367
+ candidates.push(resolveRoots('global'));
2368
+ const seen = new Set();
2369
+ for (const roots of candidates) {
2370
+ const path = join(roots.configDir, 'active-session.json');
2371
+ if (seen.has(path))
2372
+ continue;
2373
+ seen.add(path);
2374
+ if (!existsSync(path))
2375
+ continue;
2376
+ try {
2377
+ const session = JSON.parse(readFileSync(path, 'utf8'));
2378
+ if (!session.skill || !session.expires_at)
2379
+ continue;
2380
+ if (new Date(session.expires_at).getTime() < Date.now()) {
2381
+ try {
2382
+ rmSync(path);
2383
+ }
2384
+ catch { }
2385
+ continue;
1515
2386
  }
1516
- catch { }
1517
- return null;
2387
+ // Project-scope guard: only honor sessions that belong to this CWD's
2388
+ // install. A session in /tmp/proj-a should not fire when Claude Code
2389
+ // happens to be running in /tmp/proj-b.
2390
+ if (session.scope === 'project' && session.install_path) {
2391
+ if (pathResolve(session.install_path) !== pathResolve(cwd))
2392
+ continue;
2393
+ }
2394
+ return { data: session, path, roots };
2395
+ }
2396
+ catch {
2397
+ // Malformed session file — skip
1518
2398
  }
1519
- return session;
2399
+ }
2400
+ return null;
2401
+ }
2402
+ /** Re-export of projects-registry.listProjects so the helper above can read it. */
2403
+ function listProjectsRegistry() {
2404
+ // Lazy import-style read so this module compiles even when projects-registry
2405
+ // is not yet built. Falls back to an empty array on any error.
2406
+ try {
2407
+ return listProjects();
1520
2408
  }
1521
2409
  catch {
1522
- return null;
2410
+ return [];
1523
2411
  }
1524
2412
  }
2413
+ /**
2414
+ * Load active session in a backwards-compatible way.
2415
+ *
2416
+ * Returns the session data if a valid session exists for the current CWD.
2417
+ * Returns null if no valid session is found, the session has expired, or
2418
+ * the session belongs to a different project install.
2419
+ */
2420
+ function loadActiveSession() {
2421
+ return findActiveSessionForCwd()?.data ?? null;
2422
+ }
1525
2423
  /**
1526
2424
  * Extraction-intent keyword patterns for prompt-submit detection.
1527
2425
  */
@@ -1582,17 +2480,19 @@ function extractKeywords(text, max = 30) {
1582
2480
  return [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, max).map(([w]) => w);
1583
2481
  }
1584
2482
  /**
1585
- * Load fingerprint files from cache.
2483
+ * Load fingerprint files from cache. Looks under the active install's
2484
+ * configDir/fingerprints/ directory.
1586
2485
  */
1587
- function loadFingerprintCache() {
2486
+ function loadFingerprintCache(roots = getActiveRoots()) {
2487
+ const fingerprintDir = join(roots.configDir, 'fingerprints');
1588
2488
  try {
1589
- if (!existsSync(FINGERPRINT_DIR))
2489
+ if (!existsSync(fingerprintDir))
1590
2490
  return [];
1591
- const files = readdirSync(FINGERPRINT_DIR).filter(f => f.endsWith('.json'));
2491
+ const files = readdirSync(fingerprintDir).filter(f => f.endsWith('.json'));
1592
2492
  const fps = [];
1593
2493
  for (const file of files) {
1594
2494
  try {
1595
- const data = JSON.parse(readFileSync(join(FINGERPRINT_DIR, file), 'utf8'));
2495
+ const data = JSON.parse(readFileSync(join(fingerprintDir, file), 'utf8'));
1596
2496
  // Handle both encrypted and unencrypted formats
1597
2497
  if (data.capability && data.ngrams) {
1598
2498
  fps.push(data);
@@ -1729,11 +2629,23 @@ async function handleSessionKeepalive(evtType) {
1729
2629
  }
1730
2630
  /**
1731
2631
  * Session cleanup handler.
1732
- * Removes monitoring hooks from settings.json, deletes session + fingerprint cache.
2632
+ *
2633
+ * Locates the active session for Claude Code's CWD via findActiveSessionForCwd,
2634
+ * then removes monitoring hooks from THAT install's settings.json (not always
2635
+ * ~/.claude/settings.json — that was the bug in v0.9.x). Also deletes the
2636
+ * session marker and fingerprint cache for that install.
2637
+ *
2638
+ * If no session is found (e.g. session expired between load and cleanup, or
2639
+ * the user moved between projects), falls back to global ~/.claude so we
2640
+ * still scrub any leftover hooks from old global installs.
1733
2641
  */
1734
2642
  function handleSessionCleanup() {
1735
2643
  try {
1736
- const settingsPath = join(HOME, '.claude', 'settings.json');
2644
+ const located = findActiveSessionForCwd();
2645
+ const settingsPath = located?.data.settings_path
2646
+ || located?.roots.settingsPath
2647
+ || join(HOME, '.claude', 'settings.json');
2648
+ const cleanupRoots = located?.roots ?? resolveRoots('global');
1737
2649
  if (existsSync(settingsPath)) {
1738
2650
  const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1739
2651
  if (settings.hooks) {
@@ -1765,19 +2677,22 @@ function handleSessionCleanup() {
1765
2677
  }
1766
2678
  }
1767
2679
  }
1768
- // Delete active session
1769
- try {
1770
- if (existsSync(ACTIVE_SESSION_PATH))
1771
- rmSync(ACTIVE_SESSION_PATH);
2680
+ // Delete the active session marker we located (if any).
2681
+ if (located?.path) {
2682
+ try {
2683
+ if (existsSync(located.path))
2684
+ rmSync(located.path);
2685
+ }
2686
+ catch { }
1772
2687
  }
1773
- catch { }
1774
- // Delete fingerprint cache
2688
+ // Delete fingerprint cache for this install.
2689
+ const fingerprintDir = join(cleanupRoots.configDir, 'fingerprints');
1775
2690
  try {
1776
- if (existsSync(FINGERPRINT_DIR)) {
1777
- const files = readdirSync(FINGERPRINT_DIR);
2691
+ if (existsSync(fingerprintDir)) {
2692
+ const files = readdirSync(fingerprintDir);
1778
2693
  for (const file of files) {
1779
2694
  try {
1780
- rmSync(join(FINGERPRINT_DIR, file));
2695
+ rmSync(join(fingerprintDir, file));
1781
2696
  }
1782
2697
  catch { }
1783
2698
  }
@@ -1791,6 +2706,30 @@ function handleSessionCleanup() {
1791
2706
  }
1792
2707
  // ── Main ──
1793
2708
  async function main() {
2709
+ // Resolve active install roots from flags + CWD before dispatching commands.
2710
+ // Commands that don't pass roots explicitly will fall through to currentRoots.
2711
+ setActiveRoots(resolveActiveRoots({ global: globalFlag, project: projectFlag }));
2712
+ // Positional subcommands for management operations.
2713
+ switch (subcommand) {
2714
+ case 'publishers':
2715
+ await handlePublishersCommand();
2716
+ process.exit(0);
2717
+ case 'add-publisher':
2718
+ await handleAddPublisherCommand(subcommandArgs[0]);
2719
+ process.exit(0);
2720
+ case 'remove-publisher':
2721
+ await handleRemovePublisherCommand(subcommandArgs[0]);
2722
+ process.exit(0);
2723
+ case 'uninstall':
2724
+ await handleUninstallCommand();
2725
+ process.exit(0);
2726
+ case 'status':
2727
+ await handleStatusSubcommand();
2728
+ process.exit(0);
2729
+ case 'migrate-to-project':
2730
+ await handleMigrateToProjectCommand();
2731
+ process.exit(0);
2732
+ }
1794
2733
  // --session-keepalive: unified session-scoped hook handler
1795
2734
  if (sessionKeepaliveFlag) {
1796
2735
  await handleSessionKeepalive(eventType);
@@ -1827,7 +2766,32 @@ async function main() {
1827
2766
  process.exit(0);
1828
2767
  }
1829
2768
  if (inviteCode) {
1830
- await setup(inviteCode);
2769
+ // For --invite, scope is decided by explicit flags first, then CWD heuristics.
2770
+ // We do NOT use the existing-install detection here — `--invite` is the
2771
+ // moment the customer is choosing where to install.
2772
+ let inviteRoots = resolveRoots(resolveScope({ global: globalFlag, project: projectFlag, cwd: process.cwd() }), process.cwd());
2773
+ if (dryRunFlag) {
2774
+ setActiveRoots(inviteRoots);
2775
+ printDryRunPlan(inviteRoots);
2776
+ process.exit(0);
2777
+ }
2778
+ // Interactive confirmation. In a TTY, the customer sees the planned
2779
+ // writes and can press Y/n/g. In a non-TTY (CI, audit harness, scripts)
2780
+ // confirmInstall short-circuits to 'yes'. If --global or --project was
2781
+ // explicitly passed we trust the customer and skip the prompt.
2782
+ if (!globalFlag && !projectFlag) {
2783
+ const choice = await confirmInstall(inviteRoots.scope, inviteRoots);
2784
+ if (choice === 'no') {
2785
+ console.error('Cancelled. No files written.');
2786
+ process.exit(0);
2787
+ }
2788
+ if (choice === 'global' && inviteRoots.scope === 'project') {
2789
+ inviteRoots = resolveRoots('global');
2790
+ console.error('Switched to global install.');
2791
+ }
2792
+ }
2793
+ setActiveRoots(inviteRoots);
2794
+ await setup(inviteCode, inviteRoots);
1831
2795
  if (!statusFlag && !refreshFlag)
1832
2796
  process.exit(0);
1833
2797
  }
@@ -1836,26 +2800,38 @@ async function main() {
1836
2800
  process.exit(0);
1837
2801
  }
1838
2802
  if (refreshFlag) {
1839
- await refreshTokens();
2803
+ const roots = getActiveRoots();
2804
+ await refreshTokens(roots);
1840
2805
  console.error(' Syncing skills...\n');
1841
- await syncSkills();
1842
- const result = await installSkillStubs();
2806
+ await syncSkills(roots);
2807
+ const result = await installSkillStubs(roots);
1843
2808
  if (result.installed > 0)
1844
2809
  console.error(` ✅ ${result.installed} skill${result.installed !== 1 ? 's' : ''} updated`);
1845
2810
  console.error('');
1846
2811
  process.exit(0);
1847
2812
  }
1848
2813
  if (syncFlag) {
1849
- const config = loadConfig();
2814
+ const roots = getActiveRoots();
2815
+ const config = loadConfig(roots);
1850
2816
  if (!config) {
1851
2817
  console.error('🔐 Not set up. Run: npx skillvault --invite YOUR_CODE\n');
1852
2818
  process.exit(1);
1853
2819
  }
1854
- console.error('🔐 SkillVault Sync\n');
2820
+ console.error(`🔐 SkillVault Sync (${roots.scope})\n`);
1855
2821
  // Re-install hooks with current version (auto-upgrades pinned commands)
1856
- configureSessionHook();
1857
- await syncSkills();
1858
- const result = await installSkillStubs();
2822
+ configureSessionHook(roots);
2823
+ await syncSkills(roots);
2824
+ const result = await installSkillStubs(roots);
2825
+ // Update the registry's last_synced_at if this is a project install.
2826
+ if (roots.scope === 'project') {
2827
+ const proj = loadProjectConfig(roots);
2828
+ registerProject({
2829
+ path: pathResolve(process.cwd()),
2830
+ installed_at: proj?.installed_at || new Date().toISOString(),
2831
+ last_synced_at: new Date().toISOString(),
2832
+ active_publishers: proj?.active_publishers || config.publishers.map(p => p.id),
2833
+ });
2834
+ }
1859
2835
  console.error(`\n ✅ ${result.installed} installed, ${result.skipped} up to date\n`);
1860
2836
  process.exit(0);
1861
2837
  }
@@ -1877,6 +2853,27 @@ async function main() {
1877
2853
  console.log(' Learn more: https://app.getskillvault.com\n');
1878
2854
  }
1879
2855
  }
2856
+ /**
2857
+ * Print the planned writes for an invite without performing them. Used by
2858
+ * --dry-run for scripts and the audit suite to verify scope detection.
2859
+ */
2860
+ function printDryRunPlan(roots) {
2861
+ console.error('🔐 SkillVault Setup (DRY RUN — no files will be written)');
2862
+ console.error('');
2863
+ console.error(`Scope: ${roots.scope}`);
2864
+ console.error(`Location: ${roots.scope === 'project' ? process.cwd() : HOME}`);
2865
+ console.error('');
2866
+ console.error('Would write:');
2867
+ console.error(` ${roots.settingsPath} (Claude Code hooks)`);
2868
+ console.error(` ${join(roots.claudeDir, 'skills')}/<skill>/ (skill stubs)`);
2869
+ console.error(` ${join(roots.configDir, 'project.json')} (project config)`);
2870
+ console.error(` ${roots.vaultDir}/<publisher_id>/ (encrypted vaults)`);
2871
+ console.error('');
2872
+ console.error('Plus user-level (always global):');
2873
+ console.error(` ~/.skillvault/credentials.json (customer identity)`);
2874
+ console.error(` ~/.skillvault/projects.json (project install registry)`);
2875
+ console.error('');
2876
+ }
1880
2877
  main().catch((err) => {
1881
2878
  console.error('Fatal:', err);
1882
2879
  process.exit(1);