@vruum/skills-operator 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vruum AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @vruum/skills-operator
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.
4
+
5
+ ```bash
6
+ npx @vruum/skills-operator install
7
+ ```
8
+
9
+ ## What it does
10
+
11
+ 1. Opens your browser for Vruum OAuth sign-in (PKCE + state, loopback redirect).
12
+ 2. Saves the session to `~/.vruum/auth.json` (mode 0600).
13
+ 3. Downloads the latest operator skill tarball from `api.vruum.ai` (server verifies your operator role on every request).
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.
16
+
17
+ ## Commands
18
+
19
+ ```bash
20
+ npx @vruum/skills-operator install # fetch latest
21
+ npx @vruum/skills-operator install --pin <sha> # fetch a specific build
22
+ npx @vruum/skills-operator install --force # override dual-install safety
23
+ npx @vruum/skills-operator update # alias for install
24
+ npx @vruum/skills-operator rollback [--to <sha>] # flip `current` to a prior build, no network
25
+ npx @vruum/skills-operator uninstall # remove symlinks + skills-operator/
26
+ ```
27
+
28
+ ## Auth
29
+
30
+ The first `install` triggers a loopback OAuth flow. Your session is cached at `~/.vruum/auth.json` and refreshes automatically. If the refresh token becomes invalid (password change, session revoked), re-run `install` to trigger fresh sign-in. The installer never silently wipes auth state — you always see an explicit prompt.
31
+
32
+ ## Role revocation
33
+
34
+ When an admin removes your operator role, the next update-check emits `OPERATOR_ROLE_REVOKED` inside the skill preamble. Existing installed skills keep working locally but can't upgrade until the role is restored.
35
+
36
+ ## Dual-install safety
37
+
38
+ If you also have `./.agents/setup` installed from a local `vruum_ai` checkout, the installer refuses to overwrite those symlinks without `--force`. The reverse is also true (`./.agents/setup` refuses over OAuth installs without `--force`). This protects the Jon-on-laptop case where both flows exist.
39
+
40
+ ## Windows
41
+
42
+ Symlinks require admin or dev-mode on Windows. Without either, the installer falls back to copying files — works fine, but `rollback` requires re-running `install`.
43
+
44
+ ## Telemetry (opt-in)
45
+
46
+ Set `telemetry: community` (or `anonymous`) in `~/.vruum/config.yaml` to help surface installer bugs. Payload is fixed-shape (`version`, `platform`, `node`, `phase`, `error_class`, `session_id`) and carries zero PII. Default is off.
47
+
48
+ ## Pairs with
49
+
50
+ The Vruum MCP server at `https://api.vruum.ai/mcp`. Slash commands in your AI assistant turn into specialized workflows there.
51
+
52
+ ## Issues
53
+
54
+ [github.com/vruum-gtm/skills-operator/issues](https://github.com/vruum-gtm/skills-operator/issues)
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # vruum-skills-operator-update-check — periodic version check for the operator skill bundle.
3
+ #
4
+ # Output (one line, or nothing):
5
+ # JUST_UPGRADED <old> <new> — marker left by a recent install
6
+ # UPGRADE_AVAILABLE <old> <new> — server advertises a newer SHA
7
+ # OPERATOR_ROLE_REVOKED — 403 from the HEAD endpoint
8
+ # (nothing) — up to date, cache fresh, or quiet failure
9
+ #
10
+ # Env overrides (for testing):
11
+ # VRUUM_STATE_DIR — override ~/.vruum state directory
12
+ # VRUUM_API_BASE — override api.vruum.ai
13
+ set -euo pipefail
14
+
15
+ STATE_DIR="${VRUUM_STATE_DIR:-$HOME/.vruum}"
16
+ API_BASE="${VRUUM_API_BASE:-https://api.vruum.ai}"
17
+ OP_ROOT="$STATE_DIR/skills-operator"
18
+ VERSION_FILE="$OP_ROOT/VERSION"
19
+ CACHE_FILE="$OP_ROOT/last-update-check"
20
+ MARKER_FILE="$OP_ROOT/just-upgraded-from"
21
+ AUTH_FILE="$STATE_DIR/auth.json"
22
+ CONFIG_FILE="$STATE_DIR/config.yaml"
23
+ CHECK_INTERVAL_SECS=3600 # 1h
24
+
25
+ # Step 0: if config disables update checks, bail.
26
+ if [ -f "$CONFIG_FILE" ] && grep -qE '^[[:space:]]*update_check[[:space:]]*:[[:space:]]*false' "$CONFIG_FILE"; then
27
+ exit 0
28
+ fi
29
+
30
+ # Just-upgraded marker wins once.
31
+ if [ -f "$MARKER_FILE" ]; then
32
+ OLD="$(cat "$MARKER_FILE" 2>/dev/null || true)"
33
+ rm -f "$MARKER_FILE"
34
+ NEW="$(cat "$VERSION_FILE" 2>/dev/null || echo '?')"
35
+ [ -n "$OLD" ] && echo "JUST_UPGRADED $OLD $NEW"
36
+ fi
37
+
38
+ [ -f "$VERSION_FILE" ] || exit 0
39
+ LOCAL="$(tr -d '[:space:]' < "$VERSION_FILE")"
40
+ [ -n "$LOCAL" ] || exit 0
41
+
42
+ # Force flag busts cache.
43
+ if [ "${1:-}" = "--force" ]; then
44
+ rm -f "$CACHE_FILE"
45
+ fi
46
+
47
+ # Cache short-circuit.
48
+ if [ -f "$CACHE_FILE" ]; then
49
+ last_check=$(stat -f %m "$CACHE_FILE" 2>/dev/null || stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0)
50
+ now=$(date +%s)
51
+ if [ $((now - last_check)) -lt "$CHECK_INTERVAL_SECS" ]; then
52
+ cached="$(cat "$CACHE_FILE" 2>/dev/null || true)"
53
+ [ -n "$cached" ] && echo "$cached"
54
+ exit 0
55
+ fi
56
+ fi
57
+
58
+ # Step 1: read access token; silent if missing.
59
+ [ -f "$AUTH_FILE" ] || exit 0
60
+ TOKEN="$(sed -nE 's/.*"access_token"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$AUTH_FILE" | head -1)"
61
+ [ -n "$TOKEN" ] || exit 0
62
+
63
+ # Step 2: HEAD /api/operator/skills/latest. Short timeout, quiet on any error.
64
+ HEADERS="$(curl -fsS -I --max-time 5 \
65
+ -H "Authorization: Bearer $TOKEN" \
66
+ "$API_BASE/api/operator/skills/latest" 2>/dev/null || true)"
67
+
68
+ if [ -z "$HEADERS" ]; then
69
+ # Network error or non-2xx. Check for 403 specifically so the preamble can
70
+ # surface role revocation even when curl would have returned non-zero.
71
+ STATUS=$(curl -o /dev/null -s -w "%{http_code}" -I --max-time 5 \
72
+ -H "Authorization: Bearer $TOKEN" \
73
+ "$API_BASE/api/operator/skills/latest" 2>/dev/null || echo "000")
74
+ if [ "$STATUS" = "403" ]; then
75
+ echo "OPERATOR_ROLE_REVOKED"
76
+ : > "$CACHE_FILE"
77
+ fi
78
+ exit 0
79
+ fi
80
+
81
+ REMOTE_SHA="$(echo "$HEADERS" | sed -nE 's/^[Xx]-[Vv]ruum-[Ss]kills-[Vv]ersion:[[:space:]]*([^[:space:]\r]+).*/\1/p' | head -1)"
82
+ [ -n "$REMOTE_SHA" ] || exit 0
83
+
84
+ if [ "$LOCAL" != "$REMOTE_SHA" ]; then
85
+ MSG="UPGRADE_AVAILABLE $LOCAL $REMOTE_SHA"
86
+ echo "$MSG" | tee "$CACHE_FILE" >/dev/null
87
+ echo "$MSG"
88
+ else
89
+ : > "$CACHE_FILE"
90
+ fi
package/install.js ADDED
@@ -0,0 +1,776 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @vruum/skills-operator installer.
4
+ *
5
+ * Fetches the operator skill bundle from api.vruum.ai (OAuth-gated, role-
6
+ * restricted to operators with 2+ linked companies) and symlinks each skill
7
+ * into the local AI assistant's skill directory.
8
+ *
9
+ * Subcommands:
10
+ * install — fetch latest, extract, symlink
11
+ * update — alias for install
12
+ * rollback [--to sha] — flip ~/.vruum/skills-operator/current to a prior SHA
13
+ * (local-only, works against SHAs you've installed before)
14
+ * uninstall — remove symlinks + ~/.vruum/skills-operator/
15
+ * help — print this text
16
+ *
17
+ * Note: `install --pin <sha>` is NOT supported. The backend streams the
18
+ * currently-deployed bundle only; historical SHAs aren't stored anywhere.
19
+ * The installer keeps 2 prior SHAs cached on-disk so `rollback` handles the
20
+ * operator-side recovery need without network.
21
+ *
22
+ * Design notes (referenced from the eng-review plan):
23
+ * - OAuth state param + PKCE on the loopback flow (RFC 8252 + CSRF defense).
24
+ * - 127.0.0.1:0 OS-picked loopback port (no collisions).
25
+ * - 300s listener timeout (enterprise SSO + MFA headroom).
26
+ * - Per-SHA subdirs under ~/.vruum/skills-operator/<sha>/.
27
+ * - `current` symlink sits alongside; skills in ~/.claude/skills/ and
28
+ * ~/.codex/skills/ point THROUGH current/ so rollback is one rename.
29
+ * - Windows without admin/dev-mode falls back to file copy + explicit message.
30
+ * - Lock file `.install.lock` contains PID; stale PID (`kill -0` fails) is
31
+ * reclaimed; live PID refuses.
32
+ * - Auth.json written atomically with mode 0600 (mkstemp + fsync + rename).
33
+ * - 401 silent refresh once; second 401 tells the user to re-run install.
34
+ *
35
+ * MIRRORED FROM @vruum/skills for the link/unlink/symlink logic; kept as a
36
+ * copy rather than a shared library because the two packages publish
37
+ * independently under different Trusted Publisher workflows.
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ const crypto = require('node:crypto');
43
+ const fs = require('node:fs');
44
+ const http = require('node:http');
45
+ const https = require('node:https');
46
+ const os = require('node:os');
47
+ const path = require('node:path');
48
+ const url = require('node:url');
49
+ const { spawn } = require('node:child_process');
50
+ const tar = require('tar');
51
+
52
+ const VERSION = require('./package.json').version;
53
+
54
+ const API_BASE = process.env.VRUUM_API_BASE || 'https://api.vruum.ai';
55
+ const SKILLS_ENDPOINT = `${API_BASE}/api/operator/skills/latest`;
56
+ const DISCOVERY_ENDPOINT = `${API_BASE}/.well-known/oauth-authorization-server`;
57
+ const INSTALL_ERROR_ENDPOINT = `${API_BASE}/api/operator/skills/install-errors`;
58
+ const LOOPBACK_TIMEOUT_MS = 300_000;
59
+ const DOWNLOAD_RETRIES = 3;
60
+
61
+ const VRUUM_ROOT = path.join(os.homedir(), '.vruum');
62
+ const AUTH_PATH = path.join(VRUUM_ROOT, 'auth.json');
63
+ const BIN_DIR = path.join(VRUUM_ROOT, 'bin');
64
+ const OPERATOR_ROOT = path.join(VRUUM_ROOT, 'skills-operator');
65
+ const CURRENT_LINK = path.join(OPERATOR_ROOT, 'current');
66
+ const VERSION_FILE = path.join(OPERATOR_ROOT, 'VERSION');
67
+ const LOCK_FILE = path.join(OPERATOR_ROOT, '.install.lock');
68
+ const CONFIG_FILE = path.join(VRUUM_ROOT, 'config.yaml');
69
+ const SESSION_ID = crypto.randomUUID();
70
+
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') },
74
+ ];
75
+
76
+ // ─── Arg parsing ────────────────────────────────────────────────────────────
77
+
78
+ function parseArgs(argv) {
79
+ const args = { command: 'install', to: null, force: false, help: false };
80
+ const rest = argv.slice(2);
81
+ let i = 0;
82
+ if (rest[i] && !rest[i].startsWith('-')) {
83
+ args.command = rest[i];
84
+ i += 1;
85
+ }
86
+ for (; i < rest.length; i += 1) {
87
+ const a = rest[i];
88
+ if (a === '--to') {
89
+ args.to = rest[i + 1];
90
+ if (!args.to) throw new Error('--to requires a sha');
91
+ i += 1;
92
+ } else if (a === '--force') {
93
+ args.force = true;
94
+ } else if (a === '--help' || a === '-h') {
95
+ args.help = true;
96
+ } else {
97
+ throw new Error(`unknown argument: ${a}`);
98
+ }
99
+ }
100
+ return args;
101
+ }
102
+
103
+ function printHelp() {
104
+ console.log(`@vruum/skills-operator v${VERSION}
105
+
106
+ Usage:
107
+ npx @vruum/skills-operator install Install latest operator skills
108
+ npx @vruum/skills-operator install --force Override dual-install safety check
109
+ npx @vruum/skills-operator update Alias for install
110
+ npx @vruum/skills-operator rollback [--to <sha>] Flip to a prior build, no network
111
+ npx @vruum/skills-operator uninstall Remove symlinks + ~/.vruum/skills-operator/
112
+ npx @vruum/skills-operator help Show this text
113
+
114
+ Requires: a Vruum account with operator role (owner on 2+ companies).
115
+ The installer opens your browser for OAuth on first run.
116
+
117
+ Pairs with the Vruum MCP server at ${API_BASE}/mcp.`);
118
+ }
119
+
120
+ // ─── Small utils ────────────────────────────────────────────────────────────
121
+
122
+ function expandTilde(p) {
123
+ return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
124
+ }
125
+
126
+ function nowIso() {
127
+ return new Date().toISOString();
128
+ }
129
+
130
+ function isShaLike(s) {
131
+ return typeof s === 'string' && /^[0-9a-f]{7,64}$/i.test(s);
132
+ }
133
+
134
+ function readConfigValue(key) {
135
+ if (!fs.existsSync(CONFIG_FILE)) return null;
136
+ const lines = fs.readFileSync(CONFIG_FILE, 'utf8').split('\n');
137
+ for (const line of lines) {
138
+ const m = line.match(/^\s*([^:#\s]+)\s*:\s*(.*?)\s*$/);
139
+ if (m && m[1] === key) return m[2];
140
+ }
141
+ return null;
142
+ }
143
+
144
+ async function postTelemetry(phase, errorClass) {
145
+ const telemetrySetting = readConfigValue('telemetry');
146
+ if (!telemetrySetting || telemetrySetting === 'off') return;
147
+ const payload = JSON.stringify({
148
+ version: VERSION,
149
+ platform: `${os.platform()}-${os.arch()}`,
150
+ node: process.version,
151
+ phase,
152
+ error_class: errorClass,
153
+ session_id: SESSION_ID,
154
+ });
155
+ try {
156
+ await new Promise((resolve) => {
157
+ const req = https.request(INSTALL_ERROR_ENDPOINT, {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
160
+ timeout: 5000,
161
+ }, (res) => { res.on('data', () => {}); res.on('end', resolve); });
162
+ req.on('error', () => resolve());
163
+ req.on('timeout', () => { req.destroy(); resolve(); });
164
+ req.end(payload);
165
+ });
166
+ } catch { /* telemetry is best-effort */ }
167
+ }
168
+
169
+ function fail(msg, phase = 'unknown', errorClass = 'InstallError') {
170
+ console.error(`\nError: ${msg}`);
171
+ postTelemetry(phase, errorClass).finally(() => process.exit(1));
172
+ }
173
+
174
+ // ─── Lock (with PID reclamation) ────────────────────────────────────────────
175
+
176
+ function acquireLock() {
177
+ fs.mkdirSync(OPERATOR_ROOT, { recursive: true });
178
+ for (let attempt = 0; attempt < 2; attempt += 1) {
179
+ try {
180
+ const fd = fs.openSync(LOCK_FILE, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
181
+ fs.writeSync(fd, String(process.pid));
182
+ fs.closeSync(fd);
183
+ return;
184
+ } catch (e) {
185
+ if (e.code !== 'EEXIST') throw e;
186
+ // Lock file exists — check if the holding PID is alive.
187
+ let holderPid = 0;
188
+ try { holderPid = parseInt(fs.readFileSync(LOCK_FILE, 'utf8').trim(), 10) || 0; }
189
+ catch { /* unreadable; treat as stale */ }
190
+ if (holderPid > 0) {
191
+ try { process.kill(holderPid, 0); /* alive */ throw new Error(`install already in progress (pid ${holderPid})`); }
192
+ catch (signalErr) {
193
+ if (signalErr.code === 'ESRCH' || !holderPid) {
194
+ console.warn(`stale lock reclaimed from pid ${holderPid}`);
195
+ fs.rmSync(LOCK_FILE, { force: true });
196
+ continue; // retry the creat
197
+ }
198
+ if (signalErr.message && signalErr.message.startsWith('install already')) throw signalErr;
199
+ // Other error (e.g. EPERM — process alive but owned by someone else): treat as alive.
200
+ throw new Error(`install already in progress (pid ${holderPid})`);
201
+ }
202
+ } else {
203
+ fs.rmSync(LOCK_FILE, { force: true });
204
+ }
205
+ }
206
+ }
207
+ throw new Error('could not acquire install lock');
208
+ }
209
+
210
+ function releaseLock() {
211
+ try { fs.rmSync(LOCK_FILE, { force: true }); } catch { /* ignore */ }
212
+ }
213
+
214
+ // ─── OAuth loopback ─────────────────────────────────────────────────────────
215
+
216
+ function base64UrlEncode(buf) {
217
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
218
+ }
219
+
220
+ async function discoverAuthServer() {
221
+ return new Promise((resolve, reject) => {
222
+ https.get(DISCOVERY_ENDPOINT, (res) => {
223
+ if (res.statusCode !== 200) {
224
+ reject(new Error(`OAuth discovery returned ${res.statusCode}`));
225
+ return;
226
+ }
227
+ let body = '';
228
+ res.on('data', (c) => { body += c; });
229
+ res.on('end', () => {
230
+ try { resolve(JSON.parse(body)); }
231
+ catch (e) { reject(new Error('OAuth discovery response is not JSON')); }
232
+ });
233
+ }).on('error', reject);
234
+ });
235
+ }
236
+
237
+ function tryOpenBrowser(urlToOpen) {
238
+ const cmd = process.platform === 'darwin' ? 'open'
239
+ : process.platform === 'win32' ? 'start'
240
+ : 'xdg-open';
241
+ try {
242
+ const child = spawn(cmd, [urlToOpen], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' });
243
+ child.on('error', () => {});
244
+ child.unref();
245
+ return true;
246
+ } catch { return false; }
247
+ }
248
+
249
+ async function loopbackAuthFlow() {
250
+ const meta = await discoverAuthServer();
251
+ if (!meta.authorization_endpoint || !meta.token_endpoint) {
252
+ throw new Error('OAuth metadata missing authorization_endpoint or token_endpoint');
253
+ }
254
+
255
+ const state = base64UrlEncode(crypto.randomBytes(32));
256
+ const verifier = base64UrlEncode(crypto.randomBytes(32));
257
+ const challenge = base64UrlEncode(crypto.createHash('sha256').update(verifier).digest());
258
+
259
+ // Start listener on an OS-picked port.
260
+ return await new Promise((resolve, reject) => {
261
+ const server = http.createServer();
262
+ server.listen(0, '127.0.0.1', () => {
263
+ const { port } = server.address();
264
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
265
+
266
+ const authUrl = new URL(meta.authorization_endpoint);
267
+ authUrl.searchParams.set('response_type', 'code');
268
+ authUrl.searchParams.set('redirect_uri', redirectUri);
269
+ authUrl.searchParams.set('state', state);
270
+ authUrl.searchParams.set('code_challenge', challenge);
271
+ authUrl.searchParams.set('code_challenge_method', 'S256');
272
+ // Scope omitted: Supabase tokens default audience is `authenticated`; the
273
+ // server-side role gate enforces operator access separately.
274
+
275
+ const timeout = setTimeout(() => {
276
+ server.close();
277
+ reject(new Error('OAuth callback timed out after 5 minutes. Re-run `install` to retry.'));
278
+ }, LOOPBACK_TIMEOUT_MS);
279
+
280
+ server.on('request', async (req, res) => {
281
+ const parsed = url.parse(req.url, true);
282
+ if (parsed.pathname !== '/callback') {
283
+ res.writeHead(404); res.end(); return;
284
+ }
285
+ const { state: returnedState, code, error, error_description } = parsed.query;
286
+ if (error) {
287
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
288
+ res.end(`OAuth error: ${error}${error_description ? ' — ' + error_description : ''}`);
289
+ clearTimeout(timeout); server.close();
290
+ reject(new Error(`OAuth server returned error: ${error}`));
291
+ return;
292
+ }
293
+ if (returnedState !== state) {
294
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
295
+ res.end('State parameter mismatch — possible CSRF attempt. Aborting.');
296
+ clearTimeout(timeout); server.close();
297
+ reject(new Error('OAuth state mismatch'));
298
+ return;
299
+ }
300
+ if (!code) {
301
+ res.writeHead(400); res.end('Missing code parameter.');
302
+ clearTimeout(timeout); server.close();
303
+ reject(new Error('OAuth callback missing code'));
304
+ return;
305
+ }
306
+ // Exchange code for token.
307
+ try {
308
+ const tokens = await postTokenRequest(meta.token_endpoint, {
309
+ grant_type: 'authorization_code',
310
+ code,
311
+ redirect_uri: redirectUri,
312
+ code_verifier: verifier,
313
+ });
314
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
315
+ res.end('<html><body><h3>Signed in to Vruum.</h3><p>You can close this tab.</p></body></html>');
316
+ clearTimeout(timeout); server.close();
317
+ resolve(tokens);
318
+ } catch (e) {
319
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
320
+ res.end(`Token exchange failed: ${e.message}`);
321
+ clearTimeout(timeout); server.close();
322
+ reject(e);
323
+ }
324
+ });
325
+
326
+ console.log('Opening browser for Vruum sign-in...');
327
+ console.log(`If the browser doesn't open, visit this URL manually:\n ${authUrl.toString()}\n`);
328
+ tryOpenBrowser(authUrl.toString());
329
+ });
330
+ server.on('error', (e) => reject(e));
331
+ });
332
+ }
333
+
334
+ function postTokenRequest(endpoint, params) {
335
+ return new Promise((resolve, reject) => {
336
+ const body = new URLSearchParams(params).toString();
337
+ const parsed = new URL(endpoint);
338
+ const req = https.request({
339
+ hostname: parsed.hostname,
340
+ port: parsed.port || 443,
341
+ path: parsed.pathname + parsed.search,
342
+ method: 'POST',
343
+ headers: {
344
+ 'Content-Type': 'application/x-www-form-urlencoded',
345
+ 'Content-Length': Buffer.byteLength(body),
346
+ },
347
+ }, (res) => {
348
+ let data = '';
349
+ res.on('data', (c) => { data += c; });
350
+ res.on('end', () => {
351
+ if (res.statusCode >= 200 && res.statusCode < 300) {
352
+ try { resolve(JSON.parse(data)); }
353
+ catch (e) { reject(new Error('Token response is not JSON')); }
354
+ } else {
355
+ reject(new Error(`Token endpoint returned ${res.statusCode}: ${data}`));
356
+ }
357
+ });
358
+ });
359
+ req.on('error', reject);
360
+ req.end(body);
361
+ });
362
+ }
363
+
364
+ // ─── Auth persistence ───────────────────────────────────────────────────────
365
+
366
+ function writeAuthFile(tokens) {
367
+ fs.mkdirSync(VRUUM_ROOT, { recursive: true });
368
+ const tmp = `${AUTH_PATH}.tmp-${process.pid}`;
369
+ const fd = fs.openSync(tmp, fs.constants.O_CREAT | fs.constants.O_WRONLY | fs.constants.O_EXCL, 0o600);
370
+ const payload = {
371
+ access_token: tokens.access_token,
372
+ refresh_token: tokens.refresh_token,
373
+ expires_at: tokens.expires_at || (Math.floor(Date.now() / 1000) + (tokens.expires_in || 3600)),
374
+ token_type: tokens.token_type || 'bearer',
375
+ written_at: nowIso(),
376
+ };
377
+ fs.writeSync(fd, JSON.stringify(payload, null, 2));
378
+ fs.fsyncSync(fd);
379
+ fs.closeSync(fd);
380
+ fs.renameSync(tmp, AUTH_PATH);
381
+ }
382
+
383
+ function readAuthFile() {
384
+ if (!fs.existsSync(AUTH_PATH)) return null;
385
+ try { return JSON.parse(fs.readFileSync(AUTH_PATH, 'utf8')); }
386
+ catch { return null; }
387
+ }
388
+
389
+ async function refreshTokens(auth) {
390
+ if (!auth || !auth.refresh_token) return null;
391
+ const meta = await discoverAuthServer();
392
+ try {
393
+ return await postTokenRequest(meta.token_endpoint, {
394
+ grant_type: 'refresh_token',
395
+ refresh_token: auth.refresh_token,
396
+ });
397
+ } catch { return null; }
398
+ }
399
+
400
+ async function ensureAuth() {
401
+ let auth = readAuthFile();
402
+ const now = Math.floor(Date.now() / 1000);
403
+ if (auth && auth.access_token && auth.expires_at && auth.expires_at > now + 30) {
404
+ return auth;
405
+ }
406
+ if (auth && auth.refresh_token) {
407
+ const refreshed = await refreshTokens(auth);
408
+ if (refreshed) {
409
+ writeAuthFile(refreshed);
410
+ return readAuthFile();
411
+ }
412
+ }
413
+ // Trigger interactive loopback OAuth.
414
+ console.log('No valid session found — launching browser for Vruum sign-in.');
415
+ const tokens = await loopbackAuthFlow();
416
+ writeAuthFile(tokens);
417
+ return readAuthFile();
418
+ }
419
+
420
+ // ─── HTTP to Vruum API ──────────────────────────────────────────────────────
421
+
422
+ function httpRequest({ method, urlString, headers = {}, outputStream = null }) {
423
+ return new Promise((resolve, reject) => {
424
+ const parsed = new URL(urlString);
425
+ const proto = parsed.protocol === 'http:' ? http : https;
426
+ const req = proto.request({
427
+ method,
428
+ hostname: parsed.hostname,
429
+ port: parsed.port || (parsed.protocol === 'http:' ? 80 : 443),
430
+ path: parsed.pathname + parsed.search,
431
+ headers,
432
+ }, (res) => {
433
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
434
+ // Follow one redirect (S3 signed URL redirect).
435
+ httpRequest({ method, urlString: res.headers.location, headers, outputStream })
436
+ .then(resolve, reject);
437
+ return;
438
+ }
439
+ if (outputStream) {
440
+ res.pipe(outputStream);
441
+ outputStream.on('finish', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: null }));
442
+ outputStream.on('error', reject);
443
+ } else {
444
+ let body = '';
445
+ res.on('data', (c) => { body += c; });
446
+ res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body }));
447
+ }
448
+ });
449
+ req.on('error', reject);
450
+ req.end();
451
+ });
452
+ }
453
+
454
+ async function fetchHeadWithAuth(endpoint) {
455
+ const auth = await ensureAuth();
456
+ let resp = await httpRequest({
457
+ method: 'HEAD',
458
+ urlString: endpoint,
459
+ headers: { Authorization: `Bearer ${auth.access_token}` },
460
+ });
461
+ if (resp.statusCode === 401) {
462
+ const refreshed = await refreshTokens(readAuthFile());
463
+ if (refreshed) {
464
+ writeAuthFile(refreshed);
465
+ const newAuth = readAuthFile();
466
+ resp = await httpRequest({
467
+ method: 'HEAD',
468
+ urlString: endpoint,
469
+ headers: { Authorization: `Bearer ${newAuth.access_token}` },
470
+ });
471
+ }
472
+ }
473
+ return resp;
474
+ }
475
+
476
+ // ─── Link / unlink (MIRRORED from @vruum/skills) ────────────────────────────
477
+
478
+ function ensureDirExists(dir) {
479
+ if (!fs.existsSync(dir)) return false;
480
+ return true;
481
+ }
482
+
483
+ function linkSkill({ name, srcAbs, targetDir, force }) {
484
+ const dst = path.join(targetDir, name);
485
+ let existing = null;
486
+ try {
487
+ const lstat = fs.lstatSync(dst);
488
+ if (lstat.isSymbolicLink()) existing = { kind: 'symlink', target: fs.readlinkSync(dst) };
489
+ else existing = { kind: lstat.isDirectory() ? 'dir' : 'file' };
490
+ } catch (err) {
491
+ if (err.code !== 'ENOENT') throw err;
492
+ }
493
+
494
+ if (existing?.kind === 'symlink' && existing.target === srcAbs) {
495
+ return { name, action: 'already-linked' };
496
+ }
497
+ if (existing?.kind === 'symlink' && existing.target.includes(path.join('.agents', 'skills')) && !force) {
498
+ return { name, action: 'skipped', reason: `detected .agents/setup symlink; re-run with --force to override` };
499
+ }
500
+ if (existing && existing.kind !== 'symlink') {
501
+ return { name, action: 'skipped', reason: `non-symlink ${existing.kind} already at ${dst}` };
502
+ }
503
+
504
+ if (existing?.kind === 'symlink') fs.unlinkSync(dst);
505
+ try {
506
+ fs.symlinkSync(srcAbs, dst, 'dir');
507
+ return { name, action: existing ? 'relinked' : 'linked' };
508
+ } catch (e) {
509
+ if (e.code === 'EPERM' && process.platform === 'win32') {
510
+ // Windows without admin/dev-mode. Fall back to copy.
511
+ fs.cpSync(srcAbs, dst, { recursive: true });
512
+ return { name, action: 'copied', reason: 'Windows symlinks require admin or dev-mode; copied files instead. Re-run `install` to upgrade.' };
513
+ }
514
+ throw e;
515
+ }
516
+ }
517
+
518
+ function copyUpdateCheckShim() {
519
+ fs.mkdirSync(BIN_DIR, { recursive: true });
520
+ const src = path.join(__dirname, 'bin', 'vruum-skills-operator-update-check');
521
+ const dst = path.join(BIN_DIR, 'vruum-skills-operator-update-check');
522
+ if (fs.existsSync(src)) {
523
+ fs.copyFileSync(src, dst);
524
+ try { fs.chmodSync(dst, 0o755); } catch { /* Windows: no-op */ }
525
+ }
526
+ }
527
+
528
+ // ─── Install ────────────────────────────────────────────────────────────────
529
+
530
+ async function doInstall({ force = false } = {}) {
531
+ const endpoint = SKILLS_ENDPOINT;
532
+ fs.mkdirSync(OPERATOR_ROOT, { recursive: true });
533
+ acquireLock();
534
+ try {
535
+ const head = await fetchHeadWithAuth(endpoint);
536
+ if (head.statusCode === 401) {
537
+ fail('Your session expired. Run `install` again to re-authenticate.', 'auth', 'SessionExpired');
538
+ }
539
+ if (head.statusCode === 403) {
540
+ fail('Your account does not have operator role. Contact admin.', 'auth', 'RoleRequired');
541
+ }
542
+ if (head.statusCode === 404) {
543
+ fail('No operator skill bundle has been published yet.', 'head', 'NotFound');
544
+ }
545
+ if (head.statusCode !== 200) {
546
+ fail(`HEAD ${endpoint} returned ${head.statusCode}`, 'head', 'HttpError');
547
+ }
548
+ const sha = head.headers['x-vruum-skills-version'];
549
+ const sha256 = head.headers['x-vruum-skills-sha256'];
550
+ if (!sha || !sha256) {
551
+ fail('Server response missing X-Vruum-Skills-Version / Sha256 headers.', 'head', 'ProtocolError');
552
+ }
553
+
554
+ // Short-circuit if we already have this SHA.
555
+ const currentSha = fs.existsSync(VERSION_FILE) ? fs.readFileSync(VERSION_FILE, 'utf8').trim() : null;
556
+ if (currentSha === sha) {
557
+ console.log(`already up to date (sha=${sha})`);
558
+ return;
559
+ }
560
+
561
+ // Download to a tmp path, verify, extract to ~/.vruum/skills-operator/<sha>/.
562
+ const targetDir = path.join(OPERATOR_ROOT, sha);
563
+ fs.mkdirSync(targetDir, { recursive: true });
564
+ const tarballPath = path.join(OPERATOR_ROOT, `.download-${process.pid}.tgz`);
565
+ const auth = await ensureAuth();
566
+ let lastErr = null;
567
+ for (let attempt = 0; attempt < DOWNLOAD_RETRIES; attempt += 1) {
568
+ try {
569
+ const out = fs.createWriteStream(tarballPath);
570
+ await httpRequest({
571
+ method: 'GET',
572
+ urlString: endpoint,
573
+ headers: { Authorization: `Bearer ${auth.access_token}` },
574
+ outputStream: out,
575
+ });
576
+ lastErr = null;
577
+ break;
578
+ } catch (e) {
579
+ lastErr = e;
580
+ console.warn(`download attempt ${attempt + 1} failed: ${e.message}`);
581
+ }
582
+ }
583
+ if (lastErr) fail(`download failed: ${lastErr.message}`, 'download', 'DownloadFailed');
584
+
585
+ // SHA256 verify.
586
+ const actualSha256 = await new Promise((resolve, reject) => {
587
+ const hash = crypto.createHash('sha256');
588
+ const s = fs.createReadStream(tarballPath);
589
+ s.on('data', (c) => hash.update(c));
590
+ s.on('end', () => resolve(hash.digest('hex')));
591
+ s.on('error', reject);
592
+ });
593
+ if (actualSha256 !== sha256) {
594
+ try { fs.rmSync(tarballPath, { force: true }); } catch {}
595
+ fail(`SHA256 mismatch (expected ${sha256}, got ${actualSha256}). Partial download deleted.`, 'verify', 'Sha256Mismatch');
596
+ }
597
+
598
+ // Extract.
599
+ await tar.x({ file: tarballPath, cwd: targetDir, strip: 0 });
600
+ try { fs.rmSync(tarballPath, { force: true }); } catch {}
601
+
602
+ // Flip `current` atomically.
603
+ const tmpLink = `${CURRENT_LINK}.tmp-${process.pid}`;
604
+ try { fs.rmSync(tmpLink, { force: true }); } catch {}
605
+ try {
606
+ fs.symlinkSync(targetDir, tmpLink, 'dir');
607
+ fs.renameSync(tmpLink, CURRENT_LINK);
608
+ } catch (e) {
609
+ if (e.code === 'EPERM' && process.platform === 'win32') {
610
+ // Windows without admin: fall back to plain file (no rollback support).
611
+ if (fs.existsSync(CURRENT_LINK)) fs.rmSync(CURRENT_LINK, { recursive: true, force: true });
612
+ fs.cpSync(targetDir, CURRENT_LINK, { recursive: true });
613
+ console.warn('Windows symlinks unavailable — copied into current/. Rollback requires re-running install.');
614
+ } else {
615
+ throw e;
616
+ }
617
+ }
618
+
619
+ // Symlink each skill into harnesses via current/.
620
+ const skillsDir = path.join(CURRENT_LINK, 'skills');
621
+ if (!fs.existsSync(skillsDir)) fail(`bundle missing skills/ directory at ${skillsDir}`, 'extract', 'BundleShape');
622
+ const skillNames = fs.readdirSync(skillsDir, { withFileTypes: true })
623
+ .filter((d) => d.isDirectory())
624
+ .map((d) => d.name);
625
+
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
+ }
638
+
639
+ fs.writeFileSync(VERSION_FILE, sha);
640
+
641
+ // Drop the update-check shim into ~/.vruum/bin/.
642
+ copyUpdateCheckShim();
643
+
644
+ // Prune old SHAs, keep current + 2 prior.
645
+ pruneOldShas(2);
646
+
647
+ console.log(`\nOperator skills v${sha.slice(0, 12)} installed.`);
648
+ } finally {
649
+ releaseLock();
650
+ }
651
+ }
652
+
653
+ function pruneOldShas(keep) {
654
+ if (!fs.existsSync(OPERATOR_ROOT)) return;
655
+ // Compare against current's realpath by name, not by absolute path, because
656
+ // macOS canonicalizes /var → /private/var and string-compare would lie.
657
+ let currentSha = null;
658
+ if (fs.existsSync(CURRENT_LINK)) {
659
+ try { currentSha = path.basename(fs.realpathSync(CURRENT_LINK)); }
660
+ catch {}
661
+ }
662
+ const entries = fs.readdirSync(OPERATOR_ROOT, { withFileTypes: true })
663
+ .filter((d) => d.isDirectory() && isShaLike(d.name))
664
+ .map((d) => ({ name: d.name, path: path.join(OPERATOR_ROOT, d.name) }))
665
+ .map((d) => ({ ...d, mtime: fs.statSync(d.path).mtimeMs }));
666
+ entries.sort((a, b) => b.mtime - a.mtime);
667
+ let kept = 0;
668
+ for (const e of entries) {
669
+ if (e.name === currentSha) continue;
670
+ kept += 1;
671
+ if (kept > keep) {
672
+ try { fs.rmSync(e.path, { recursive: true, force: true }); } catch {}
673
+ }
674
+ }
675
+ }
676
+
677
+ // ─── Rollback ───────────────────────────────────────────────────────────────
678
+
679
+ function doRollback({ to }) {
680
+ if (!fs.existsSync(OPERATOR_ROOT)) fail('No operator skills installed yet.', 'rollback', 'NotInstalled');
681
+ const entries = fs.readdirSync(OPERATOR_ROOT, { withFileTypes: true })
682
+ .filter((d) => d.isDirectory() && isShaLike(d.name))
683
+ .map((d) => d.name);
684
+ if (entries.length === 0) fail('No historical builds to roll back to.', 'rollback', 'NoHistory');
685
+
686
+ let targetSha = to;
687
+ if (!targetSha) {
688
+ // Pick the most-recently-modified non-current build.
689
+ const current = fs.existsSync(CURRENT_LINK) ? path.basename(fs.realpathSync(CURRENT_LINK)) : null;
690
+ const candidates = entries.filter((e) => e !== current)
691
+ .map((name) => ({ name, mtime: fs.statSync(path.join(OPERATOR_ROOT, name)).mtimeMs }))
692
+ .sort((a, b) => b.mtime - a.mtime);
693
+ if (candidates.length === 0) fail('Only one build on disk — nothing to roll back to.', 'rollback', 'NoPrior');
694
+ targetSha = candidates[0].name;
695
+ console.log(`rolling back to most recent prior build: ${targetSha}`);
696
+ }
697
+
698
+ const targetDir = path.join(OPERATOR_ROOT, targetSha);
699
+ if (!fs.existsSync(targetDir)) fail(`No local build for sha=${targetSha}. The installer only keeps 2 prior builds on disk — once they're gone you can't roll back to them.`, 'rollback', 'NotOnDisk');
700
+
701
+ // Flip current atomically.
702
+ const tmpLink = `${CURRENT_LINK}.tmp-${process.pid}`;
703
+ try { fs.rmSync(tmpLink, { force: true }); } catch {}
704
+ try {
705
+ fs.symlinkSync(targetDir, tmpLink, 'dir');
706
+ fs.renameSync(tmpLink, CURRENT_LINK);
707
+ } catch (e) {
708
+ fail(`failed to flip current symlink: ${e.message}`, 'rollback', 'SymlinkError');
709
+ }
710
+ fs.writeFileSync(VERSION_FILE, targetSha);
711
+ console.log(`rolled back to ${targetSha.slice(0, 12)}`);
712
+ }
713
+
714
+ // ─── Uninstall ──────────────────────────────────────────────────────────────
715
+
716
+ function doUninstall() {
717
+ if (!fs.existsSync(OPERATOR_ROOT)) {
718
+ console.log('Nothing to uninstall — ~/.vruum/skills-operator/ does not exist.');
719
+ return;
720
+ }
721
+ for (const { dir: harnessDir } of HARNESSES) {
722
+ if (!fs.existsSync(harnessDir)) continue;
723
+ for (const entry of fs.readdirSync(harnessDir)) {
724
+ const full = path.join(harnessDir, entry);
725
+ try {
726
+ const lstat = fs.lstatSync(full);
727
+ if (!lstat.isSymbolicLink()) continue;
728
+ const target = fs.readlinkSync(full);
729
+ if (target.includes(OPERATOR_ROOT) || target.includes('skills-operator')) {
730
+ fs.unlinkSync(full);
731
+ console.log(`unlinked ${full}`);
732
+ }
733
+ } catch {}
734
+ }
735
+ }
736
+ fs.rmSync(OPERATOR_ROOT, { recursive: true, force: true });
737
+ console.log('Removed ~/.vruum/skills-operator/. Auth left intact.');
738
+ }
739
+
740
+ // ─── Main ───────────────────────────────────────────────────────────────────
741
+
742
+ async function main() {
743
+ let args;
744
+ try { args = parseArgs(process.argv); }
745
+ catch (e) { console.error(e.message); printHelp(); process.exit(1); }
746
+
747
+ if (args.help || args.command === 'help') { printHelp(); return; }
748
+
749
+ try {
750
+ if (args.command === 'install' || args.command === 'update') {
751
+ await doInstall({ force: args.force });
752
+ } else if (args.command === 'rollback') {
753
+ doRollback({ to: args.to });
754
+ } else if (args.command === 'uninstall') {
755
+ doUninstall();
756
+ } else {
757
+ console.error(`unknown command: ${args.command}`);
758
+ printHelp();
759
+ process.exit(1);
760
+ }
761
+ } catch (e) {
762
+ fail(e.message, 'main', e.code || 'UnhandledError');
763
+ }
764
+ }
765
+
766
+ if (require.main === module) main();
767
+
768
+ module.exports = {
769
+ parseArgs,
770
+ isShaLike,
771
+ acquireLock,
772
+ releaseLock,
773
+ linkSkill,
774
+ pruneOldShas,
775
+ // exposed for tests
776
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@vruum/skills-operator",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/vruum-gtm/vruum_ai.git",
9
+ "directory": ".agents/skills-operator"
10
+ },
11
+ "homepage": "https://vruum.ai",
12
+ "bugs": {
13
+ "url": "https://github.com/vruum-gtm/vruum_ai/issues"
14
+ },
15
+ "bin": {
16
+ "vruum-skills-operator": "./install.js"
17
+ },
18
+ "files": [
19
+ "install.js",
20
+ "bin/",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "dependencies": {
28
+ "tar": "^7.4.0"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test __tests__/*.test.js"
32
+ },
33
+ "keywords": [
34
+ "vruum",
35
+ "mcp",
36
+ "claude-code",
37
+ "codex",
38
+ "operator",
39
+ "ai",
40
+ "sales"
41
+ ]
42
+ }