@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.
Files changed (3) hide show
  1. package/README.md +22 -3
  2. package/install.js +173 -20
  3. 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 11 operator skills for Claude Code / Codex CLI with one command — no private GitHub access required.
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 `~/.codex/skills/` via `current/` so rollback is atomic.
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`. Slash commands in your AI assistant turn into specialized workflows there.
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
- * ~/.codex/skills/ point THROUGH current/ so rollback is one rename.
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
- { name: 'Claude Code', dir: path.join(os.homedir(), '.claude', 'skills') },
73
- { name: 'Codex CLI', dir: path.join(os.homedir(), '.codex', 'skills') },
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
- resolve(tokens);
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
- return await postTokenRequest(meta.token_endpoint, {
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
- for (const { name: harnessName, dir: targetDirForHarness } of HARNESSES) {
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 (target.includes(OPERATOR_ROOT) || target.includes('skills-operator')) {
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.0",
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": {