@vruum/skills-operator 0.1.1 → 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 +97 -16
  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 ────────────────────────────────────────────────────────────
@@ -587,6 +590,72 @@ function linkSkill({ name, srcAbs, targetDir, force }) {
587
590
  }
588
591
  }
589
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
+
590
659
  function copyUpdateCheckShim() {
591
660
  fs.mkdirSync(BIN_DIR, { recursive: true });
592
661
  const src = path.join(__dirname, 'bin', 'vruum-skills-operator-update-check');
@@ -627,6 +696,15 @@ async function doInstall({ force = false } = {}) {
627
696
  const currentSha = fs.existsSync(VERSION_FILE) ? fs.readFileSync(VERSION_FILE, 'utf8').trim() : null;
628
697
  if (currentSha === sha) {
629
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
+ }
630
708
  return;
631
709
  }
632
710
 
@@ -695,18 +773,7 @@ async function doInstall({ force = false } = {}) {
695
773
  .filter((d) => d.isDirectory())
696
774
  .map((d) => d.name);
697
775
 
698
- for (const { name: harnessName, dir: targetDirForHarness } of HARNESSES) {
699
- if (!fs.existsSync(targetDirForHarness)) continue;
700
- const results = [];
701
- for (const skillName of skillNames) {
702
- const srcAbs = path.join(skillsDir, skillName);
703
- results.push(linkSkill({ name: skillName, srcAbs, targetDir: targetDirForHarness, force }));
704
- }
705
- const linked = results.filter((r) => ['linked', 'relinked', 'copied', 'already-linked'].includes(r.action)).length;
706
- const skipped = results.filter((r) => r.action === 'skipped');
707
- console.log(`${harnessName}: ${linked}/${skillNames.length} skills ready`);
708
- for (const s of skipped) console.log(` skipped ${s.name} — ${s.reason}`);
709
- }
776
+ reconcileHarnessLinks({ skillsDir, skillNames, force });
710
777
 
711
778
  fs.writeFileSync(VERSION_FILE, sha);
712
779
 
@@ -780,6 +847,17 @@ function doRollback({ to }) {
780
847
  fail(`failed to flip current symlink: ${e.message}`, 'rollback', 'SymlinkError');
781
848
  }
782
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
+
783
861
  console.log(`rolled back to ${targetSha.slice(0, 12)}`);
784
862
  }
785
863
 
@@ -798,7 +876,7 @@ function doUninstall() {
798
876
  const lstat = fs.lstatSync(full);
799
877
  if (!lstat.isSymbolicLink()) continue;
800
878
  const target = fs.readlinkSync(full);
801
- if (target.includes(OPERATOR_ROOT) || target.includes('skills-operator')) {
879
+ if (isOperatorLink(target)) {
802
880
  fs.unlinkSync(full);
803
881
  console.log(`unlinked ${full}`);
804
882
  }
@@ -844,5 +922,8 @@ module.exports = {
844
922
  releaseLock,
845
923
  linkSkill,
846
924
  pruneOldShas,
925
+ isOperatorLink,
926
+ pruneStaleLinks,
927
+ reconcileHarnessLinks,
847
928
  // exposed for tests
848
929
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vruum/skills-operator",
3
- "version": "0.1.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": {