unbound-cli 1.15.2 → 1.16.8

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.2",
3
+ "version": "1.16.8",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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 = '';