skillvault 0.11.0 → 0.11.2
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 +88 -10
- package/dist/credentials.js +65 -3
- package/dist/projects-registry.js +97 -17
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -24,7 +24,7 @@ import { resolveScope, resolveRoots, } from './scope.js';
|
|
|
24
24
|
import { loadCredentials, saveCredentials, loadProjectConfig, saveProjectConfig, } from './credentials.js';
|
|
25
25
|
import { registerProject, unregisterProject, getCurrentProject, listProjects, } from './projects-registry.js';
|
|
26
26
|
import { confirmInstall } from './prompts.js';
|
|
27
|
-
const VERSION = '0.11.
|
|
27
|
+
const VERSION = '0.11.2';
|
|
28
28
|
const HOME = process.env.HOME || process.env.USERPROFILE || '~';
|
|
29
29
|
const API_URL = process.env.SKILLVAULT_API_URL || 'https://api.getskillvault.com';
|
|
30
30
|
const CONFIG_DIR = join(HOME, '.skillvault');
|
|
@@ -1242,13 +1242,12 @@ function scrubSkillvaultHooks(settingsPath) {
|
|
|
1242
1242
|
return removed;
|
|
1243
1243
|
}
|
|
1244
1244
|
/**
|
|
1245
|
-
* Walk
|
|
1246
|
-
* has an `encrypted: true` flag (the
|
|
1247
|
-
* the list of removed skill names.
|
|
1248
|
-
*
|
|
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
1249
|
*/
|
|
1250
|
-
function
|
|
1251
|
-
const skillsDir = join(roots.claudeDir, 'skills');
|
|
1250
|
+
function removeStubsFromDir(skillsDir) {
|
|
1252
1251
|
if (!existsSync(skillsDir))
|
|
1253
1252
|
return [];
|
|
1254
1253
|
const removed = [];
|
|
@@ -1268,6 +1267,68 @@ function removeAllSkillvaultStubs(roots) {
|
|
|
1268
1267
|
}
|
|
1269
1268
|
return removed;
|
|
1270
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
|
+
}
|
|
1271
1332
|
/**
|
|
1272
1333
|
* Uninstall a single SkillVault install at the given roots.
|
|
1273
1334
|
*
|
|
@@ -1303,8 +1364,17 @@ function uninstallInstall(roots) {
|
|
|
1303
1364
|
catch { }
|
|
1304
1365
|
}
|
|
1305
1366
|
else {
|
|
1306
|
-
// Global install — preserve
|
|
1307
|
-
|
|
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
|
+
]);
|
|
1308
1378
|
for (const entry of readdirSync(roots.configDir)) {
|
|
1309
1379
|
if (PRESERVE.has(entry))
|
|
1310
1380
|
continue;
|
|
@@ -1317,8 +1387,16 @@ function uninstallInstall(roots) {
|
|
|
1317
1387
|
}
|
|
1318
1388
|
}
|
|
1319
1389
|
}
|
|
1320
|
-
// Skill stubs in
|
|
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.
|
|
1321
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
|
+
}
|
|
1322
1400
|
// Hooks in settings.json
|
|
1323
1401
|
summary.removedHooks = scrubSkillvaultHooks(roots.settingsPath);
|
|
1324
1402
|
// Registry: drop the entry if this was a project install. Use the install
|
package/dist/credentials.js
CHANGED
|
@@ -12,10 +12,63 @@
|
|
|
12
12
|
* <roots.configDir>/project.json (per install, project- or global-scoped)
|
|
13
13
|
* Active publishers in this specific install plus install metadata.
|
|
14
14
|
*/
|
|
15
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, openSync, closeSync, unlinkSync, } from 'node:fs';
|
|
16
16
|
import { join, dirname } from 'node:path';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
18
|
import { credentialsPath } from './scope.js';
|
|
19
|
+
/**
|
|
20
|
+
* Run `fn` while holding an exclusive advisory lock on `<targetPath>.lock`.
|
|
21
|
+
* Same primitive as projects-registry.ts withFileLock — duplicated here so
|
|
22
|
+
* credentials.ts can be locked without crossing module boundaries. See that
|
|
23
|
+
* file for the design notes (POSIX O_EXCL semantics, busy-wait backoff,
|
|
24
|
+
* 1s stale-lock reclaim).
|
|
25
|
+
*/
|
|
26
|
+
function withFileLock(targetPath, fn) {
|
|
27
|
+
const lockPath = targetPath + '.lock';
|
|
28
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
29
|
+
const start = Date.now();
|
|
30
|
+
const TIMEOUT_MS = 1000;
|
|
31
|
+
let fd = null;
|
|
32
|
+
while (fd === null) {
|
|
33
|
+
try {
|
|
34
|
+
fd = openSync(lockPath, 'wx', 0o600);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
const code = err.code;
|
|
38
|
+
if (code !== 'EEXIST')
|
|
39
|
+
throw err;
|
|
40
|
+
if (Date.now() - start > TIMEOUT_MS) {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(lockPath);
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
try {
|
|
46
|
+
fd = openSync(lockPath, 'wx', 0o600);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new Error(`Could not acquire lock at ${lockPath} after ${TIMEOUT_MS}ms`);
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
const buf = new SharedArrayBuffer(4);
|
|
54
|
+
const view = new Int32Array(buf);
|
|
55
|
+
Atomics.wait(view, 0, 0, 5 + Math.floor(Math.random() * 10));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return fn();
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
try {
|
|
63
|
+
closeSync(fd);
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
try {
|
|
67
|
+
unlinkSync(lockPath);
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
19
72
|
/**
|
|
20
73
|
* Load customer credentials from ~/.skillvault/credentials.json.
|
|
21
74
|
*
|
|
@@ -75,11 +128,20 @@ export function loadCredentials() {
|
|
|
75
128
|
}
|
|
76
129
|
/**
|
|
77
130
|
* Persist customer credentials to ~/.skillvault/credentials.json with mode 0600.
|
|
131
|
+
*
|
|
132
|
+
* Wrapped in withFileLock so two concurrent invites with different invite
|
|
133
|
+
* codes can't race and silently drop one of each other's publishers from
|
|
134
|
+
* the publishers[] array. The caller is expected to read-modify-write the
|
|
135
|
+
* full credentials object themselves; this function only serializes the
|
|
136
|
+
* write step. For full read-modify-write atomicity, callers should hold
|
|
137
|
+
* the lock across both load + save (see redeemInvite() in cli.ts).
|
|
78
138
|
*/
|
|
79
139
|
export function saveCredentials(creds) {
|
|
80
140
|
const path = credentialsPath();
|
|
81
|
-
|
|
82
|
-
|
|
141
|
+
withFileLock(path, () => {
|
|
142
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
143
|
+
writeFileSync(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
144
|
+
});
|
|
83
145
|
}
|
|
84
146
|
function projectConfigPath(roots) {
|
|
85
147
|
return join(roots.configDir, 'project.json');
|
|
@@ -6,10 +6,80 @@
|
|
|
6
6
|
* `getCurrentProject()` to figure out whether the current CWD already has a
|
|
7
7
|
* registered SkillVault install.
|
|
8
8
|
*/
|
|
9
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
|
|
9
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync, openSync, closeSync, unlinkSync, } from 'node:fs';
|
|
10
10
|
import { dirname, resolve } from 'node:path';
|
|
11
11
|
import { projectsRegistryPath } from './scope.js';
|
|
12
12
|
const REGISTRY_VERSION = 1;
|
|
13
|
+
/**
|
|
14
|
+
* Run `fn` while holding an exclusive advisory lock on `<targetPath>.lock`.
|
|
15
|
+
*
|
|
16
|
+
* Uses `openSync(..., 'wx')` to atomically create the lockfile (POSIX
|
|
17
|
+
* O_EXCL semantics — exactly one process succeeds, the rest get EEXIST).
|
|
18
|
+
* On contention, busy-waits with a small backoff up to ~1s before giving
|
|
19
|
+
* up. The lockfile is unlinked on completion (success or thrown error)
|
|
20
|
+
* via try/finally so a crash mid-operation can't permanently wedge
|
|
21
|
+
* subsequent invocations — though if a process is SIGKILL'd between the
|
|
22
|
+
* openSync and the closeSync, the lockfile will linger and the next
|
|
23
|
+
* caller will time out and either inherit the stale lock (forced) or
|
|
24
|
+
* throw. For our use case (a single-user CLI doing read-modify-write on
|
|
25
|
+
* a small JSON file), 1s is plenty.
|
|
26
|
+
*
|
|
27
|
+
* Used by registerProject + unregisterProject to fix the lost-update race
|
|
28
|
+
* when two `--invite` commands run concurrently against the same
|
|
29
|
+
* ~/.skillvault/projects.json (audit scenario 12-concurrent-install).
|
|
30
|
+
*/
|
|
31
|
+
function withFileLock(targetPath, fn) {
|
|
32
|
+
const lockPath = targetPath + '.lock';
|
|
33
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
34
|
+
const start = Date.now();
|
|
35
|
+
const TIMEOUT_MS = 1000;
|
|
36
|
+
let fd = null;
|
|
37
|
+
while (fd === null) {
|
|
38
|
+
try {
|
|
39
|
+
fd = openSync(lockPath, 'wx', 0o600);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const code = err.code;
|
|
43
|
+
if (code !== 'EEXIST')
|
|
44
|
+
throw err;
|
|
45
|
+
if (Date.now() - start > TIMEOUT_MS) {
|
|
46
|
+
// Stale lock — assume the previous holder crashed and reclaim it.
|
|
47
|
+
try {
|
|
48
|
+
unlinkSync(lockPath);
|
|
49
|
+
}
|
|
50
|
+
catch { }
|
|
51
|
+
// Loop one more time, but if it still fails, throw.
|
|
52
|
+
try {
|
|
53
|
+
fd = openSync(lockPath, 'wx', 0o600);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error(`Could not acquire lock at ${lockPath} after ${TIMEOUT_MS}ms`);
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
// Busy-wait briefly. The Node.js event loop is single-threaded so
|
|
61
|
+
// we can't yield to a peer process via Promise without making the
|
|
62
|
+
// function async — and registerProject is sync. Atomics.wait on a
|
|
63
|
+
// SharedArrayBuffer is the cleanest sync sleep:
|
|
64
|
+
const buf = new SharedArrayBuffer(4);
|
|
65
|
+
const view = new Int32Array(buf);
|
|
66
|
+
Atomics.wait(view, 0, 0, 5 + Math.floor(Math.random() * 10));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return fn();
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
try {
|
|
74
|
+
closeSync(fd);
|
|
75
|
+
}
|
|
76
|
+
catch { }
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(lockPath);
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
13
83
|
function readRegistry() {
|
|
14
84
|
const path = projectsRegistryPath();
|
|
15
85
|
if (!existsSync(path))
|
|
@@ -75,29 +145,39 @@ export function listProjects() {
|
|
|
75
145
|
/**
|
|
76
146
|
* Insert or update a project entry. Matching is by absolute path; if an entry
|
|
77
147
|
* with the same path already exists, it is replaced.
|
|
148
|
+
*
|
|
149
|
+
* The read-modify-write is wrapped in withFileLock so two concurrent
|
|
150
|
+
* `--invite` calls can't race and lose one of each other's updates.
|
|
78
151
|
*/
|
|
79
152
|
export function registerProject(entry) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
153
|
+
withFileLock(projectsRegistryPath(), () => {
|
|
154
|
+
const reg = readRegistry();
|
|
155
|
+
const normalized = { ...entry, path: resolve(entry.path) };
|
|
156
|
+
const idx = reg.projects.findIndex((p) => resolve(p.path) === normalized.path);
|
|
157
|
+
if (idx >= 0) {
|
|
158
|
+
reg.projects[idx] = normalized;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
reg.projects.push(normalized);
|
|
162
|
+
}
|
|
163
|
+
writeRegistry(reg);
|
|
164
|
+
});
|
|
90
165
|
}
|
|
91
166
|
/**
|
|
92
167
|
* Remove the project entry whose path matches. No-op if not present.
|
|
168
|
+
*
|
|
169
|
+
* Wrapped in withFileLock for the same reason as registerProject — a
|
|
170
|
+
* concurrent uninstall + register would otherwise race.
|
|
93
171
|
*/
|
|
94
172
|
export function unregisterProject(path) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
173
|
+
withFileLock(projectsRegistryPath(), () => {
|
|
174
|
+
const reg = readRegistry();
|
|
175
|
+
const target = resolve(path);
|
|
176
|
+
const before = reg.projects.length;
|
|
177
|
+
reg.projects = reg.projects.filter((p) => resolve(p.path) !== target);
|
|
178
|
+
if (reg.projects.length !== before)
|
|
179
|
+
writeRegistry(reg);
|
|
180
|
+
});
|
|
101
181
|
}
|
|
102
182
|
/**
|
|
103
183
|
* Look up the registry entry that matches the current working directory (or
|