unbound-cli 1.15.1 → 1.16.7

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
@@ -11,7 +11,7 @@
11
11
  cd unbound-cli
12
12
  npm install
13
13
  ```
14
-
14
+
15
15
  ## Run locally (without installing globally)
16
16
 
17
17
  ```bash
@@ -85,7 +85,7 @@ sudo node src/index.js setup --api-key <ADMIN_KEY> --all --backend-url http://lo
85
85
  # Onboarding (combined setup + discover)
86
86
  node src/index.js onboard --api-key <USER_KEY> \
87
87
  --backend-url http://localhost:8000 --frontend-url http://localhost:3000
88
- sudo node src/index.js onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> \
88
+ sudo node src/index.js onboard --api-key <ADMIN_KEY> \
89
89
  --backend-url http://localhost:8000 --frontend-url http://localhost:3000
90
90
  ```
91
91
 
@@ -186,7 +186,7 @@ node src/index.js setup --all --api-key <key>
186
186
  node src/index.js onboard --api-key <USER_KEY>
187
187
 
188
188
  # MDM onboarding (admin device enrollment, requires root — MDM scope auto-detected from sudo)
189
- sudo node src/index.js onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
189
+ sudo node src/index.js onboard --api-key <ADMIN_KEY>
190
190
  ```
191
191
 
192
192
  ## Unlink when done
package/README.md CHANGED
@@ -40,10 +40,10 @@ npm install -g unbound-cli
40
40
  unbound onboard --api-key <USER_KEY>
41
41
 
42
42
  # Admin enrolling a device via MDM (requires root)
43
- sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
43
+ sudo unbound onboard --api-key <ADMIN_KEY>
44
44
  ```
45
45
 
46
- The user API key and discovery API key are separate — discovery uses its own key that the CLI does not store. Run with `sudo` to let the discovery step scan all users on the device; without it, only the current user is scanned.
46
+ Only your API key is needed. Run with `sudo` to let the discovery step scan all users on the device (the device is attributed to its owner, resolved from the hardware serial); without it, only the current user is scanned.
47
47
 
48
48
  ## Commands
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.15.1",
3
+ "version": "1.16.7",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/auth-pages.js CHANGED
@@ -23,13 +23,11 @@ function escapeHtml(value) {
23
23
  .replace(/'/g, '&#39;');
24
24
  }
25
25
 
26
- // Unbound shield mark (public/Unbound.svg in unbound-fe), inlined verbatim.
27
- const LOGO_SVG =
28
- '<svg width="28" height="27" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
29
- '<path d="M5.12736 6.88525C6.33833 14.4266 11.9473 18.6338 12.0038 18.6757L9.577 22.0419C9.28788 21.8335 2.47374 16.8456 0.979492 7.53649L5.12639 6.88525H5.12736Z" fill="#7B56FB"/>' +
30
- '<path d="M18.7655 6.81787L22.8988 7.24132C22.8637 7.59566 21.9496 15.9907 14.6361 21.9394L12.0059 18.6755C17.9322 13.8559 18.7577 6.88796 18.7655 6.81787Z" fill="#7B56FB"/>' +
31
- '<path d="M12.1878 1.4375C14.6214 1.4375 17.3928 1.8113 20.282 2.91325L18.7654 6.81775C11.5638 4.07068 5.19258 6.85572 5.12833 6.88492L3.42188 3.10697C3.63993 3.00865 7.20178 1.4375 12.1878 1.4375Z" fill="#7B56FB"/>' +
32
- '</svg>';
26
+ // Full Unbound logo (public/unbound_logo_dark_theme.svg in unbound-fe), inlined
27
+ // verbatim. Kept at the asset's native 100x18 proportions and scaled up to
28
+ // 150px wide — the same treatment as the auto-topup modal — so the wordmark
29
+ // keeps its shape instead of being shrunk or squashed to fit a box.
30
+ const LOGO_SVG = '<svg aria-hidden="true" width="150" height="27" viewBox="0 0 100 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M28.3097 3.82617H30.9472V13.9767H28.3097V12.574C27.5351 13.6571 26.4897 14.1983 25.1742 14.1983C24.0547 14.1983 23.173 13.8383 22.5276 13.1184C21.8823 12.3985 21.5596 11.4241 21.5596 10.1934V3.82617H24.1971V9.62096C24.1971 10.3104 24.3411 10.8269 24.6301 11.1712C24.919 11.5163 25.3956 11.6885 26.0591 11.6885C26.7844 11.6885 27.3408 11.4735 27.7286 11.0427C28.1163 10.6119 28.3097 9.98504 28.3097 9.16051V3.82617Z" fill="#2C2C2C"/><path d="M38.455 3.60352C39.5985 3.60352 40.4957 3.961 41.1477 4.67434C41.7997 5.38767 42.1248 6.36623 42.1248 7.60838V13.9756H39.4873V8.18086C39.4873 7.49142 39.34 6.97495 39.0445 6.63064C38.7489 6.28633 38.2632 6.11335 37.5874 6.11335C36.0629 6.11335 35.3006 6.95601 35.3006 8.64131V13.9748H32.6631V3.82509H35.3006V5.20892C36.0629 4.1381 37.1141 3.60352 38.4542 3.60352H38.455Z" fill="#2C2C2C"/><path d="M49.6116 3.6029C50.9271 3.6029 52.0277 4.09877 52.9126 5.08887C53.7975 6.07979 54.2404 7.34995 54.2404 8.90017C54.2404 10.4504 53.8008 11.7214 52.9217 12.7115C52.0425 13.7024 50.9386 14.1974 49.6108 14.1974C48.283 14.1974 47.1881 13.6686 46.4012 12.6101V13.9759H43.7637V0.968681L46.4012 0V5.24537C47.1881 4.15066 48.2575 3.6029 49.6108 3.6029H49.6116ZM49.0033 11.6868C49.7409 11.6868 50.3525 11.4281 50.8382 10.9117C51.3238 10.3944 51.5667 9.7247 51.5667 8.90017C51.5667 8.07563 51.3271 7.40513 50.8472 6.88867C50.3681 6.37221 49.7532 6.11356 49.0033 6.11356C48.2534 6.11356 47.6318 6.37221 47.1404 6.88867C46.649 7.40596 46.4028 8.07646 46.4028 8.90017C46.4028 9.72387 46.649 10.3952 47.1404 10.9117C47.6318 11.4281 48.2534 11.6868 49.0033 11.6868Z" fill="#2C2C2C"/><path d="M57.0085 5.11667C58.0293 4.10845 59.2772 3.60352 60.7524 3.60352C62.2275 3.60352 63.4788 4.10845 64.5053 5.11667C65.5318 6.12571 66.0455 7.38681 66.0455 8.89996C66.0455 10.4131 65.5384 11.6899 64.5234 12.6923C63.5093 13.6956 62.2514 14.1964 60.7516 14.1964C59.2517 14.1964 58.0128 13.6923 56.9978 12.6833C55.9837 11.6742 55.4766 10.4131 55.4766 8.89996C55.4766 7.38681 55.9869 6.12571 57.0077 5.11667H57.0085ZM62.6243 6.88846C62.1263 6.372 61.5023 6.11335 60.7524 6.11335C60.0025 6.11335 59.3785 6.372 58.8805 6.88846C58.3824 7.40575 58.1338 8.07625 58.1338 8.89996C58.1338 9.72367 58.3832 10.395 58.8805 10.9115C59.3785 11.4279 60.0025 11.6866 60.7524 11.6866C61.5023 11.6866 62.1263 11.4279 62.6243 10.9115C63.1224 10.3942 63.371 9.72449 63.371 8.89996C63.371 8.07543 63.1224 7.40493 62.6243 6.88846Z" fill="#2C2C2C"/><path d="M74.1789 3.82617H76.8164V13.9767H74.1789V12.574C73.4042 13.6571 72.3588 14.1983 71.0433 14.1983C69.9238 14.1983 69.0422 13.8383 68.3968 13.1184C67.7514 12.3985 67.4287 11.4241 67.4287 10.1934V3.82617H70.0662V9.62096C70.0662 10.3104 70.2103 10.8269 70.4992 11.1712C70.7882 11.5163 71.2648 11.6885 71.9283 11.6885C72.6535 11.6885 73.21 11.4735 73.5977 11.0427C73.9854 10.6119 74.1789 9.98504 74.1789 9.16051V3.82617Z" fill="#2C2C2C"/><path d="M84.3232 3.60352C85.4666 3.60352 86.3639 3.961 87.0159 4.67434C87.6678 5.38767 87.993 6.36623 87.993 7.60838V13.9756H85.3555V8.18086C85.3555 7.49142 85.2081 6.97495 84.9126 6.63064C84.6171 6.28633 84.1314 6.11335 83.4556 6.11335C81.931 6.11335 81.1688 6.95601 81.1688 8.64131V13.9748H78.5312V3.82509H81.1688V5.20892C81.931 4.1381 82.9822 3.60352 84.3224 3.60352H84.3232Z" fill="#2C2C2C"/><path d="M97.3623 0.968681L99.9998 0V13.975H97.3623V12.6093C96.5877 13.6678 95.5241 14.1966 94.1716 14.1966C92.8191 14.1966 91.7399 13.7016 90.8607 12.7106C89.9816 11.7205 89.542 10.4496 89.542 8.89934C89.542 7.34912 89.9816 6.07896 90.8607 5.08804C91.7399 4.09795 92.8372 3.60207 94.1527 3.60207C95.4681 3.60207 96.5753 4.14984 97.3623 5.24455V0.968681ZM94.7618 11.6868C95.5118 11.6868 96.1325 11.4281 96.6247 10.9117C97.1162 10.3944 97.3623 9.7247 97.3623 8.90016C97.3623 8.07563 97.1162 7.40513 96.6247 6.88867C96.1325 6.3722 95.5118 6.11356 94.7618 6.11356C94.0119 6.11356 93.397 6.3722 92.9179 6.88867C92.438 7.40596 92.1984 8.07646 92.1984 8.90016C92.1984 9.72387 92.4413 10.3952 92.9269 10.9117C93.4126 11.4281 94.0243 11.6868 94.7618 11.6868Z" fill="#2C2C2C"/><path d="M3.50761 4.90186C4.53166 11.2831 9.27488 14.8432 9.32262 14.8786L7.27041 17.727C7.02593 17.5507 1.2636 13.33 0 5.45292L3.50679 4.90186H3.50761Z" fill="#7B56FB"/><path d="M15.0395 4.84521L18.5347 5.20353C18.5051 5.50336 17.7321 12.607 11.5475 17.6407L9.32324 14.8788C14.3348 10.8006 15.0329 4.90452 15.0395 4.84521Z" fill="#7B56FB"/><path d="M9.47728 0.29248C11.5353 0.29248 13.8789 0.608784 16.3221 1.54122L15.0396 4.84511C8.94961 2.52061 3.56184 4.87724 3.50751 4.90195L2.06445 1.70514C2.24885 1.62194 5.2609 0.29248 9.47728 0.29248Z" fill="#7B56FB"/></svg>';
33
31
 
34
32
  /**
35
33
  * Shared page shell. `status` slots the icon block, heading, and body copy;
@@ -69,15 +67,10 @@ function renderPage({ title, icon, heading, body }) {
69
67
  .brand {
70
68
  display: inline-flex;
71
69
  align-items: center;
72
- gap: 8px;
73
70
  margin-bottom: 28px;
74
71
  }
75
- .brand span {
76
- color: #17132B;
77
- font-size: 17px;
78
- font-weight: 600;
79
- letter-spacing: 0.02em;
80
- }
72
+ /* The wordmark ships its own intrinsic 150x27; never constrain it to a box. */
73
+ .brand svg { display: block; }
81
74
  .icon {
82
75
  display: block;
83
76
  width: 64px;
@@ -97,20 +90,27 @@ function renderPage({ title, icon, heading, body }) {
97
90
  h1 { color: #3d3d3d; font-size: 20px; font-weight: 600; margin-bottom: 10px; }
98
91
  p { color: #6b7280; font-size: 14px; line-height: 1.6; }
99
92
  .identity {
100
- display: inline-flex;
101
- flex-direction: column;
102
- gap: 2px;
103
- margin: 14px 0 6px;
104
- padding: 9px 18px;
93
+ display: block;
94
+ margin: 16px 0 18px;
105
95
  border: 1px solid #e5e7eb;
106
96
  border-radius: 12px;
107
97
  background: #fafafb;
108
- font-size: 13px;
109
- max-width: 100%;
98
+ text-align: left;
110
99
  overflow-wrap: anywhere;
111
100
  }
112
- .identity b { color: #3d3d3d; font-weight: 500; }
113
- .identity i { color: #6b7280; font-style: normal; font-size: 12px; }
101
+ /* Label above value, not beside it: a 400px card cannot hold a long tenant
102
+ name and a long email on the same line without truncating one of them. */
103
+ .identity .row { padding: 10px 16px; }
104
+ .identity .row + .row { border-top: 1px solid #e5e7eb; }
105
+ .identity dt {
106
+ color: #6b7280;
107
+ font-size: 11px;
108
+ font-weight: 500;
109
+ letter-spacing: 0.04em;
110
+ text-transform: uppercase;
111
+ margin-bottom: 3px;
112
+ }
113
+ .identity dd { color: #3d3d3d; font-size: 13px; font-weight: 500; }
114
114
  @keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
115
115
  @keyframes draw { to { stroke-dashoffset: 0; } }
116
116
  @media (prefers-reduced-motion: reduce) {
@@ -120,7 +120,7 @@ function renderPage({ title, icon, heading, body }) {
120
120
  </head>
121
121
  <body>
122
122
  <main class="card">
123
- <div class="brand">${LOGO_SVG}<span>unbound</span></div>
123
+ <div class="brand">${LOGO_SVG}</div>
124
124
  ${icon}
125
125
  <h1>${heading}</h1>
126
126
  ${body}
@@ -148,13 +148,16 @@ const CROSS_ICON =
148
148
  * query-param values — escaped here, and omitted entirely when absent.
149
149
  */
150
150
  function successPage({ email, orgName } = {}) {
151
- // Stacked (email over org) rather than one truncating line — real tenant
152
- // emails and org names routinely overflow a 400px card side by side.
153
- const identityParts = [];
154
- if (email) identityParts.push(`<b>${escapeHtml(email)}</b>`);
155
- if (orgName) identityParts.push(`<i>${escapeHtml(orgName)}</i>`);
156
- const identity = identityParts.length
157
- ? `<div class="identity">${identityParts.join('')}</div>`
151
+ // Each value carries its own label so the reader can tell an org named after
152
+ // a person from the account email. Rows are omitted when the query param is
153
+ // absent, so a missing org never leaves a dangling "Organization" label.
154
+ const row = (label, value) =>
155
+ `<div class="row"><dt>${label}</dt><dd>${escapeHtml(value)}</dd></div>`;
156
+ const identityRows = [];
157
+ if (orgName) identityRows.push(row('Organization', orgName));
158
+ if (email) identityRows.push(row('Email', email));
159
+ const identity = identityRows.length
160
+ ? `<dl class="identity">${identityRows.join('')}</dl>`
158
161
  : '';
159
162
 
160
163
  return renderPage({
package/src/auth.js CHANGED
@@ -8,7 +8,7 @@ const authPages = require('./auth-pages');
8
8
  const config = require('./config');
9
9
  const output = require('./output');
10
10
  const api = require('./api');
11
- const { getDeviceSerial } = require('./device-serial');
11
+ const { getDeviceSerial, probeDeviceSerialLive } = require('./device-serial');
12
12
 
13
13
  /**
14
14
  * Opens the browser for authentication and waits for the callback.
@@ -223,4 +223,31 @@ async function ensureLoggedIn({ apiKey, baseUrl, frontendUrl } = {}) {
223
223
  return true;
224
224
  }
225
225
 
226
- module.exports = { loginWithBrowser, loginWithApiKey, ensureLoggedIn };
226
+ /**
227
+ * Exchange the admin key + this device's hardware serial for the device owner's
228
+ * application key, so an org-wide scan is attributed to the owner, not the admin.
229
+ * Throws on a missing serial or unusable response so the caller can abort.
230
+ */
231
+ async function fetchDeviceOwnerKey(adminApiKey, { baseUrl } = {}) {
232
+ // Probe the serial live, never the user-writable cache — it decides whose key
233
+ // gets minted, so a planted cache value must not be trusted here.
234
+ const deviceSerial = await probeDeviceSerialLive();
235
+ if (!deviceSerial) {
236
+ throw new Error(
237
+ "Could not read this device's hardware serial number. "
238
+ + 'Re-run without sudo to onboard just the current user.'
239
+ );
240
+ }
241
+ const resp = await api.get('/api/v1/automations/mdm/get_application_api_key/', {
242
+ apiKey: adminApiKey,
243
+ baseUrl,
244
+ query: { serial_number: deviceSerial, app_type: 'default' },
245
+ });
246
+ const ownerKey = resp && resp.api_key;
247
+ if (!ownerKey) {
248
+ throw new Error('The backend did not return a key for this device.');
249
+ }
250
+ return ownerKey;
251
+ }
252
+
253
+ module.exports = { loginWithBrowser, loginWithApiKey, ensureLoggedIn, fetchDeviceOwnerKey };
@@ -0,0 +1,82 @@
1
+ const os = require('os');
2
+ const path = require('path');
3
+
4
+ const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude');
5
+
6
+ // Mirrors Claude Code's own resolution: it reads CLAUDE_CONFIG_DIR verbatim,
7
+ // with no tilde expansion. Expanding "~" would install to $HOME/x while Claude
8
+ // reads ./~/x. A blank value is treated as unset so setup never targets the cwd.
9
+ function resolveClaudeConfigDir() {
10
+ const raw = (process.env.CLAUDE_CONFIG_DIR || '').trim();
11
+ return raw ? path.resolve(raw) : DEFAULT_CLAUDE_DIR;
12
+ }
13
+
14
+ // Claude Code resolves CLAUDE_CONFIG_DIR against the cwd when it is set but
15
+ // empty, rather than falling back to ~/.claude — so a default install is one it
16
+ // never reads. Callers surface this instead of reporting a healthy setup.
17
+ const BLANK_CONFIG_DIR_MESSAGE =
18
+ 'CLAUDE_CONFIG_DIR is set but empty — Claude Code reads that as a path relative to the ' +
19
+ 'current directory, so it will not load hooks installed in ~/.claude. Unset it, or point ' +
20
+ 'it at a real directory.';
21
+
22
+ function isBlankConfigDir() {
23
+ const raw = process.env.CLAUDE_CONFIG_DIR;
24
+ return typeof raw === 'string' && raw.trim() === '';
25
+ }
26
+
27
+ // Windows paths are case-insensitive, so C:\\Users\\jane\\.claude and
28
+ // c:\\users\\jane\\.claude name one directory. Comparing them literally would
29
+ // class the default dir as relocated and forward a --config-dir nobody needs.
30
+ const samePath = process.platform === 'win32'
31
+ ? (a, b) => a.toLowerCase() === b.toLowerCase()
32
+ : (a, b) => a === b;
33
+
34
+ function isNonDefaultConfigDir() {
35
+ return !samePath(resolveClaudeConfigDir(), DEFAULT_CLAUDE_DIR);
36
+ }
37
+
38
+ function claudeHooksDir(dir = resolveClaudeConfigDir()) {
39
+ return path.join(dir, 'hooks');
40
+ }
41
+
42
+ function claudeSettingsPath(dir = resolveClaudeConfigDir()) {
43
+ return path.join(dir, 'settings.json');
44
+ }
45
+
46
+ // Renders a path under the home dir with a leading ~, so a default install
47
+ // still reads as ~/.claude/... in status output rather than a long absolute path.
48
+ function displayPath(p) {
49
+ const home = os.homedir();
50
+ if (samePath(p, home)) return '~';
51
+ const prefix = home + path.sep;
52
+ return samePath(p.slice(0, prefix.length), prefix) ? '~' + p.slice(home.length) : p;
53
+ }
54
+
55
+ const CLAUDE_INSTALL_MARKERS = [path.join('hooks', 'unbound.py'), 'anthropic_key.sh'];
56
+
57
+ // Reports hooks sitting in ~/.claude while a different dir is active, so the
58
+ // caller can fail loudly instead of showing a green status Claude never honors.
59
+ function detectConfigDirMismatch(markers = CLAUDE_INSTALL_MARKERS, { fs = require('fs') } = {}) {
60
+ const activeDir = resolveClaudeConfigDir();
61
+ if (samePath(activeDir, DEFAULT_CLAUDE_DIR)) return null;
62
+ const anyExists = (base) => markers.some((rel) => {
63
+ try { return fs.existsSync(path.join(base, rel)); } catch { return false; }
64
+ });
65
+ if (!anyExists(activeDir) && anyExists(DEFAULT_CLAUDE_DIR)) {
66
+ return { activeDir, staleDir: DEFAULT_CLAUDE_DIR };
67
+ }
68
+ return null;
69
+ }
70
+
71
+ module.exports = {
72
+ DEFAULT_CLAUDE_DIR,
73
+ resolveClaudeConfigDir,
74
+ samePath,
75
+ isNonDefaultConfigDir,
76
+ isBlankConfigDir,
77
+ BLANK_CONFIG_DIR_MESSAGE,
78
+ claudeHooksDir,
79
+ claudeSettingsPath,
80
+ displayPath,
81
+ detectConfigDirMismatch,
82
+ };
@@ -280,7 +280,7 @@ Examples:
280
280
  $ unbound analytics tool-use mcp-actions --server claude_ai_notion
281
281
  $ unbound analytics tool-use unsanctioned --format csv -o blocked.csv
282
282
 
283
- Admin role required — for listing as well as CSV export.
283
+ Admin or Analytics Viewer. Applies to listing as well as CSV export.
284
284
  ${FILTER_HELP}${AGENT_HELP}`);
285
285
 
286
286
  attach(toolUse, 'terminal', DATASETS.terminal, `
@@ -179,7 +179,7 @@ function normalizeAssistantTurn(turn) {
179
179
  // backend-supplied strings out of the shell line so copy-pastes stay safe.
180
180
  function friendlyChatError(err) {
181
181
  if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
182
- if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound status` to check yours.';
182
+ if (err?.statusCode === 403) return 'Chat requires Admin, Analytics Viewer or Manager role. Run `unbound status` to check yours.';
183
183
  if (err?.statusCode === 429) return 'Rate limited. Please wait a moment and try again.';
184
184
  if (err?.statusCode === 504) return 'The turn took too long and the connection was closed. Try narrowing the question.';
185
185
  return sanitizeForTerminal(err?.message || 'Request failed.');
@@ -666,7 +666,7 @@ INTERACTIVE SESSION
666
666
  /exit quit (Ctrl+C also works)
667
667
 
668
668
  NOTES
669
- * Requires Admin or Manager role in your organization. Run 'unbound status' to check.
669
+ * Requires Admin, Analytics Viewer or Manager role in your organization. Run 'unbound status' to check.
670
670
  * A question the assistant asks back is a normal reply, not an error: the turn
671
671
  exits 0, and you answer it in the next one with -c.
672
672
  * Each -m invocation is its own process and sends no history. -c carries the
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const config = require('../config');
6
6
  const output = require('../output');
7
7
  const telemetry = require('../telemetry');
8
+ const { Option } = require('commander');
8
9
  const { shellEscape, downloadToFile, withSecureTempFile, DISCOVER_BASE_URL } = require('../utils');
9
10
  const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
10
11
 
@@ -17,7 +18,7 @@ const DISCOVERY_EXIT_UNSUPPORTED_OS = 3;
17
18
  // indefinitely. Discovery enforces this itself — on expiry it releases its lock,
18
19
  // reports the run as failed, and exits non-zero. Kept in sync with
19
20
  // setup/mdm/onboard.py's DISCOVERY_TIMEOUT_SECONDS.
20
- const DISCOVERY_TIMEOUT_SECONDS = 9000;
21
+ const DISCOVERY_TIMEOUT_SECONDS = 12000;
21
22
 
22
23
  // Classifies a discovery subprocess exit code:
23
24
  // 'success' (scan ran), 'unsupported' (skipped on this OS), or 'failure'.
@@ -34,8 +35,10 @@ function isWindowsNative() {
34
35
  }
35
36
 
36
37
  function isRoot() {
37
- if (process.platform === 'win32') return false;
38
- return typeof process.getuid === 'function' && process.getuid() === 0;
38
+ // Delegate to the shared, Windows-aware privilege check so `discover` and
39
+ // `onboard` agree on scope on every platform. Lazy require avoids any
40
+ // module-load cycle between discover and setup.
41
+ return require('./setup').hasRootPrivileges();
39
42
  }
40
43
 
41
44
  /**
@@ -194,6 +197,7 @@ function register(program) {
194
197
  .option('--api-key <key>', 'Discovery API key (required)')
195
198
  .option('--domain <url>', 'Backend URL (defaults to configured backend)')
196
199
  .option('--set-cron', 'Set up a daily scheduled discovery scan at 09:00 (cross-platform)')
200
+ .addOption(new Option('--discovery-key <key>', 'Deprecated and ignored. The scan uses your API key.').hideHelp())
197
201
  .addHelpText('after', `
198
202
  Scans this device for installed AI coding tools (Cursor, Claude Code,
199
203
  Gemini CLI, Codex, Windsurf, Roo Code, Cline, GitHub Copilot, JetBrains,
@@ -201,7 +205,7 @@ and more) and reports findings to the Unbound backend.
201
205
 
202
206
  Which --api-key to use depends on scope:
203
207
  - Single-user scan (no sudo): use your own user API key.
204
- - All-users scan (sudo): use the org discovery key.
208
+ - All-users scan (sudo): use your admin API key.
205
209
  The --domain defaults to the backend URL configured via "unbound config set-backend-url"
206
210
  (falls back to https://backend.getunbound.ai when unset).
207
211
 
@@ -218,8 +222,15 @@ Examples:
218
222
  `)
219
223
  .action(telemetry.wrapAction('discover', async (opts) => {
220
224
  let scanSucceeded = false;
221
- // Register the discovery key so beforeSend redacts it from any event.
225
+ // Register the api key so beforeSend redacts it from any event.
222
226
  telemetry.rememberSecret(opts.apiKey);
227
+ // Accept and ignore a stale discovery key (flag or env) instead of erroring;
228
+ // the scan uses the API key. Redact it, then warn.
229
+ const staleDiscoveryKey = opts.discoveryKey || process.env.UNBOUND_DISCOVERY_KEY;
230
+ if (staleDiscoveryKey) {
231
+ telemetry.rememberSecret(staleDiscoveryKey);
232
+ output.warn('--discovery-key is deprecated and ignored — the scan uses your API key.');
233
+ }
223
234
  try {
224
235
  if (!opts.apiKey) {
225
236
  output.error('--api-key is required.');
@@ -254,7 +265,7 @@ Examples:
254
265
  const domain = opts.domain || config.getBaseUrl();
255
266
  const suffix = domain && domain !== config.DEFAULT_BASE_URL ? ` --domain ${domain}` : '';
256
267
  console.error(' Discovery completed successfully — only scheduled-run setup failed.');
257
- console.error(` Re-run cron setup with: unbound discover --set-cron --api-key <DISCOVERY_KEY>${suffix}`);
268
+ console.error(` Re-run cron setup with: unbound discover --set-cron --api-key <KEY>${suffix}`);
258
269
  }
259
270
  process.exitCode = 1;
260
271
  }
@@ -4,7 +4,8 @@ const api = require('../api');
4
4
  const output = require('../output');
5
5
  const { getDeviceSerial } = require('../device-serial');
6
6
  const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
7
- const { hasRootPrivileges } = require('./setup');
7
+ const { hasRootPrivileges, elevated } = require('./setup');
8
+ const claudePaths = require('../claudePaths');
8
9
 
9
10
  // Validate the stored API key against the backend. Returns one of:
10
11
  // valid | invalid | not-logged-in | unverified (network/other error).
@@ -74,7 +75,9 @@ Examples:
74
75
  spin.stop();
75
76
 
76
77
  const tampered = tools.filter((t) => t.status === 'tampered');
77
- const unhealthy = tampered.length > 0 || key.state === 'invalid';
78
+ const configMismatch = claudePaths.detectConfigDirMismatch();
79
+ const blankConfigDir = claudePaths.isBlankConfigDir();
80
+ const unhealthy = tampered.length > 0 || key.state === 'invalid' || !!configMismatch || blankConfigDir;
78
81
 
79
82
  if (opts.json) {
80
83
  output.json({
@@ -92,12 +95,19 @@ Examples:
92
95
  })),
93
96
  healthy: !unhealthy,
94
97
  key_validations_skipped: keySkippedCount,
98
+ claude_config_dir_mismatch: configMismatch,
99
+ claude_config_dir_blank: blankConfigDir,
95
100
  });
96
101
  if (unhealthy) process.exitCode = 1;
97
102
  return;
98
103
  }
99
104
 
100
105
  const C = output.colors;
106
+ const reportConfigMismatch = (m) => {
107
+ console.log(C.red('✗ Claude Code is installed where Claude Code won’t read it.'));
108
+ console.log(` Install lives in ${C.bold(m.staleDir)} but ${C.bold('CLAUDE_CONFIG_DIR')} points at ${C.bold(m.activeDir)}.`);
109
+ console.log(` Re-run ${C.bold('unbound setup')} with CLAUDE_CONFIG_DIR set so it installs under the active dir.`);
110
+ };
101
111
  const labelOf = (t) => t.label + (t.mode && t.mode !== 'subscription' ? ` (${t.mode})` : '');
102
112
  const W = Math.max(7, ...tools.map((t) => labelOf(t).length)); // 7 = "API key"
103
113
 
@@ -146,7 +156,16 @@ Examples:
146
156
  const anyInstalled = tools.some((t) => t.status !== 'not-installed');
147
157
 
148
158
  if (opts.fix) {
159
+ if (blankConfigDir) {
160
+ output.error(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
161
+ console.log('');
162
+ }
163
+ if (configMismatch) {
164
+ reportConfigMismatch(configMismatch);
165
+ console.log('');
166
+ }
149
167
  if (!tampered.length) {
168
+ if (configMismatch || blankConfigDir) { process.exitCode = 1; return; }
150
169
  if (!anyInstalled) console.log(`${C.yellow('No tools are set up yet')} — nothing to fix. Run ${C.bold('unbound setup')} first.`);
151
170
  else output.success('Nothing to fix — all set up tools are healthy.');
152
171
  console.log('');
@@ -157,7 +176,7 @@ Examples:
157
176
  // tools only, and point at sudo for whatever's org-managed.
158
177
  if (!root && !userFix.length) {
159
178
  console.log(`${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
160
- console.log(` Run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
179
+ console.log(` Run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
161
180
  console.log('');
162
181
  process.exitCode = 1;
163
182
  return;
@@ -167,13 +186,13 @@ Examples:
167
186
  output.info(`Reinstalling: ${fixDisplay}${root ? ' (org-wide, all users)' : ''}`);
168
187
  console.log('');
169
188
  const r = spawnSync(process.argv[0], [process.argv[1], 'setup', ...fixNow], { stdio: 'inherit' });
170
- 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'}.`);
171
- process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0);
189
+ if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
190
+ process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0) || ((configMismatch || blankConfigDir) ? 1 : 0);
172
191
  return;
173
192
  }
174
193
 
175
194
  // No `--fix`: report and show the command(s) that fix it.
176
- if (!anyInstalled && key.state !== 'invalid') {
195
+ if (!anyInstalled && key.state !== 'invalid' && !configMismatch && !blankConfigDir) {
177
196
  console.log(`${C.yellow('No AI tools are set up yet.')} Run ${C.bold('unbound setup')} to get started.`);
178
197
  console.log('');
179
198
  return;
@@ -183,17 +202,22 @@ Examples:
183
202
  console.log('');
184
203
  return;
185
204
  }
205
+ if (blankConfigDir) {
206
+ output.error(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
207
+ process.exitCode = 1;
208
+ }
209
+ if (configMismatch) reportConfigMismatch(configMismatch);
186
210
  if (tampered.length) {
187
211
  console.log(C.red(`✗ ${tampered.length} tool${tampered.length === 1 ? ' needs' : 's need'} attention.`));
188
212
  if (!mdmFix.length) {
189
213
  console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
190
214
  } else if (!userFix.length) {
191
215
  console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
192
- console.log(` Run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
216
+ console.log(` Run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
193
217
  } else {
194
218
  // Both: a user without sudo can still fix their own tools; sudo fixes everything.
195
219
  console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
196
- 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.`);
220
+ console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'} too.`);
197
221
  }
198
222
  }
199
223
  if (key.state === 'invalid') {
@@ -162,7 +162,7 @@ Examples:
162
162
  $ unbound download start logs-export --check --last 90d --json
163
163
  $ unbound download get 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6
164
164
 
165
- Admin role required. Exports are kept for ${RETENTION_HOURS} hours.
165
+ Admin or Analytics Viewer. Exports are kept for ${RETENTION_HOURS} hours.
166
166
  `);
167
167
 
168
168
  group
@@ -203,7 +203,7 @@ Examples:
203
203
  $ unbound download start analytics-export --filter dataset=terminal-runs --last 7d
204
204
  $ unbound download start logs-export --check --last 90d --json
205
205
 
206
- Admin role required. Exports are kept for ${RETENTION_HOURS} hours.
206
+ Admin or Analytics Viewer. Exports are kept for ${RETENTION_HOURS} hours.
207
207
  ${FILTER_HELP}${AGENT_HELP}`)
208
208
  .action(async (kind, opts) => {
209
209
  try {
@@ -17,13 +17,16 @@ const PKG_VERSION = '0.2.1';
17
17
  const TIERS = ['shadow', 'receipts', 'baseline', 'strict', 'paranoid'];
18
18
  const AGENTS = ['claude-code', 'codex'];
19
19
 
20
- // Consent receipt path
21
- const CONSENT_RECEIPT_PATH = path.join(os.homedir(), '.claude', 'oacb-consent.json');
20
+ const claudePaths = require('../claudePaths');
21
+ const CLAUDE_DIR = claudePaths.resolveClaudeConfigDir();
22
+
23
+ const CONSENT_RECEIPT_PATH = path.join(CLAUDE_DIR, 'oacb-consent.json');
24
+ const LEGACY_CONSENT_RECEIPT_PATH = path.join(claudePaths.DEFAULT_CLAUDE_DIR, 'oacb-consent.json');
22
25
 
23
26
  // Claude Code paths
24
- const CLAUDE_HOOKS_DIR = path.join(os.homedir(), '.claude', 'hooks');
25
- const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
26
- const CLAUDE_SETTINGS_BACKUP_PATH = path.join(os.homedir(), '.claude', 'settings.json.oacb-backup');
27
+ const CLAUDE_HOOKS_DIR = claudePaths.claudeHooksDir(CLAUDE_DIR);
28
+ const CLAUDE_SETTINGS_PATH = claudePaths.claudeSettingsPath(CLAUDE_DIR);
29
+ const CLAUDE_SETTINGS_BACKUP_PATH = path.join(CLAUDE_DIR, 'settings.json.oacb-backup');
27
30
 
28
31
  // Codex paths
29
32
  const CODEX_HOOKS_DIR = path.join(os.homedir(), '.codex', 'hooks');
@@ -203,7 +206,7 @@ async function handleCheck() {
203
206
  ['Unbound session', hasUnboundSession ? 'logged in' : 'not logged in (optional)'],
204
207
  ['─── Claude Code ───', ''],
205
208
  ['Claude Code on PATH', claudeCodeVersion || 'not detected'],
206
- ['~/.claude/settings.json', claudeSettingsExist ? 'found' : 'not found'],
209
+ [claudePaths.displayPath(CLAUDE_SETTINGS_PATH), claudeSettingsExist ? 'found' : 'not found'],
207
210
  ['OACB tier applied', claudeTier !== 'none' ? claudeTier : 'none (run `unbound oacb apply`)'],
208
211
  ['Hooks installed', `${claudeHookStates.filter(Boolean).length} / ${HOOK_NAMES.length}`],
209
212
  ['─── Codex CLI ─────', ''],
@@ -225,7 +228,22 @@ async function handleCheck() {
225
228
  output.warn('No OACB tier applied to any agent. Run `unbound oacb apply` to start with shadow tier.');
226
229
  }
227
230
 
228
- output.success('Pre-flight complete.');
231
+ const blankConfigDir = claudePaths.isBlankConfigDir();
232
+ if (blankConfigDir) {
233
+ output.error(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
234
+ process.exitCode = 1;
235
+ }
236
+
237
+ const mismatch = claudePaths.detectConfigDirMismatch([path.join('hooks', HOOK_NAMES[0])]);
238
+ if (mismatch) {
239
+ output.error(
240
+ `OACB hooks installed in ${mismatch.staleDir} but CLAUDE_CONFIG_DIR points at ${mismatch.activeDir} — ` +
241
+ 'Claude will not load them; re-run `unbound oacb apply`.'
242
+ );
243
+ process.exitCode = 1;
244
+ }
245
+
246
+ if (!mismatch && !blankConfigDir) output.success('Pre-flight complete.');
229
247
  }
230
248
 
231
249
  async function handleAudit({ agent, tier, format }) {
@@ -464,7 +482,7 @@ async function _applyClaudeCode({ baseline, tier, overrides, localHooks }) {
464
482
 
465
483
  output.success(`OACB ${tier} applied (claude-code).`);
466
484
  if (tier === 'shadow') {
467
- output.info('Shadow: hooks log but never block. Review ~/.claude/hooks/oacb-audit.log for 2 weeks, then `unbound oacb apply --tier baseline`.');
485
+ output.info(`Shadow: hooks log but never block. Review ${path.join(CLAUDE_HOOKS_DIR, 'oacb-audit.log')} for 2 weeks, then \`unbound oacb apply --tier baseline\`.`);
468
486
  } else if (tier === 'receipts') {
469
487
  output.info('Receipts: engineer sees WARN banners for risky commands and can abort. Expires in 30 days.');
470
488
  }
@@ -762,6 +780,11 @@ async function handleRemove({ agent, dryRun }) {
762
780
  output.success(`Wrote cleaned ${CLAUDE_SETTINGS_PATH}`);
763
781
 
764
782
  try { await fs.unlink(CONSENT_RECEIPT_PATH); } catch (_) {}
783
+ // readConsentReceipt falls back to the legacy receipt, so a leftover copy
784
+ // would keep reporting OACB as installed after it was removed.
785
+ if (!claudePaths.samePath(CONSENT_RECEIPT_PATH, LEGACY_CONSENT_RECEIPT_PATH)) {
786
+ try { await fs.unlink(LEGACY_CONSENT_RECEIPT_PATH); } catch (_) {}
787
+ }
765
788
  // The backup is no longer load-bearing after a successful remove — clean it
766
789
  // up so the user's ~/.claude/ doesn't accumulate stale OACB state.
767
790
  try { await fs.unlink(CLAUDE_SETTINGS_BACKUP_PATH); } catch (_) {}
@@ -1517,7 +1540,7 @@ async function historyDryRun(tier, agent) {
1517
1540
  const commands = [];
1518
1541
 
1519
1542
  // Try Claude Code session logs first (most accurate for auto-mode context)
1520
- const ccProjectsDir = path.join(os.homedir(), '.claude', 'projects');
1543
+ const ccProjectsDir = path.join(CLAUDE_DIR, 'projects');
1521
1544
  try {
1522
1545
  const projectDirs = await fs.readdir(ccProjectsDir);
1523
1546
  for (const proj of projectDirs.slice(-5)) { // last 5 projects to bound cost
@@ -1676,6 +1699,9 @@ async function readConsentReceipt() {
1676
1699
  try {
1677
1700
  return JSON.parse(await fs.readFile(CONSENT_RECEIPT_PATH, 'utf8'));
1678
1701
  } catch (_) {
1702
+ if (!claudePaths.samePath(CONSENT_RECEIPT_PATH, LEGACY_CONSENT_RECEIPT_PATH)) {
1703
+ try { return JSON.parse(await fs.readFile(LEGACY_CONSENT_RECEIPT_PATH, 'utf8')); } catch (_) {}
1704
+ }
1679
1705
  return null;
1680
1706
  }
1681
1707
  }
@@ -1685,7 +1711,7 @@ async function readConsentReceipt() {
1685
1711
  async function handleWhy({ agent }) {
1686
1712
  const logPath = agent === 'codex'
1687
1713
  ? path.join(os.homedir(), '.codex', 'hooks', 'oacb-audit.log')
1688
- : path.join(os.homedir(), '.claude', 'hooks', 'oacb-audit.log');
1714
+ : path.join(CLAUDE_HOOKS_DIR, 'oacb-audit.log');
1689
1715
 
1690
1716
  let entry = null;
1691
1717
  try {
@@ -1778,7 +1804,7 @@ async function handleStatus({ agent }) {
1778
1804
 
1779
1805
  const logPath = agent === 'codex'
1780
1806
  ? path.join(os.homedir(), '.codex', 'hooks', 'oacb-audit.log')
1781
- : path.join(os.homedir(), '.claude', 'hooks', 'oacb-audit.log');
1807
+ : path.join(CLAUDE_HOOKS_DIR, 'oacb-audit.log');
1782
1808
 
1783
1809
  // Expiry countdown
1784
1810
  let expiryNote = '';