unbound-cli 1.2.0 → 1.3.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/LOCAL_DEV.md CHANGED
@@ -119,8 +119,8 @@ node src/index.js login --base-url http://localhost:8000 --api-key <your-key>
119
119
 
120
120
  ```bash
121
121
  # Auth
122
- node src/index.js whoami
123
122
  node src/index.js status
123
+ node src/index.js doctor
124
124
 
125
125
  # Policies — overview
126
126
  node src/index.js policy # overview + docs links
package/README.md CHANGED
@@ -17,8 +17,8 @@ unbound login
17
17
  # Or login with an API key (for CI/CD or headless environments)
18
18
  unbound login --api-key <your-api-key>
19
19
 
20
- # Check who you are
21
- unbound whoami
20
+ # Check your status, role, and connected tools
21
+ unbound status
22
22
 
23
23
  # List policies
24
24
  unbound policy list
@@ -55,8 +55,8 @@ The user API key and discovery API key are separate — discovery uses its own k
55
55
  | `unbound login --api-key <key>` | Sign in with an API key (non-interactive) |
56
56
  | `unbound login --domain <domain>` | Sign in via a custom domain |
57
57
  | `unbound logout` | Remove stored credentials |
58
- | `unbound whoami` | Show current user, org, and role |
59
- | `unbound status` | Show CLI status and API connectivity |
58
+ | `unbound status` | Show CLI status, role, and connected tools |
59
+ | `unbound doctor` | Diagnose per-tool health (config, hook, env) and API key |
60
60
 
61
61
  ### Tool Setup
62
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -121,7 +121,7 @@ function normalizeAssistantTurn(turn) {
121
121
  // backend-supplied strings out of the shell line so copy-pastes stay safe.
122
122
  function friendlyChatError(err) {
123
123
  if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
124
- if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound whoami` to check yours.';
124
+ if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound status` to check yours.';
125
125
  if (err?.statusCode === 429) return 'Rate limited. Please wait a moment and try again.';
126
126
  return sanitizeForTerminal(err?.message || 'Request failed.');
127
127
  }
@@ -479,7 +479,7 @@ INTERACTIVE SESSION
479
479
  /exit quit (Ctrl+C also works)
480
480
 
481
481
  NOTES
482
- * Requires Admin or Manager role in your organization. Run 'unbound whoami' to check.
482
+ * Requires Admin or Manager role in your organization. Run 'unbound status' to check.
483
483
  * Conversation history is held in memory only; nothing is ever written to disk.
484
484
  * Each -m invocation sends no history; keep each prompt self-contained.
485
485
  * -o writes a new file with mode 0600 and refuses to overwrite existing files.
@@ -0,0 +1,198 @@
1
+ const { spawnSync } = require('child_process');
2
+ const config = require('../config');
3
+ const api = require('../api');
4
+ const output = require('../output');
5
+ const { getDeviceSerial } = require('../device-serial');
6
+ const { detectTools } = require('../toolHealth');
7
+ const { hasRootPrivileges } = require('./setup');
8
+
9
+ // Validate the stored API key against the backend. Returns one of:
10
+ // valid | invalid | not-logged-in | unverified (network/other error).
11
+ async function checkApiKey() {
12
+ if (!config.isLoggedIn()) return { state: 'not-logged-in', detail: 'no API key stored — run `unbound login`' };
13
+ try {
14
+ const deviceSerial = await getDeviceSerial();
15
+ const privileges = await api.get('/api/v1/users/privileges/', { query: { device_serial: deviceSerial } });
16
+ config.backfillUserInfo(privileges);
17
+ return { state: 'valid', detail: 'verified against the backend' };
18
+ } catch (err) {
19
+ // The HTTP status is the reliable signal; the message wording can change.
20
+ if (err && (err.statusCode === 401 || /invalid or expired|401|unauthor/i.test(err.message || ''))) {
21
+ return { state: 'invalid', detail: 'the stored key was rejected (401) — run `unbound login`' };
22
+ }
23
+ return { state: 'unverified', detail: `could not verify (${err.message})` };
24
+ }
25
+ }
26
+
27
+ // Exit code for `--fix`: signal/spawn error → 1; otherwise the `setup` status. A
28
+ // successful non-root run that left MDM tools tampered still fails so a CI gate
29
+ // doesn't read the machine as clean. As root those tools were just repaired, so a
30
+ // status-0 run is clean — don't penalize.
31
+ function fixExitCode(status, root, mdmRemaining) {
32
+ if (status == null) return 1;
33
+ return status || (!root && mdmRemaining ? 1 : 0);
34
+ }
35
+
36
+ function register(program) {
37
+ program
38
+ .command('doctor')
39
+ .description('Diagnose the local Unbound install: per-tool config, hook script and env wiring, plus API-key validity. Prints how to fix anything broken.')
40
+ .addHelpText('after', `
41
+ For each AI coding tool, doctor verifies:
42
+ Config - the tool's config file contains the Unbound integration
43
+ Hook script - the installed hook/key-helper file exists
44
+ Env wiring - the API key (and gateway URL, for gateway mode) is exported
45
+
46
+ Per-tool status:
47
+ Healthy - everything in place
48
+ Tampered - partially installed (reinstall to fix)
49
+ Not installed - no Unbound integration found
50
+ Managed by MDM- configured org-wide by your administrator
51
+
52
+ Exit code is non-zero when any tool is tampered or the API key is invalid, so
53
+ doctor can gate scripts/CI.
54
+
55
+ Examples:
56
+ $ unbound doctor
57
+ $ unbound doctor --fix Reinstall every tampered tool
58
+ $ unbound doctor --json
59
+ `)
60
+ .option('--fix', 'Reinstall every tool reported as tampered')
61
+ .option('--json', 'Output raw JSON')
62
+ .action(async (opts) => {
63
+ try {
64
+ const apiKey = config.getApiKey();
65
+ const gatewayUrl = config.getGatewayUrl();
66
+
67
+ const spin = output.spinner('Running diagnostics...');
68
+ const key = await checkApiKey();
69
+ const tools = detectTools({ gatewayUrl, apiKey });
70
+ spin.stop();
71
+
72
+ const tampered = tools.filter((t) => t.status === 'tampered');
73
+ const unhealthy = tampered.length > 0 || key.state === 'invalid';
74
+
75
+ if (opts.json) {
76
+ output.json({
77
+ api_key: key,
78
+ tools: tools.map((t) => ({
79
+ tool: t.key,
80
+ label: t.label,
81
+ mode: t.mode,
82
+ status: t.status,
83
+ conflict: !!t.conflict,
84
+ checks: t.checks.map((c) => ({ name: c.name, ok: c.ok, kind: c.kind, detail: c.detail, warn: !!c.warn })),
85
+ })),
86
+ healthy: !unhealthy,
87
+ });
88
+ if (unhealthy) process.exitCode = 1;
89
+ return;
90
+ }
91
+
92
+ const C = output.colors;
93
+ const labelOf = (t) => t.label + (t.mode ? ` (${t.mode})` : '');
94
+ const W = Math.max(7, ...tools.map((t) => labelOf(t).length)); // 7 = "API key"
95
+
96
+ console.log('');
97
+ const keyText = {
98
+ valid: C.green('✓ Valid'),
99
+ invalid: C.red('✗ Invalid or expired'),
100
+ 'not-logged-in': C.dim('– Not logged in'),
101
+ unverified: `${C.yellow('? Could not verify')} ${C.dim(`(${key.detail})`)}`,
102
+ }[key.state];
103
+ console.log(` ${C.bold('API key'.padEnd(W))} ${keyText}`);
104
+ console.log('');
105
+
106
+ for (const t of tools) {
107
+ let s;
108
+ if (t.status === 'healthy') s = C.green('✓ Healthy');
109
+ else if (t.status === 'managed-by-mdm') s = C.green('✓ Healthy') + C.dim(' · managed by MDM');
110
+ else if (t.status === 'tampered') {
111
+ // Status + reason on one line. Mode sits next to the tool name.
112
+ const reasons = [];
113
+ if (t.conflict) reasons.push('two modes installed');
114
+ for (const c of t.checks) if (!c.ok) reasons.push(c.summary || c.name);
115
+ s = C.red(`✗ Tampered — ${reasons.join(', ')}`);
116
+ } else s = C.dim('○ Not set up');
117
+ console.log(` ${labelOf(t).padEnd(W)} ${s}`);
118
+ }
119
+ console.log('');
120
+
121
+ // Fix routing. User-level tools are fixable by anyone (`unbound doctor
122
+ // --fix`); MDM-managed ones need `sudo`. One sudo run repairs everything
123
+ // (sudo `setup` installs MDM-wide, and on macOS sudo keeps HOME so it sees
124
+ // the user's tools too). When both kinds are broken we show both commands
125
+ // so a user WITHOUT sudo can still repair their own user-level tools.
126
+ const userTampered = tampered.filter((t) => t.scope !== 'mdm');
127
+ const mdmTampered = tampered.filter((t) => t.scope === 'mdm');
128
+ const userFix = userTampered.map((t) => t.key);
129
+ const mdmFix = mdmTampered.map((t) => t.key);
130
+ const userNames = userTampered.map((t) => t.label).join(', ');
131
+ const mdmNames = mdmTampered.map((t) => t.label).join(', ');
132
+ const allFix = tampered.map((t) => t.key);
133
+ const anyInstalled = tools.some((t) => t.status !== 'not-installed');
134
+
135
+ if (opts.fix) {
136
+ if (!tampered.length) {
137
+ if (!anyInstalled) console.log(`${C.yellow('No tools are set up yet')} — nothing to fix. Run ${C.bold('unbound setup')} first.`);
138
+ else output.success('Nothing to fix — all set up tools are healthy.');
139
+ console.log('');
140
+ return;
141
+ }
142
+ const root = hasRootPrivileges();
143
+ // sudo → reinstall everything (MDM-wide); without sudo → the user-level
144
+ // tools only, and point at sudo for whatever's org-managed.
145
+ if (!root && !userFix.length) {
146
+ console.log(`${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
147
+ console.log(` Run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
148
+ console.log('');
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ const fixNow = root ? allFix : userFix;
153
+ output.info(`Reinstalling: ${fixNow.join(', ')}${root ? ' (org-wide, all users)' : ''}`);
154
+ console.log('');
155
+ const r = spawnSync(process.argv[0], [process.argv[1], 'setup', ...fixNow], { stdio: 'inherit' });
156
+ if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
157
+ process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0);
158
+ return;
159
+ }
160
+
161
+ // No `--fix`: report and show the command(s) that fix it.
162
+ if (!anyInstalled && key.state !== 'invalid') {
163
+ console.log(`${C.yellow('No AI tools are set up yet.')} Run ${C.bold('unbound setup')} to get started.`);
164
+ console.log('');
165
+ return;
166
+ }
167
+ if (!unhealthy) {
168
+ output.success('All set up tools are healthy.');
169
+ console.log('');
170
+ return;
171
+ }
172
+ if (tampered.length) {
173
+ console.log(C.red(`✗ ${tampered.length} tool${tampered.length === 1 ? ' needs' : 's need'} attention.`));
174
+ if (!mdmFix.length) {
175
+ console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
176
+ } else if (!userFix.length) {
177
+ console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
178
+ console.log(` Run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
179
+ } else {
180
+ // Both: a user without sudo can still fix their own tools; sudo fixes everything.
181
+ console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
182
+ console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'} too.`);
183
+ }
184
+ }
185
+ if (key.state === 'invalid') {
186
+ if (!tampered.length) console.log(C.red('✗ Your API key is invalid or expired.'));
187
+ console.log(` Run ${C.bold('unbound login')} to re-authenticate.`);
188
+ }
189
+ console.log('');
190
+ process.exitCode = 1;
191
+ } catch (err) {
192
+ if (!err.displayed) output.error(err.message);
193
+ process.exitCode = 1;
194
+ }
195
+ });
196
+ }
197
+
198
+ module.exports = { register, fixExitCode };
@@ -2,21 +2,36 @@ const config = require('../config');
2
2
  const api = require('../api');
3
3
  const output = require('../output');
4
4
  const { getDeviceSerial } = require('../device-serial');
5
+ const { detectTools } = require('../toolHealth');
6
+
7
+ function roleFromPrivileges(p) {
8
+ if (!p) return null;
9
+ if (p.is_admin) return 'Admin';
10
+ if (p.is_manager) return 'Manager';
11
+ if (p.is_member) return 'Member';
12
+ return 'Unknown';
13
+ }
14
+
5
15
 
6
16
  function register(program) {
7
17
  program
8
18
  .command('status')
9
- .description('Show the current CLI status including config location, login state, and API connectivity. Useful for debugging connection issues.')
19
+ .description('Show the current CLI status: config location, login state, role, connected tools, and API connectivity. Useful for debugging connection issues.')
10
20
  .addHelpText('after', `
11
21
  Output fields:
12
22
  Config file - Path to the config file (~/.unbound/config.json)
13
23
  Logged in - Whether credentials are stored (Yes/No)
14
24
  Email - The authenticated user's email (if logged in)
15
25
  Organization - The organization name (if logged in)
26
+ Role - Admin / Manager / Member (if logged in)
16
27
  Backend URL - REST API host (configurable for tenant deployments)
17
28
  Frontend URL - Browser login host
18
29
  Gateway URL - AI gateway host (used by tool setup)
19
30
  API status - Connectivity check result (Connected / Error)
31
+ Connected tools - AI tools wired through Unbound on this device, with mode
32
+
33
+ For a deep per-tool health check (config, hook script, env wiring), run
34
+ \`unbound doctor\`.
20
35
 
21
36
  Examples:
22
37
  $ unbound status
@@ -32,8 +47,8 @@ Examples:
32
47
  ['Logged in', loggedIn ? 'Yes' : 'No'],
33
48
  ];
34
49
 
35
- // Check API connectivity
36
50
  let connectivity = 'Not checked (not logged in)';
51
+ let role = null;
37
52
  if (loggedIn) {
38
53
  const spin = output.spinner('Checking API connectivity...');
39
54
  try {
@@ -42,6 +57,7 @@ Examples:
42
57
  query: { device_serial: deviceSerial },
43
58
  });
44
59
  config.backfillUserInfo(privileges);
60
+ role = roleFromPrivileges(privileges);
45
61
  spin.stop();
46
62
  connectivity = 'Connected';
47
63
  } catch (err) {
@@ -52,12 +68,17 @@ Examples:
52
68
  const cfg = config.readConfig();
53
69
  pairs.push(['Email', cfg.email || '-']);
54
70
  pairs.push(['Organization', cfg.org_name || '-']);
71
+ pairs.push(['Role', role || '-']);
55
72
  }
56
73
  pairs.push(['Backend URL', config.getBaseUrl()]);
57
74
  pairs.push(['Frontend URL', config.getFrontendUrl()]);
58
75
  pairs.push(['Gateway URL', config.getGatewayUrl()]);
59
76
  pairs.push(['API status', connectivity]);
60
77
 
78
+ // Locally detected AI tools wired through Unbound, with their mode.
79
+ const connected = detectTools({ gatewayUrl: config.getGatewayUrl(), apiKey: config.getApiKey() })
80
+ .filter((t) => t.status !== 'not-installed');
81
+
61
82
  if (opts.json) {
62
83
  const cfg = loggedIn ? config.readConfig() : {};
63
84
  output.json({
@@ -65,15 +86,34 @@ Examples:
65
86
  logged_in: loggedIn,
66
87
  email: loggedIn ? (cfg.email || null) : null,
67
88
  organization: loggedIn ? (cfg.org_name || null) : null,
89
+ role: role,
68
90
  backend_url: config.getBaseUrl(),
69
91
  frontend_url: config.getFrontendUrl(),
70
92
  gateway_url: config.getGatewayUrl(),
71
93
  api_status: connectivity,
94
+ connected_tools: connected.map((t) => ({ tool: t.key, label: t.label, mode: t.mode, status: t.status })),
72
95
  });
73
96
  return;
74
97
  }
75
98
 
76
99
  output.keyValue(pairs);
100
+
101
+ console.log('');
102
+ output.info('Connected tools');
103
+ const C = output.colors;
104
+ if (connected.length === 0) {
105
+ console.log(` ${C.dim('None set up yet.')} Run ${C.bold('unbound setup')} to wire a tool.`);
106
+ } else {
107
+ for (const t of connected) {
108
+ const mode = t.mode ? C.dim(` (${t.mode})`) : '';
109
+ let mark = C.green('✓');
110
+ let note = '';
111
+ if (t.status === 'tampered') { mark = C.red('✗'); note = C.dim(' — run `unbound doctor`'); }
112
+ else if (t.status === 'managed-by-mdm') { note = C.dim(' (managed by MDM)'); }
113
+ console.log(` ${mark} ${t.label}${mode}${note}`);
114
+ }
115
+ }
116
+ console.log('');
77
117
  } catch (err) {
78
118
  output.error(err.message);
79
119
  process.exitCode = 1;
package/src/config.js CHANGED
@@ -141,7 +141,7 @@ function isLoggedIn() {
141
141
  * Refreshes cached user identity (email, org_name) from a backend response.
142
142
  * Always overwrites when the response carries a non-empty value so that
143
143
  * switching tenants under the same API key (or rotating the key to a new org)
144
- * shows the correct organization in `whoami` / `status` instead of stale data.
144
+ * shows the correct organization in `status` instead of stale data.
145
145
  * Defensive: leaves an existing cached value untouched if the response field
146
146
  * is missing or empty, so a partial API response can't blank the local config.
147
147
  */
package/src/index.js CHANGED
@@ -29,8 +29,8 @@ AUTHENTICATION
29
29
  $ unbound login --api-key <key> Sign in with an API key (for CI/CD)
30
30
  $ unbound login --domain custom.co Sign in via custom domain
31
31
  $ unbound logout Remove stored credentials
32
- $ unbound whoami Show current user and organization
33
- $ unbound status Show CLI status and API connectivity
32
+ $ unbound status Show CLI status, role, and connected tools
33
+ $ unbound doctor Diagnose per-tool health and API key
34
34
 
35
35
  Tenant deployments — pass URL flags on login; they persist to ~/.unbound/config.json:
36
36
  $ unbound login --api-key <YOUR_API_KEY> \\
@@ -178,8 +178,8 @@ LEARN MORE
178
178
  // Register all command modules
179
179
  require('./commands/login').register(program);
180
180
  require('./commands/logout').register(program);
181
- require('./commands/whoami').register(program);
182
181
  require('./commands/status').register(program);
182
+ require('./commands/doctor').register(program);
183
183
  require('./commands/policy').register(program);
184
184
  require('./commands/users').register(program);
185
185
  require('./commands/user-groups').register(program);
@@ -241,7 +241,7 @@ Use this on a fresh install for tenant deployments. Positional order is fixed:
241
241
  2. <frontend-url> — Frontend host (e.g. https://gateway.acme.com)
242
242
  Used by the browser login flow.
243
243
  3. <backend-url> — REST API host (e.g. https://backend.acme.com)
244
- All "unbound *" commands (whoami, status, policies, ...) hit this.
244
+ All "unbound *" commands (status, doctor, policies, ...) hit this.
245
245
 
246
246
  Bare hostnames are accepted; "https://" is added automatically.
247
247
  The three values are written atomically to ~/.unbound/config.json.
@@ -0,0 +1,288 @@
1
+ // Per-tool local health detection shared by `unbound doctor` and `unbound status`.
2
+ //
3
+ // Source of truth for what each tool installs is the setup repo's per-tool
4
+ // setup.py (mirrored here). Each tool has STRUCTURAL artifacts (a config file
5
+ // with an Unbound marker + an optional hook/script file) that decide whether it
6
+ // is installed, plus AUXILIARY wiring (an env var holding the api key, or the
7
+ // gateway URL). Env vars are persisted to a shell rc file (Unix) or the user
8
+ // registry (Windows), NOT reliably to the current process env, so we read those.
9
+ //
10
+ // Install state (matches the doctor spec):
11
+ // none of the structural artifacts present -> not installed
12
+ // all structural present AND all wiring ok -> healthy
13
+ // some present (or wiring broken) -> tampered
14
+ // nothing local but an org MDM install found -> managed by MDM
15
+
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const { spawnSync } = require('child_process');
20
+
21
+ const HOME = os.homedir();
22
+ const GATEWAY_DEFAULT = 'https://api.getunbound.ai';
23
+
24
+ function expand(p) {
25
+ return p.startsWith('~') ? path.join(HOME, p.slice(1)) : p;
26
+ }
27
+
28
+ function fileExists(p) {
29
+ try {
30
+ return fs.statSync(expand(p)).isFile();
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function readText(p) {
37
+ try {
38
+ return fs.readFileSync(expand(p), 'utf8');
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function readJson(p) {
45
+ const text = readText(p);
46
+ if (text == null) return null;
47
+ try {
48
+ return JSON.parse(text.replace(/^\uFEFF/, '')); // tolerate a UTF-8 BOM
49
+ } catch {
50
+ return undefined; // file exists but is corrupt
51
+ }
52
+ }
53
+
54
+ // Candidate shell rc files where the setup scripts append `export NAME=...`.
55
+ function rcFiles() {
56
+ if (process.platform === 'win32') return [];
57
+ if (process.platform === 'darwin') return ['~/.zprofile', '~/.bash_profile', '~/.zshrc', '~/.bashrc'];
58
+ return ['~/.zshrc', '~/.bashrc', '~/.profile'];
59
+ }
60
+
61
+ // Read a persisted env var: live process env first, then the shell rc (Unix) or
62
+ // the user registry (Windows). Returns the value so callers can flag mismatch.
63
+ function readEnvVar(name) {
64
+ // `in`, not truthiness: an env var explicitly set to "" is "set but empty", not
65
+ // absent — without this we'd fall through and return a stale rc-file value,
66
+ // masking the fact that the live env blanked the key.
67
+ if (name in process.env) return { found: true, value: process.env[name], source: 'process env' };
68
+
69
+ if (process.platform === 'win32') {
70
+ try {
71
+ const r = spawnSync('reg', ['query', 'HKCU\\Environment', '/v', name], { encoding: 'utf8' });
72
+ if (r.status === 0 && r.stdout) {
73
+ const m = r.stdout.match(new RegExp(`${name}\\s+REG_[A-Z_]+\\s+(.*)`));
74
+ if (m) return { found: true, value: m[1].trim(), source: 'user registry' };
75
+ }
76
+ } catch {
77
+ /* fall through */
78
+ }
79
+ return { found: false };
80
+ }
81
+
82
+ const re = new RegExp(`^\\s*export\\s+${name}=(.*)$`, 'm');
83
+ for (const rc of rcFiles()) {
84
+ const text = readText(rc);
85
+ if (!text) continue;
86
+ const m = text.match(re);
87
+ if (m) {
88
+ const val = m[1].trim().replace(/^["']|["']$/g, '');
89
+ return { found: true, value: val, source: path.basename(expand(rc)) };
90
+ }
91
+ }
92
+ return { found: false };
93
+ }
94
+
95
+ // --- check builders. Each returns { name, ok, kind, detail, summary, warn? }. ---
96
+ // kind: 'structural' (decides installed) | 'aux' (wiring health only).
97
+ // summary: a short reason shown inline on the tampered line.
98
+
99
+ function configCheck(label, file, marker, opts = {}) {
100
+ const kind = opts.kind || 'structural';
101
+ const data = marker.json ? readJson(file) : readText(file);
102
+ let ok = false;
103
+ let detail;
104
+ let summary;
105
+ if (data == null) {
106
+ detail = `not found (${file})`;
107
+ summary = opts.short || `${label.toLowerCase()} not found`;
108
+ } else if (data === undefined) {
109
+ detail = `unreadable / corrupt (${file})`;
110
+ summary = `${label.toLowerCase()} unreadable`;
111
+ } else {
112
+ ok = marker.test(data);
113
+ detail = ok ? file : `present but missing the Unbound integration (${file})`;
114
+ summary = opts.short || `${label.toLowerCase()} not integrated`;
115
+ }
116
+ return { name: label, ok, kind, detail, summary };
117
+ }
118
+
119
+ function scriptCheck(label, file, kind = 'structural') {
120
+ const ok = fileExists(file);
121
+ return { name: label, ok, kind, detail: ok ? file : `not found (${file})`, summary: `${label.toLowerCase()} missing` };
122
+ }
123
+
124
+ function envCheck(label, name, expected, kind = 'aux') {
125
+ const base = label.replace(/ env$/, '');
126
+ const r = readEnvVar(name);
127
+ // An explicitly-empty value is broken wiring, same as absent.
128
+ if (!r.found || !r.value) return { name: label, ok: false, kind, detail: `${name} is not set`, summary: `${base} not set` };
129
+ // A value that doesn't match what setup wrote (stale key, wrong gateway URL) is a
130
+ // real misconfiguration: mark it not-ok so the tool reports tampered, not healthy.
131
+ if (expected && r.value !== expected) {
132
+ return { name: label, ok: false, kind, warn: true, summary: `${base} differs from setup`, detail: `${name} set (${r.source}) but differs from what setup configured` };
133
+ }
134
+ return { name: label, ok: true, kind, detail: `${name} set (${r.source})`, summary: `${base} set` };
135
+ }
136
+
137
+ // MDM (org-wide) install detection. An Unbound MDM install is only real when the
138
+ // system managed config REFERENCES the Unbound hook AND the hook script exists.
139
+ // A plain managed-settings.json (Claude Enterprise / generic MDM) does NOT count,
140
+ // and a managed config that points at a missing hook script is a broken (tampered)
141
+ // MDM install, not a healthy one. Returns { status: 'healthy'|'tampered'|null, checks }.
142
+ function mdmDetect(family, dirOverride) {
143
+ // Only Cursor / Claude Code / Codex have a managed (MDM) directory. Copilot and
144
+ // Gemini have none — their org install writes the same per-user config into
145
+ // every profile, so they're checked exactly like a user-level tool.
146
+ const configName = { cursor: 'hooks.json', 'claude-code': 'managed-settings.json', codex: 'managed-settings.json' };
147
+ if (!configName[family]) return { status: null, checks: [] };
148
+
149
+ const mac = '/Library/Application Support';
150
+ const win = process.env.ProgramData || 'C:\\ProgramData';
151
+ const dirs = {
152
+ cursor: { darwin: `${mac}/Cursor`, linux: '/etc/cursor', win32: path.join(win, 'Cursor') },
153
+ 'claude-code': { darwin: `${mac}/ClaudeCode`, linux: '/etc/claude-code', win32: path.join(win, 'ClaudeCode') },
154
+ codex: { darwin: `${mac}/Codex`, linux: '/etc/codex', win32: path.join(win, 'Codex') },
155
+ };
156
+ const dir = dirOverride || dirs[family][process.platform] || dirs[family].linux;
157
+ const configPath = path.join(dir, configName[family]);
158
+ const scriptPath = path.join(dir, 'hooks', 'unbound.py');
159
+
160
+ const cfgText = readText(configPath);
161
+ if (cfgText == null || !cfgText.includes('unbound.py')) return { status: null, checks: [] };
162
+
163
+ const scriptOk = fileExists(scriptPath);
164
+ const checks = [
165
+ { name: 'MDM config', ok: true, kind: 'structural', detail: configPath, summary: 'managed config' },
166
+ { name: 'MDM hook script', ok: scriptOk, kind: 'structural', summary: 'managed hook not installed', detail: scriptOk ? scriptPath : `managed config references a hook that isn't installed (${scriptPath})` },
167
+ ];
168
+ return { status: scriptOk ? 'healthy' : 'tampered', checks };
169
+ }
170
+
171
+ // Marker that a claude/codex/cursor hooks block references unbound.py.
172
+ function refsUnbound(obj) {
173
+ return JSON.stringify(obj || {}).includes('unbound.py');
174
+ }
175
+
176
+ // One descriptor per (tool, mode). `family` groups the two-mode tools so the
177
+ // collapsed view shows a single line per product.
178
+ function buildVariants(gatewayUrl, apiKey) {
179
+ const gw = (gatewayUrl || GATEWAY_DEFAULT).replace(/\/+$/, ''); // setup rstrips too
180
+ return [
181
+ {
182
+ key: 'cursor', label: 'Cursor', family: 'cursor', mode: null,
183
+ checks: () => [
184
+ configCheck('Config', '~/.cursor/hooks.json', { json: true, test: refsUnbound }),
185
+ scriptCheck('Hook script', '~/.cursor/hooks/unbound.py'),
186
+ envCheck('API key env', 'UNBOUND_CURSOR_API_KEY', apiKey),
187
+ ],
188
+ },
189
+ {
190
+ key: 'claude-code-subscription', label: 'Claude Code (subscription)', family: 'claude-code', mode: 'subscription',
191
+ checks: () => [
192
+ configCheck('Config', '~/.claude/settings.json', { json: true, test: refsUnbound }),
193
+ scriptCheck('Hook script', '~/.claude/hooks/unbound.py'),
194
+ envCheck('API key env', 'UNBOUND_CLAUDE_API_KEY', apiKey),
195
+ ],
196
+ },
197
+ {
198
+ key: 'claude-code-gateway', label: 'Claude Code (gateway)', family: 'claude-code', mode: 'gateway',
199
+ checks: () => [
200
+ configCheck('Config', '~/.claude/settings.json', { json: true, test: (j) => typeof j.apiKeyHelper === 'string' && j.apiKeyHelper.includes('anthropic_key.sh') }),
201
+ scriptCheck('Key helper', '~/.claude/anthropic_key.sh'),
202
+ envCheck('API key env', 'UNBOUND_API_KEY', apiKey),
203
+ envCheck('Gateway URL env', 'ANTHROPIC_BASE_URL', gw),
204
+ ],
205
+ },
206
+ {
207
+ key: 'codex-subscription', label: 'Codex (subscription)', family: 'codex', mode: 'subscription',
208
+ checks: () => [
209
+ configCheck('Config', '~/.codex/hooks.json', { json: true, test: refsUnbound }),
210
+ configCheck('Hooks feature flag', '~/.codex/config.toml', { json: false, test: (t) => /codex_hooks\s*=\s*true/.test(t) }, { short: 'codex hooks not enabled' }),
211
+ scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
212
+ envCheck('API key env', 'UNBOUND_CODEX_API_KEY', apiKey),
213
+ ],
214
+ },
215
+ {
216
+ key: 'codex-gateway', label: 'Codex (gateway)', family: 'codex', mode: 'gateway',
217
+ checks: () => [
218
+ configCheck('Config', '~/.codex/config.toml', { json: false, test: (t) => /openai_base_url\s*=/.test(t) }),
219
+ envCheck('API key env', 'OPENAI_API_KEY', apiKey),
220
+ ],
221
+ },
222
+ {
223
+ key: 'copilot', label: 'GitHub Copilot', family: 'copilot', mode: null,
224
+ checks: () => [
225
+ // Copilot has no managed (MDM) directory: the org install writes the same
226
+ // ~/.copilot config into every user profile, so it's checked like a
227
+ // user-level tool and never reports "managed by MDM".
228
+ configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound }),
229
+ scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'),
230
+ envCheck('API key env', 'UNBOUND_COPILOT_API_KEY', apiKey),
231
+ ],
232
+ },
233
+ // Gemini CLI is intentionally omitted here — it isn't part of `setup --all`
234
+ // and has no managed directory. Add it back when its scope is settled.
235
+ ];
236
+ }
237
+
238
+ function statusOf(checks) {
239
+ const structural = checks.filter((c) => c.kind === 'structural');
240
+ const present = structural.filter((c) => c.ok);
241
+ if (present.length === 0) return 'not-installed';
242
+ if (present.length === structural.length && checks.every((c) => c.ok)) return 'healthy';
243
+ return 'tampered';
244
+ }
245
+
246
+ function detectVariant(variant) {
247
+ const checks = variant.checks();
248
+ return { key: variant.key, label: variant.label, family: variant.family, mode: variant.mode, status: statusOf(checks), checks };
249
+ }
250
+
251
+ // Collapse the per-(tool,mode) variants to one entry per product family. Picks
252
+ // the installed/tampered mode if any; otherwise reports managed-by-mdm or
253
+ // not-installed. This is what both `doctor` and `status` render.
254
+ // `_mdmDirs` (test-only) overrides the system MDM directories per family so the
255
+ // org-managed scenarios can be exercised without writing under /Library or /etc.
256
+ function detectTools({ gatewayUrl, apiKey, _mdmDirs } = {}) {
257
+ const variants = buildVariants(gatewayUrl, apiKey).map(detectVariant);
258
+ const families = [];
259
+ const seen = new Set();
260
+ for (const v of variants) {
261
+ if (seen.has(v.family)) continue;
262
+ seen.add(v.family);
263
+ const sameFamily = variants.filter((x) => x.family === v.family);
264
+ const present = sameFamily.filter((x) => x.status !== 'not-installed');
265
+ if (present.length) {
266
+ // Prefer a tampered variant so a conflicting/partial install surfaces (and
267
+ // `--fix` reinstalls it). Flag when more than one mode is present at once.
268
+ const active = present.find((x) => x.status === 'tampered') || present[0];
269
+ if (present.length > 1) active.conflict = true;
270
+ active.label = active.label.replace(/ \(.*\)$/, ''); // mode is shown via .mode
271
+ families.push(active);
272
+ } else {
273
+ const family = v.family;
274
+ const label = v.label.replace(/ \(.*\)$/, '');
275
+ const mdm = mdmDetect(family, _mdmDirs && _mdmDirs[family]);
276
+ if (mdm.status === 'healthy') {
277
+ families.push({ key: family, label, family, mode: null, status: 'managed-by-mdm', checks: mdm.checks, scope: 'mdm' });
278
+ } else if (mdm.status === 'tampered') {
279
+ families.push({ key: family, label, family, mode: null, status: 'tampered', checks: mdm.checks, scope: 'mdm' });
280
+ } else {
281
+ families.push({ key: family, label, family, mode: null, status: 'not-installed', checks: [] });
282
+ }
283
+ }
284
+ }
285
+ return families;
286
+ }
287
+
288
+ module.exports = { detectTools, statusOf, readEnvVar, GATEWAY_DEFAULT };
@@ -0,0 +1,31 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const { fixExitCode } = require('../src/commands/doctor');
5
+
6
+ // `--fix` exit code: a CI gate runs `unbound doctor --fix` and reads the exit code.
7
+ // It must be 0 only when the machine is actually clean after the repair.
8
+
9
+ test('fixExitCode: spawn/signal error (null status) → 1', () => {
10
+ assert.equal(fixExitCode(null, false, false), 1);
11
+ assert.equal(fixExitCode(null, true, true), 1);
12
+ });
13
+
14
+ test('fixExitCode: setup failure propagates its status', () => {
15
+ assert.equal(fixExitCode(2, false, false), 2);
16
+ assert.equal(fixExitCode(1, true, true), 1);
17
+ });
18
+
19
+ test('fixExitCode: non-root, user tools fixed but MDM still tampered → 1 (not clean)', () => {
20
+ assert.equal(fixExitCode(0, false, true), 1);
21
+ });
22
+
23
+ test('fixExitCode: non-root, nothing org-managed left → 0 (clean)', () => {
24
+ assert.equal(fixExitCode(0, false, false), 0);
25
+ });
26
+
27
+ test('fixExitCode: root run repaired the MDM tools too → 0 (clean, no false failure)', () => {
28
+ // Regression guard: sudo `--fix` reinstalls MDM tools, so a status-0 run is clean
29
+ // even though mdmRemaining was true going in.
30
+ assert.equal(fixExitCode(0, true, true), 0);
31
+ });
@@ -0,0 +1,210 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const { statusOf } = require('../src/toolHealth');
8
+
9
+ // --- statusOf rollup (pure): structural artifacts decide install-state; aux
10
+ // wiring only downgrades an installed tool to tampered. ---
11
+ const chk = (kind, ok) => ({ kind, ok });
12
+
13
+ test('statusOf: no structural artifact present → not-installed (dangling env ignored)', () => {
14
+ assert.equal(statusOf([chk('structural', false), chk('structural', false), chk('aux', true)]), 'not-installed');
15
+ });
16
+
17
+ test('statusOf: all structural + all aux present → healthy', () => {
18
+ assert.equal(statusOf([chk('structural', true), chk('structural', true), chk('aux', true)]), 'healthy');
19
+ });
20
+
21
+ test('statusOf: structural partially present → tampered', () => {
22
+ assert.equal(statusOf([chk('structural', true), chk('structural', false), chk('aux', true)]), 'tampered');
23
+ });
24
+
25
+ test('statusOf: structural complete but wiring (aux) missing → tampered', () => {
26
+ assert.equal(statusOf([chk('structural', true), chk('structural', true), chk('aux', false)]), 'tampered');
27
+ });
28
+
29
+ // --- detectTools integration: fabricate a tool's on-disk artifacts in a temp
30
+ // HOME and assert the per-tool status. toolHealth reads os.homedir() at load
31
+ // (which honors $HOME on POSIX), so we re-require it under the temp home. ---
32
+ function withHome(fn) {
33
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-doctor-'));
34
+ const origHome = process.env.HOME;
35
+ process.env.HOME = tmp;
36
+ delete require.cache[require.resolve('../src/toolHealth')];
37
+ try {
38
+ return fn(tmp, require('../src/toolHealth'));
39
+ } finally {
40
+ if (origHome === undefined) delete process.env.HOME;
41
+ else process.env.HOME = origHome;
42
+ delete require.cache[require.resolve('../src/toolHealth')];
43
+ fs.rmSync(tmp, { recursive: true, force: true });
44
+ }
45
+ }
46
+
47
+ function writeFile(p, content) {
48
+ fs.mkdirSync(path.dirname(p), { recursive: true });
49
+ fs.writeFileSync(p, content);
50
+ }
51
+
52
+ test('detectTools: cursor with config + script + env → healthy', () => {
53
+ withHome((tmp, th) => {
54
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
55
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
56
+ writeFile(script, '# unbound');
57
+ process.env.UNBOUND_CURSOR_API_KEY = 'k';
58
+ try {
59
+ const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.key === 'cursor');
60
+ assert.equal(cursor.status, 'healthy');
61
+ } finally {
62
+ delete process.env.UNBOUND_CURSOR_API_KEY;
63
+ }
64
+ });
65
+ });
66
+
67
+ test('detectTools: cursor config + script present but env key blank → tampered (empty != set)', () => {
68
+ withHome((tmp, th) => {
69
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
70
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
71
+ writeFile(script, '# unbound');
72
+ process.env.UNBOUND_CURSOR_API_KEY = ''; // explicitly blanked, not absent
73
+ try {
74
+ const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.key === 'cursor');
75
+ assert.equal(cursor.status, 'tampered');
76
+ } finally {
77
+ delete process.env.UNBOUND_CURSOR_API_KEY;
78
+ }
79
+ });
80
+ });
81
+
82
+ test('detectTools: cursor env key differs from the configured key → tampered, not healthy', () => {
83
+ withHome((tmp, th) => {
84
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
85
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
86
+ writeFile(script, '# unbound');
87
+ process.env.UNBOUND_CURSOR_API_KEY = 'stale-key';
88
+ try {
89
+ const cursor = th.detectTools({ apiKey: 'current-key' }).find((t) => t.key === 'cursor');
90
+ assert.equal(cursor.status, 'tampered');
91
+ } finally {
92
+ delete process.env.UNBOUND_CURSOR_API_KEY;
93
+ }
94
+ });
95
+ });
96
+
97
+ test('detectTools: cursor with only the hook script (no config) → tampered', () => {
98
+ withHome((tmp, th) => {
99
+ writeFile(path.join(tmp, '.cursor', 'hooks', 'unbound.py'), '# unbound');
100
+ const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.key === 'cursor');
101
+ assert.equal(cursor.status, 'tampered');
102
+ });
103
+ });
104
+
105
+ test('detectTools: cursor with a config that lacks the Unbound marker → not-installed', () => {
106
+ withHome((tmp, th) => {
107
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: {} })); // no unbound.py reference
108
+ const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.label === 'Cursor');
109
+ assert.equal(cursor.status, 'not-installed');
110
+ });
111
+ });
112
+
113
+ test('detectTools: claude-code collapses to ONE line and reports the installed mode (gateway)', () => {
114
+ withHome((tmp, th) => {
115
+ const helper = path.join(tmp, '.claude', 'anthropic_key.sh');
116
+ writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ apiKeyHelper: helper }));
117
+ writeFile(helper, 'echo $UNBOUND_API_KEY');
118
+ const claude = th.detectTools({ apiKey: 'k' }).filter((t) => t.family === 'claude-code');
119
+ assert.equal(claude.length, 1, 'one collapsed claude-code line');
120
+ assert.equal(claude[0].mode, 'gateway');
121
+ assert.notEqual(claude[0].status, 'not-installed');
122
+ });
123
+ });
124
+
125
+ test('detectTools: both claude-code modes present at once → one line, flagged as a conflict', () => {
126
+ withHome((tmp, th) => {
127
+ const helper = path.join(tmp, '.claude', 'anthropic_key.sh');
128
+ // settings.json references BOTH an unbound.py hook AND the gateway key helper.
129
+ writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({
130
+ hooks: { PreToolUse: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] },
131
+ apiKeyHelper: helper,
132
+ }));
133
+ const claude = th.detectTools({ apiKey: 'k' }).filter((t) => t.family === 'claude-code');
134
+ assert.equal(claude.length, 1, 'still one collapsed line');
135
+ assert.equal(claude[0].conflict, true, 'conflict flagged');
136
+ assert.equal(claude[0].status, 'tampered', 'prefers the tampered variant so --fix cleans it up');
137
+ });
138
+ });
139
+
140
+ // --- MDM (org-managed) scenarios, exercised via the _mdmDirs test override ---
141
+ test('MDM: managed config references unbound + hook present → managed by MDM', () => {
142
+ withHome((tmp, th) => {
143
+ const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
144
+ writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
145
+ writeFile(path.join(mdmDir, 'hooks', 'unbound.py'), '#');
146
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.family === 'claude-code');
147
+ assert.equal(t.status, 'managed-by-mdm');
148
+ assert.equal(t.scope, 'mdm');
149
+ });
150
+ });
151
+
152
+ test('MDM: managed config references unbound but hook missing → tampered (mdm scope)', () => {
153
+ withHome((tmp, th) => {
154
+ const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
155
+ writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
156
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.family === 'claude-code');
157
+ assert.equal(t.status, 'tampered');
158
+ assert.equal(t.scope, 'mdm');
159
+ });
160
+ });
161
+
162
+ test('MDM: a generic managed-settings.json without an unbound reference → not installed (no false positive)', () => {
163
+ withHome((tmp, th) => {
164
+ const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
165
+ writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ permissions: { allow: ['*'] } }));
166
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.label === 'Claude Code');
167
+ assert.equal(t.status, 'not-installed');
168
+ });
169
+ });
170
+
171
+ test('MDM: a user-level install takes precedence over an MDM config', () => {
172
+ withHome((tmp, th) => {
173
+ writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
174
+ writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
175
+ const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
176
+ writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
177
+ process.env.UNBOUND_CLAUDE_API_KEY = 'k';
178
+ try {
179
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.family === 'claude-code');
180
+ assert.notEqual(t.scope, 'mdm');
181
+ assert.equal(t.status, 'healthy');
182
+ assert.equal(t.mode, 'subscription');
183
+ } finally { delete process.env.UNBOUND_CLAUDE_API_KEY; }
184
+ });
185
+ });
186
+
187
+ test('codex subscription: hooks.json + script present but the feature flag is off → tampered', () => {
188
+ withHome((tmp, th) => {
189
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.codex', 'hooks', 'unbound.py') }] } }));
190
+ writeFile(path.join(tmp, '.codex', 'hooks', 'unbound.py'), '#');
191
+ writeFile(path.join(tmp, '.codex', 'config.toml'), 'model = "gpt-5"\n');
192
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
193
+ assert.equal(t.status, 'tampered');
194
+ });
195
+ });
196
+
197
+ test('copilot has no MDM scope — even with a managed dir it never reports managed-by-mdm', () => {
198
+ withHome((tmp, th) => {
199
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { copilot: path.join(tmp, 'mdm', 'Copilot') } }).find((x) => x.family === 'copilot');
200
+ assert.equal(t.status, 'not-installed');
201
+ assert.notEqual(t.scope, 'mdm');
202
+ });
203
+ });
204
+
205
+ test('only four tools are reported (Gemini CLI is omitted)', () => {
206
+ withHome((tmp, th) => {
207
+ const keys = th.detectTools({ apiKey: 'k' }).map((t) => t.family);
208
+ assert.deepEqual(new Set(keys), new Set(['cursor', 'claude-code', 'codex', 'copilot']));
209
+ });
210
+ });
@@ -1,65 +0,0 @@
1
- const config = require('../config');
2
- const api = require('../api');
3
- const output = require('../output');
4
- const { getDeviceSerial } = require('../device-serial');
5
-
6
- function roleFromPrivileges(privileges) {
7
- if (privileges.is_admin) return 'Admin';
8
- if (privileges.is_manager) return 'Manager';
9
- if (privileges.is_member) return 'Member';
10
- return 'Unknown';
11
- }
12
-
13
- function register(program) {
14
- program
15
- .command('whoami')
16
- .description('Display the currently authenticated user, organization, and role. Requires an active login session.')
17
- .addHelpText('after', `
18
- Output fields:
19
- Email - The authenticated user's email address
20
- Organization - The organization the user belongs to
21
- Role - One of: Admin, Manager, Member
22
-
23
- Examples:
24
- $ unbound whoami
25
- $ unbound whoami --json
26
- `)
27
- .option('--json', 'Output raw JSON')
28
- .action(async (opts) => {
29
- if (!config.isLoggedIn()) {
30
- output.error('Not logged in. Run `unbound login` first.');
31
- process.exitCode = 1;
32
- return;
33
- }
34
-
35
- const cfg = config.readConfig();
36
- const spin = output.spinner('Fetching user info...');
37
- try {
38
- const deviceSerial = await getDeviceSerial();
39
- const privileges = await api.get('/api/v1/users/privileges/', {
40
- query: { device_serial: deviceSerial },
41
- });
42
- spin.stop();
43
- config.backfillUserInfo(privileges);
44
-
45
- const role = roleFromPrivileges(privileges);
46
- const freshCfg = config.readConfig();
47
-
48
- if (opts.json) {
49
- output.json({ email: freshCfg.email || null, organization: freshCfg.org_name || null, role });
50
- return;
51
- }
52
-
53
- output.keyValue([
54
- ['Email', freshCfg.email || '-'],
55
- ['Organization', freshCfg.org_name || '-'],
56
- ['Role', role],
57
- ]);
58
- } catch (err) {
59
- spin.fail(err.message);
60
- process.exitCode = 1;
61
- }
62
- });
63
- }
64
-
65
- module.exports = { register };