@vruum/skills-operator 0.1.0 → 0.1.3
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/README.md +22 -3
- package/install.js +173 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @vruum/skills-operator
|
|
2
2
|
|
|
3
|
-
OAuth-gated installer for the Vruum operator skill bundle. If you're a Vruum operator (an account that owns 2+ companies in Vruum), this gives you the
|
|
3
|
+
OAuth-gated installer for the Vruum operator skill bundle. If you're a Vruum operator (an account that owns 2+ companies in Vruum), this gives you the 12 operator skills for Claude Code / Codex CLI with one command — no private GitHub access required.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @vruum/skills-operator install
|
|
@@ -12,7 +12,7 @@ npx @vruum/skills-operator install
|
|
|
12
12
|
2. Saves the session to `~/.vruum/auth.json` (mode 0600).
|
|
13
13
|
3. Downloads the latest operator skill tarball from `api.vruum.ai` (server verifies your operator role on every request).
|
|
14
14
|
4. Extracts to `~/.vruum/skills-operator/<sha>/`, updates `~/.vruum/skills-operator/current → <sha>/`.
|
|
15
|
-
5. Symlinks each skill into `~/.claude/skills/` and `~/.
|
|
15
|
+
5. Symlinks each skill into `~/.claude/skills/` and `~/.agents/skills/` via `current/` so rollback is atomic.
|
|
16
16
|
|
|
17
17
|
## Commands
|
|
18
18
|
|
|
@@ -47,7 +47,26 @@ Set `telemetry: community` (or `anonymous`) in `~/.vruum/config.yaml` to help su
|
|
|
47
47
|
|
|
48
48
|
## Pairs with
|
|
49
49
|
|
|
50
|
-
The Vruum MCP server at `https://api.vruum.ai/mcp`.
|
|
50
|
+
The Vruum MCP server at `https://api.vruum.ai/mcp`. The installed skills run as local workflows in your AI assistant; the MCP server provides the tools (including the `skill` tool, action=invoke) and data those workflows call.
|
|
51
|
+
|
|
52
|
+
**Register the MCP server in your assistant** (one-time, after install):
|
|
53
|
+
|
|
54
|
+
- **Claude Code** — add to `~/.claude.json`:
|
|
55
|
+
```json
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"vruum-local": { "type": "http", "url": "https://api.vruum.ai/mcp" }
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **Codex CLI** — add to `~/.codex/config.toml`:
|
|
62
|
+
```toml
|
|
63
|
+
[mcp_servers.vruum-local]
|
|
64
|
+
url = "https://api.vruum.ai/mcp"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **Other assistants** — connect to `https://api.vruum.ai/mcp` (HTTP transport, OAuth via standard MCP flow).
|
|
68
|
+
|
|
69
|
+
After registration, restart your assistant. Tools like `mcp__vruum-local__get_outreach_review` should appear in your tool list.
|
|
51
70
|
|
|
52
71
|
## Issues
|
|
53
72
|
|
package/install.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* - 300s listener timeout (enterprise SSO + MFA headroom).
|
|
26
26
|
* - Per-SHA subdirs under ~/.vruum/skills-operator/<sha>/.
|
|
27
27
|
* - `current` symlink sits alongside; skills in ~/.claude/skills/ and
|
|
28
|
-
* ~/.
|
|
28
|
+
* ~/.agents/skills/ point THROUGH current/ so rollback is one rename.
|
|
29
29
|
* - Windows without admin/dev-mode falls back to file copy + explicit message.
|
|
30
30
|
* - Lock file `.install.lock` contains PID; stale PID (`kill -0` fails) is
|
|
31
31
|
* reclaimed; live PID refuses.
|
|
@@ -69,8 +69,11 @@ const CONFIG_FILE = path.join(VRUUM_ROOT, 'config.yaml');
|
|
|
69
69
|
const SESSION_ID = crypto.randomUUID();
|
|
70
70
|
|
|
71
71
|
const HARNESSES = [
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
// `detect` = the harness config dir (its presence means the harness is
|
|
73
|
+
// installed); `dir` = where that harness reads skills. Codex reads the
|
|
74
|
+
// agentskills.io standard dir ~/.agents/skills (not ~/.codex/skills).
|
|
75
|
+
{ name: 'Claude Code', detect: path.join(os.homedir(), '.claude'), dir: path.join(os.homedir(), '.claude', 'skills') },
|
|
76
|
+
{ name: 'Codex CLI', detect: path.join(os.homedir(), '.codex'), dir: path.join(os.homedir(), '.agents', 'skills') },
|
|
74
77
|
];
|
|
75
78
|
|
|
76
79
|
// ─── Arg parsing ────────────────────────────────────────────────────────────
|
|
@@ -234,6 +237,49 @@ async function discoverAuthServer() {
|
|
|
234
237
|
});
|
|
235
238
|
}
|
|
236
239
|
|
|
240
|
+
async function registerClient(registrationEndpoint, redirectUri) {
|
|
241
|
+
// RFC 7591 Dynamic Client Registration. Supabase's auth server requires a
|
|
242
|
+
// client_id on authorize and token calls; it advertises a
|
|
243
|
+
// registration_endpoint in the OAuth metadata so public CLI apps can
|
|
244
|
+
// register themselves. We register a fresh public client per full auth flow
|
|
245
|
+
// (loopback redirect_uri varies by OS-picked port, so reuse isn't robust)
|
|
246
|
+
// and cache the returned client_id in auth.json for refresh calls.
|
|
247
|
+
const body = JSON.stringify({
|
|
248
|
+
client_name: 'Vruum Operator Skills CLI',
|
|
249
|
+
redirect_uris: [redirectUri],
|
|
250
|
+
token_endpoint_auth_method: 'none',
|
|
251
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
252
|
+
response_types: ['code'],
|
|
253
|
+
});
|
|
254
|
+
const parsed = new URL(registrationEndpoint);
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
const req = https.request({
|
|
257
|
+
method: 'POST',
|
|
258
|
+
hostname: parsed.hostname,
|
|
259
|
+
port: parsed.port || 443,
|
|
260
|
+
path: parsed.pathname + parsed.search,
|
|
261
|
+
headers: {
|
|
262
|
+
'Content-Type': 'application/json',
|
|
263
|
+
'Content-Length': Buffer.byteLength(body),
|
|
264
|
+
},
|
|
265
|
+
}, (res) => {
|
|
266
|
+
let data = '';
|
|
267
|
+
res.on('data', (c) => { data += c; });
|
|
268
|
+
res.on('end', () => {
|
|
269
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
270
|
+
try { resolve(JSON.parse(data)); }
|
|
271
|
+
catch { reject(new Error('Registration response is not JSON')); }
|
|
272
|
+
} else {
|
|
273
|
+
reject(new Error(`Client registration returned ${res.statusCode}: ${data}`));
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
req.on('error', reject);
|
|
278
|
+
req.write(body);
|
|
279
|
+
req.end();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
237
283
|
function tryOpenBrowser(urlToOpen) {
|
|
238
284
|
const cmd = process.platform === 'darwin' ? 'open'
|
|
239
285
|
: process.platform === 'win32' ? 'start'
|
|
@@ -251,6 +297,9 @@ async function loopbackAuthFlow() {
|
|
|
251
297
|
if (!meta.authorization_endpoint || !meta.token_endpoint) {
|
|
252
298
|
throw new Error('OAuth metadata missing authorization_endpoint or token_endpoint');
|
|
253
299
|
}
|
|
300
|
+
if (!meta.registration_endpoint) {
|
|
301
|
+
throw new Error('OAuth metadata missing registration_endpoint — cannot register public client');
|
|
302
|
+
}
|
|
254
303
|
|
|
255
304
|
const state = base64UrlEncode(crypto.randomBytes(32));
|
|
256
305
|
const verifier = base64UrlEncode(crypto.randomBytes(32));
|
|
@@ -259,12 +308,27 @@ async function loopbackAuthFlow() {
|
|
|
259
308
|
// Start listener on an OS-picked port.
|
|
260
309
|
return await new Promise((resolve, reject) => {
|
|
261
310
|
const server = http.createServer();
|
|
262
|
-
server.listen(0, '127.0.0.1', () => {
|
|
311
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
263
312
|
const { port } = server.address();
|
|
264
313
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
265
314
|
|
|
315
|
+
// Register a fresh public client for this auth flow. Supabase requires
|
|
316
|
+
// exact redirect_uri match, so we can't reuse a cached client_id across
|
|
317
|
+
// runs (the OS-picked port changes). The client_id is cached for
|
|
318
|
+
// refresh-token calls, which don't require a redirect_uri.
|
|
319
|
+
let clientId;
|
|
320
|
+
try {
|
|
321
|
+
const client = await registerClient(meta.registration_endpoint, redirectUri);
|
|
322
|
+
clientId = client.client_id;
|
|
323
|
+
} catch (e) {
|
|
324
|
+
server.close();
|
|
325
|
+
reject(new Error(`OAuth client registration failed: ${e.message}`));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
266
329
|
const authUrl = new URL(meta.authorization_endpoint);
|
|
267
330
|
authUrl.searchParams.set('response_type', 'code');
|
|
331
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
268
332
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
269
333
|
authUrl.searchParams.set('state', state);
|
|
270
334
|
authUrl.searchParams.set('code_challenge', challenge);
|
|
@@ -310,11 +374,13 @@ async function loopbackAuthFlow() {
|
|
|
310
374
|
code,
|
|
311
375
|
redirect_uri: redirectUri,
|
|
312
376
|
code_verifier: verifier,
|
|
377
|
+
client_id: clientId,
|
|
313
378
|
});
|
|
314
379
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
315
380
|
res.end('<html><body><h3>Signed in to Vruum.</h3><p>You can close this tab.</p></body></html>');
|
|
316
381
|
clearTimeout(timeout); server.close();
|
|
317
|
-
|
|
382
|
+
// Thread client_id through so writeAuthFile can persist it for refresh calls.
|
|
383
|
+
resolve({ ...tokens, _client_id: clientId });
|
|
318
384
|
} catch (e) {
|
|
319
385
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
320
386
|
res.end(`Token exchange failed: ${e.message}`);
|
|
@@ -370,6 +436,8 @@ function writeAuthFile(tokens) {
|
|
|
370
436
|
const payload = {
|
|
371
437
|
access_token: tokens.access_token,
|
|
372
438
|
refresh_token: tokens.refresh_token,
|
|
439
|
+
// client_id is required on Supabase refresh-token calls for public clients.
|
|
440
|
+
client_id: tokens.client_id || tokens._client_id,
|
|
373
441
|
expires_at: tokens.expires_at || (Math.floor(Date.now() / 1000) + (tokens.expires_in || 3600)),
|
|
374
442
|
token_type: tokens.token_type || 'bearer',
|
|
375
443
|
written_at: nowIso(),
|
|
@@ -390,10 +458,17 @@ async function refreshTokens(auth) {
|
|
|
390
458
|
if (!auth || !auth.refresh_token) return null;
|
|
391
459
|
const meta = await discoverAuthServer();
|
|
392
460
|
try {
|
|
393
|
-
|
|
461
|
+
const params = {
|
|
394
462
|
grant_type: 'refresh_token',
|
|
395
463
|
refresh_token: auth.refresh_token,
|
|
396
|
-
}
|
|
464
|
+
};
|
|
465
|
+
// Supabase requires client_id on public-client refresh calls. Auth files
|
|
466
|
+
// written before the DCR fix won't have it — missing client_id forces a
|
|
467
|
+
// full re-auth via loopbackAuthFlow().
|
|
468
|
+
if (auth.client_id) params.client_id = auth.client_id;
|
|
469
|
+
const tokens = await postTokenRequest(meta.token_endpoint, params);
|
|
470
|
+
// Preserve client_id across refresh writes.
|
|
471
|
+
return { ...tokens, client_id: auth.client_id };
|
|
397
472
|
} catch { return null; }
|
|
398
473
|
}
|
|
399
474
|
|
|
@@ -515,6 +590,72 @@ function linkSkill({ name, srcAbs, targetDir, force }) {
|
|
|
515
590
|
}
|
|
516
591
|
}
|
|
517
592
|
|
|
593
|
+
// A harness symlink is "ours" if its target resolves under
|
|
594
|
+
// ~/.vruum/skills-operator/ (skills are linked THROUGH current/). Boundary-safe:
|
|
595
|
+
// equality OR startsWith(OPERATOR_ROOT + path.sep) — never a substring
|
|
596
|
+
// `.includes`, so siblings like ~/.vruum/skills-operator-backup/… or
|
|
597
|
+
// /tmp/my-skills-operator-notes/… are NOT matched, and the public installer's
|
|
598
|
+
// ~/.vruum/skills/ links are excluded too. Resolved textually so a dangling
|
|
599
|
+
// target (the renamed-skill bug case) is still recognized.
|
|
600
|
+
function isOperatorLink(linkTarget) {
|
|
601
|
+
const resolved = path.resolve(linkTarget);
|
|
602
|
+
return resolved === OPERATOR_ROOT || resolved.startsWith(OPERATOR_ROOT + path.sep);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Remove links for skills no longer in the bundle (renamed/removed), but only
|
|
606
|
+
// symlinks this installer owns — never non-symlinks or foreign/public links.
|
|
607
|
+
function pruneStaleLinks({ targetDir, skillNames }) {
|
|
608
|
+
const results = [];
|
|
609
|
+
let entries;
|
|
610
|
+
try {
|
|
611
|
+
entries = fs.readdirSync(targetDir);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
if (err.code === 'ENOENT') return results;
|
|
614
|
+
throw err;
|
|
615
|
+
}
|
|
616
|
+
const current = new Set(skillNames);
|
|
617
|
+
for (const entry of entries) {
|
|
618
|
+
if (current.has(entry)) continue; // current skill — keep
|
|
619
|
+
const dst = path.join(targetDir, entry);
|
|
620
|
+
let linkTarget;
|
|
621
|
+
try {
|
|
622
|
+
const lstat = fs.lstatSync(dst);
|
|
623
|
+
if (!lstat.isSymbolicLink()) continue; // never touch non-symlinks
|
|
624
|
+
linkTarget = fs.readlinkSync(dst);
|
|
625
|
+
} catch (err) {
|
|
626
|
+
if (err.code === 'ENOENT') continue;
|
|
627
|
+
throw err;
|
|
628
|
+
}
|
|
629
|
+
if (!isOperatorLink(linkTarget)) continue; // foreign symlink — keep
|
|
630
|
+
fs.unlinkSync(dst);
|
|
631
|
+
results.push({ name: entry, action: 'pruned' });
|
|
632
|
+
}
|
|
633
|
+
return results;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Link each current skill into every detected harness, then prune our stale
|
|
637
|
+
// links. Called from doInstall (both the fresh-install path AND the
|
|
638
|
+
// already-up-to-date early return, so upgrading to this fixed installer
|
|
639
|
+
// reconciles on an unchanged SHA) and from doRollback (so rolling back to a
|
|
640
|
+
// build that lacks a newer skill prunes that now-dangling link).
|
|
641
|
+
function reconcileHarnessLinks({ skillsDir, skillNames, force = false }) {
|
|
642
|
+
for (const { name: harnessName, detect, dir: targetDirForHarness } of HARNESSES) {
|
|
643
|
+
if (!fs.existsSync(detect)) continue;
|
|
644
|
+
fs.mkdirSync(targetDirForHarness, { recursive: true });
|
|
645
|
+
const results = [];
|
|
646
|
+
for (const skillName of skillNames) {
|
|
647
|
+
const srcAbs = path.join(skillsDir, skillName);
|
|
648
|
+
results.push(linkSkill({ name: skillName, srcAbs, targetDir: targetDirForHarness, force }));
|
|
649
|
+
}
|
|
650
|
+
const linked = results.filter((r) => ['linked', 'relinked', 'copied', 'already-linked'].includes(r.action)).length;
|
|
651
|
+
const skipped = results.filter((r) => r.action === 'skipped');
|
|
652
|
+
console.log(`${harnessName}: ${linked}/${skillNames.length} skills ready`);
|
|
653
|
+
for (const s of skipped) console.log(` skipped ${s.name} — ${s.reason}`);
|
|
654
|
+
const pruned = pruneStaleLinks({ targetDir: targetDirForHarness, skillNames });
|
|
655
|
+
for (const p of pruned) console.log(` pruned ${p.name} (stale skill link)`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
518
659
|
function copyUpdateCheckShim() {
|
|
519
660
|
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
520
661
|
const src = path.join(__dirname, 'bin', 'vruum-skills-operator-update-check');
|
|
@@ -555,6 +696,15 @@ async function doInstall({ force = false } = {}) {
|
|
|
555
696
|
const currentSha = fs.existsSync(VERSION_FILE) ? fs.readFileSync(VERSION_FILE, 'utf8').trim() : null;
|
|
556
697
|
if (currentSha === sha) {
|
|
557
698
|
console.log(`already up to date (sha=${sha})`);
|
|
699
|
+
// Still reconcile harness links: upgrading to this fixed installer must
|
|
700
|
+
// prune stale renamed-skill links even when the SHA hasn't moved.
|
|
701
|
+
const skillsDir = path.join(CURRENT_LINK, 'skills');
|
|
702
|
+
if (fs.existsSync(skillsDir)) {
|
|
703
|
+
const skillNames = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
704
|
+
.filter((d) => d.isDirectory())
|
|
705
|
+
.map((d) => d.name);
|
|
706
|
+
reconcileHarnessLinks({ skillsDir, skillNames, force });
|
|
707
|
+
}
|
|
558
708
|
return;
|
|
559
709
|
}
|
|
560
710
|
|
|
@@ -623,18 +773,7 @@ async function doInstall({ force = false } = {}) {
|
|
|
623
773
|
.filter((d) => d.isDirectory())
|
|
624
774
|
.map((d) => d.name);
|
|
625
775
|
|
|
626
|
-
|
|
627
|
-
if (!fs.existsSync(targetDirForHarness)) continue;
|
|
628
|
-
const results = [];
|
|
629
|
-
for (const skillName of skillNames) {
|
|
630
|
-
const srcAbs = path.join(skillsDir, skillName);
|
|
631
|
-
results.push(linkSkill({ name: skillName, srcAbs, targetDir: targetDirForHarness, force }));
|
|
632
|
-
}
|
|
633
|
-
const linked = results.filter((r) => ['linked', 'relinked', 'copied', 'already-linked'].includes(r.action)).length;
|
|
634
|
-
const skipped = results.filter((r) => r.action === 'skipped');
|
|
635
|
-
console.log(`${harnessName}: ${linked}/${skillNames.length} skills ready`);
|
|
636
|
-
for (const s of skipped) console.log(` skipped ${s.name} — ${s.reason}`);
|
|
637
|
-
}
|
|
776
|
+
reconcileHarnessLinks({ skillsDir, skillNames, force });
|
|
638
777
|
|
|
639
778
|
fs.writeFileSync(VERSION_FILE, sha);
|
|
640
779
|
|
|
@@ -708,6 +847,17 @@ function doRollback({ to }) {
|
|
|
708
847
|
fail(`failed to flip current symlink: ${e.message}`, 'rollback', 'SymlinkError');
|
|
709
848
|
}
|
|
710
849
|
fs.writeFileSync(VERSION_FILE, targetSha);
|
|
850
|
+
|
|
851
|
+
// Reconcile harness links to the rolled-back build: a skill that existed only
|
|
852
|
+
// in the build we rolled away from now dangles, so prune it.
|
|
853
|
+
const skillsDir = path.join(CURRENT_LINK, 'skills');
|
|
854
|
+
if (fs.existsSync(skillsDir)) {
|
|
855
|
+
const skillNames = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
856
|
+
.filter((d) => d.isDirectory())
|
|
857
|
+
.map((d) => d.name);
|
|
858
|
+
reconcileHarnessLinks({ skillsDir, skillNames, force: false });
|
|
859
|
+
}
|
|
860
|
+
|
|
711
861
|
console.log(`rolled back to ${targetSha.slice(0, 12)}`);
|
|
712
862
|
}
|
|
713
863
|
|
|
@@ -726,7 +876,7 @@ function doUninstall() {
|
|
|
726
876
|
const lstat = fs.lstatSync(full);
|
|
727
877
|
if (!lstat.isSymbolicLink()) continue;
|
|
728
878
|
const target = fs.readlinkSync(full);
|
|
729
|
-
if (
|
|
879
|
+
if (isOperatorLink(target)) {
|
|
730
880
|
fs.unlinkSync(full);
|
|
731
881
|
console.log(`unlinked ${full}`);
|
|
732
882
|
}
|
|
@@ -772,5 +922,8 @@ module.exports = {
|
|
|
772
922
|
releaseLock,
|
|
773
923
|
linkSkill,
|
|
774
924
|
pruneOldShas,
|
|
925
|
+
isOperatorLink,
|
|
926
|
+
pruneStaleLinks,
|
|
927
|
+
reconcileHarnessLinks,
|
|
775
928
|
// exposed for tests
|
|
776
929
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vruum/skills-operator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "OAuth-gated installer for Vruum operator skills. Pulls the operator skill bundle from api.vruum.ai and symlinks it into your AI assistant's skill directory.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|