unbound-cli 1.17.0 → 1.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -377,6 +377,7 @@ async function runSetupScriptReported(script, stepName, apiKey, opts = {}) {
377
377
  // — it's a generic user-owned var that can have non-Unbound uses.
378
378
  const NUKE_ENV_VARS = [
379
379
  'UNBOUND_API_KEY',
380
+ 'UNBOUND_DEVICE_TOKEN',
380
381
  'UNBOUND_CLAUDE_API_KEY',
381
382
  'UNBOUND_CODEX_API_KEY',
382
383
  'UNBOUND_COPILOT_API_KEY',
package/src/config.js CHANGED
@@ -77,11 +77,15 @@ function clearApiKey() {
77
77
 
78
78
  // The send-only device key, where setup scripts and hooks read it.
79
79
  function getDeviceKey() {
80
- return readConfig().api_key || null;
80
+ const config = readConfig();
81
+ // api_key first: every writer still sets it, and older writers set only it.
82
+ return config.api_key || config.device_token || null;
81
83
  }
82
84
 
83
85
  function setDeviceKey(apiKey) {
84
86
  const config = readConfig();
87
+ config.device_token = apiKey;
88
+ // Older CLIs and hooks read only `api_key`; stop writing it once they are gone.
85
89
  config.api_key = apiKey;
86
90
  writeConfig(config);
87
91
  }
package/src/toolHealth.js CHANGED
@@ -206,6 +206,14 @@ function envKeyCheck(label, name, kind = 'aux') {
206
206
  };
207
207
  }
208
208
 
209
+ // Setup writes UNBOUND_DEVICE_TOKEN; installs from before it carry only the per-tool name.
210
+ function deviceTokenCheck(legacyName) {
211
+ const check = envKeyCheck('API key env', 'UNBOUND_DEVICE_TOKEN');
212
+ if (check.ok) return check;
213
+ const legacy = envKeyCheck('API key env', legacyName);
214
+ return legacy.ok ? legacy : check;
215
+ }
216
+
209
217
  function envCheck(label, name, expected, kind = 'aux') {
210
218
  const base = label.replace(/ env$/, '');
211
219
  const r = readEnvVar(name);
@@ -334,7 +342,7 @@ function buildVariants(gatewayUrl, binaryPath) {
334
342
  checks: () => [
335
343
  configCheck('Config', '~/.cursor/hooks.json', { json: true, test: refsUnbound }),
336
344
  scriptCheck('Hook script', '~/.cursor/hooks/unbound.py'),
337
- envKeyCheck('API key env', 'UNBOUND_CURSOR_API_KEY'),
345
+ deviceTokenCheck('UNBOUND_CURSOR_API_KEY'),
338
346
  ],
339
347
  },
340
348
  {
@@ -342,7 +350,7 @@ function buildVariants(gatewayUrl, binaryPath) {
342
350
  checks: () => [
343
351
  configCheck('Config', claudePath('settings.json'), { json: true, test: refsUnbound }),
344
352
  scriptCheck('Hook script', claudePath('hooks', 'unbound.py')),
345
- envKeyCheck('API key env', 'UNBOUND_CLAUDE_API_KEY'),
353
+ deviceTokenCheck('UNBOUND_CLAUDE_API_KEY'),
346
354
  ],
347
355
  },
348
356
  {
@@ -373,7 +381,7 @@ function buildVariants(gatewayUrl, binaryPath) {
373
381
  return features.hooks === undefined ? features.codex_hooks === true : features.hooks === true;
374
382
  } }, { short: 'codex hooks not enabled', kind: 'aux' }), // aux: an org install leaves it set with no user hook
375
383
  scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
376
- envKeyCheck('API key env', 'UNBOUND_CODEX_API_KEY'),
384
+ deviceTokenCheck('UNBOUND_CODEX_API_KEY'),
377
385
  ],
378
386
  },
379
387
  {
@@ -397,7 +405,7 @@ function buildVariants(gatewayUrl, binaryPath) {
397
405
  const checks = [configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound })];
398
406
  if (hasBinary) checks.push(scriptCheck('Hook binary', binaryPath || BINARY_PATH));
399
407
  else checks.push(scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'));
400
- checks.push(envKeyCheck('API key env', 'UNBOUND_COPILOT_API_KEY'));
408
+ checks.push(deviceTokenCheck('UNBOUND_COPILOT_API_KEY'));
401
409
  return checks;
402
410
  },
403
411
  },
@@ -414,7 +422,7 @@ function buildVariants(gatewayUrl, binaryPath) {
414
422
  const checks = [configCheck('Config', '~/.augment/settings.json', { json: false, test: (t) => t.includes('unbound.py') || t.includes(BINARY_MARKER) })];
415
423
  if (hasBinary) checks.push(scriptCheck('Hook binary', binaryPath || BINARY_PATH));
416
424
  else checks.push(scriptCheck('Hook script', '~/.augment/hooks/unbound.py'));
417
- checks.push(envKeyCheck('API key env', 'UNBOUND_AUGMENT_API_KEY'));
425
+ checks.push(deviceTokenCheck('UNBOUND_AUGMENT_API_KEY'));
418
426
  return checks;
419
427
  },
420
428
  },
@@ -0,0 +1,46 @@
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
+ function withConfig(contents, fn) {
8
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-token-'));
9
+ const origHome = process.env.HOME;
10
+ process.env.HOME = tmp;
11
+ const file = path.join(tmp, '.unbound', 'config.json');
12
+ if (contents) {
13
+ fs.mkdirSync(path.dirname(file), { recursive: true });
14
+ fs.writeFileSync(file, JSON.stringify(contents));
15
+ }
16
+ delete require.cache[require.resolve('../src/config')];
17
+ try {
18
+ return fn(require('../src/config'), file);
19
+ } finally {
20
+ process.env.HOME = origHome;
21
+ delete require.cache[require.resolve('../src/config')];
22
+ fs.rmSync(tmp, { recursive: true, force: true });
23
+ }
24
+ }
25
+
26
+ test('getDeviceKey prefers api_key, which an older writer may have updated alone', () => {
27
+ withConfig({ device_token: 'stale', api_key: 'relogged' }, (config) => {
28
+ assert.equal(config.getDeviceKey(), 'relogged');
29
+ });
30
+ });
31
+
32
+ test('getDeviceKey falls back to device_token', () => {
33
+ withConfig({ device_token: 'new' }, (config) => {
34
+ assert.equal(config.getDeviceKey(), 'new');
35
+ });
36
+ });
37
+
38
+ test('setDeviceKey writes both fields so older CLIs and hooks still read it', () => {
39
+ withConfig({ api_key: 'old', base_url: 'https://api.acme.com' }, (config, file) => {
40
+ config.setDeviceKey('k2');
41
+ const written = JSON.parse(fs.readFileSync(file, 'utf8'));
42
+ assert.equal(written.device_token, 'k2');
43
+ assert.equal(written.api_key, 'k2');
44
+ assert.equal(written.base_url, 'https://api.acme.com');
45
+ });
46
+ });
@@ -1085,3 +1085,34 @@ test('isUnboundKeyHelper: a helper under a relocated CLAUDE_CONFIG_DIR is still
1085
1085
  delete require.cache[require.resolve('../src/toolHealth')];
1086
1086
  }
1087
1087
  });
1088
+
1089
+ // The per-tool env check accepts the shared UNBOUND_DEVICE_TOKEN or the per-tool name from older installs.
1090
+ function claudeWithEnv(env) {
1091
+ return withHome((tmp, th) => {
1092
+ writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
1093
+ writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
1094
+ const names = ['UNBOUND_DEVICE_TOKEN', 'UNBOUND_CLAUDE_API_KEY'];
1095
+ const saved = names.map((n) => process.env[n]);
1096
+ names.forEach((n) => delete process.env[n]);
1097
+ Object.assign(process.env, env);
1098
+ try {
1099
+ return th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'claude-code');
1100
+ } finally {
1101
+ names.forEach((n, i) => { if (saved[i] === undefined) delete process.env[n]; else process.env[n] = saved[i]; });
1102
+ }
1103
+ });
1104
+ }
1105
+
1106
+ test('claude-code: UNBOUND_DEVICE_TOKEN alone → healthy', () => {
1107
+ assert.equal(claudeWithEnv({ UNBOUND_DEVICE_TOKEN: 'k' }).status, 'healthy');
1108
+ });
1109
+
1110
+ test('claude-code: only the per-tool UNBOUND_CLAUDE_API_KEY → healthy', () => {
1111
+ assert.equal(claudeWithEnv({ UNBOUND_CLAUDE_API_KEY: 'k' }).status, 'healthy');
1112
+ });
1113
+
1114
+ test('claude-code: neither env set → tampered, naming UNBOUND_DEVICE_TOKEN', () => {
1115
+ const t = claudeWithEnv({});
1116
+ assert.equal(t.status, 'tampered');
1117
+ assert.ok(t.checks.some((c) => !c.ok && /UNBOUND_DEVICE_TOKEN is not set/.test(c.detail)));
1118
+ });