unbound-cli 0.9.2 → 0.9.6

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/README.md CHANGED
@@ -72,6 +72,7 @@ Automated setup (downloads scripts, sets env vars, configures tool):
72
72
  | Command | Description |
73
73
  |---------|-------------|
74
74
  | `unbound setup cursor` | Download hooks, set env, restart Cursor |
75
+ | `unbound setup copilot` | Download hooks, set env, configure GitHub Copilot |
75
76
  | `unbound setup claude-code` | Interactive mode selection (subscription or gateway) |
76
77
  | `unbound setup claude-code --subscription` | Hooks only (keep your Claude subscription) |
77
78
  | `unbound setup claude-code --gateway` | Use Unbound as the AI provider |
@@ -94,6 +95,7 @@ Remove configuration:
94
95
  | Command | Description |
95
96
  |---------|-------------|
96
97
  | `unbound setup cursor --clear` | Remove Unbound config for Cursor |
98
+ | `unbound setup copilot --clear` | Remove Unbound config for GitHub Copilot |
97
99
  | `unbound setup claude-code --clear` | Remove Unbound config for Claude Code |
98
100
  | `unbound setup gemini-cli --clear` | Remove Unbound config for Gemini CLI |
99
101
  | `unbound setup codex --clear` | Remove Unbound config for Codex |
@@ -108,7 +110,7 @@ Configure all users on a device via MDM. Requires root.
108
110
  | `sudo unbound setup mdm --admin-api-key KEY cursor codex-subscription` | Set up specific tools |
109
111
  | `sudo unbound setup mdm --admin-api-key KEY --clear cursor` | Remove config for specific tools |
110
112
 
111
- Available tools: `cursor`, `claude-code-subscription`, `claude-code-gateway`, `gemini-cli`, `codex-subscription`, `codex-gateway`
113
+ Available tools: `cursor`, `copilot`, `claude-code-subscription`, `claude-code-gateway`, `gemini-cli`, `codex-subscription`, `codex-gateway`
112
114
 
113
115
  `claude-code-subscription` and `claude-code-gateway` are mutually exclusive. `codex-subscription` and `codex-gateway` are mutually exclusive. When using `--all`, subscription mode is used by default for Claude Code and Codex.
114
116
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.9.2",
3
+ "version": "0.9.6",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -9,6 +9,18 @@ const output = require('../output');
9
9
  const DISCOVER_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/coding-discovery-tool/refs/heads/main';
10
10
  const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
11
11
 
12
+ // install.sh exits with this code when the OS isn't supported for discovery
13
+ // (e.g. Linux). Treated as a skipped scan, not a failure.
14
+ const DISCOVERY_EXIT_UNSUPPORTED_OS = 3;
15
+
16
+ // Classifies a discovery subprocess exit code:
17
+ // 'success' (scan ran), 'unsupported' (skipped on this OS), or 'failure'.
18
+ function classifyDiscoveryExit(code) {
19
+ if (code === 0) return 'success';
20
+ if (code === DISCOVERY_EXIT_UNSUPPORTED_OS) return 'unsupported';
21
+ return 'failure';
22
+ }
23
+
12
24
  // Native Windows (cmd/PowerShell) takes the install.ps1 path below. WSL reports
13
25
  // as Linux via process.platform and keeps using the existing bash install.sh pipe.
14
26
  function isWindowsNative() {
@@ -71,11 +83,15 @@ function runDiscoveryScript(scriptName, args) {
71
83
  const child = spawn(cmd, { shell: true, stdio: 'inherit' });
72
84
 
73
85
  child.on('close', (code) => {
74
- if (code === 0) {
75
- resolve();
76
- } else {
86
+ const result = classifyDiscoveryExit(code);
87
+ if (result === 'failure') {
77
88
  reject(new Error(`Discovery script failed with exit code ${code}`));
89
+ return;
90
+ }
91
+ if (result === 'unsupported') {
92
+ output.warn('AI tool discovery is not supported on this operating system. Skipping the scan — the Unbound CLI works normally.');
78
93
  }
94
+ resolve();
79
95
  });
80
96
 
81
97
  child.on('error', reject);
@@ -129,8 +145,15 @@ async function runDiscoveryScriptWindows(scriptName, args) {
129
145
  { stdio: 'inherit', shell: false, windowsHide: true }
130
146
  );
131
147
  child.on('close', (code) => {
132
- if (code === 0) resolve();
133
- else reject(new Error(`Discovery script failed with exit code ${code}`));
148
+ const result = classifyDiscoveryExit(code);
149
+ if (result === 'failure') {
150
+ reject(new Error(`Discovery script failed with exit code ${code}`));
151
+ return;
152
+ }
153
+ if (result === 'unsupported') {
154
+ output.warn('AI tool discovery is not supported on this operating system. Skipping the scan — the Unbound CLI works normally.');
155
+ }
156
+ resolve();
134
157
  });
135
158
  child.on('error', reject);
136
159
  });
@@ -326,4 +349,4 @@ Examples:
326
349
  });
327
350
  }
328
351
 
329
- module.exports = { register, runDiscoveryScan };
352
+ module.exports = { register, runDiscoveryScan, classifyDiscoveryExit };
@@ -18,6 +18,7 @@ function isWindowsNative() {
18
18
 
19
19
  const SETUP_TOOLS = [
20
20
  { label: 'Cursor', value: 'cursor', script: 'cursor/setup.py' },
21
+ { label: 'GitHub Copilot', value: 'copilot', script: 'copilot/hooks/setup.py' },
21
22
  { label: 'Claude Code \u2014 subscription (hooks)', value: 'claude-sub', script: 'claude-code/hooks/setup.py', group: 'claude-code' },
22
23
  { label: 'Claude Code \u2014 gateway (gateway)', value: 'claude-gw', script: 'claude-code/gateway/setup.py', group: 'claude-code' },
23
24
  { label: 'Gemini CLI', value: 'gemini', script: 'gemini-cli/gateway/setup.py' },
@@ -27,6 +28,7 @@ const SETUP_TOOLS = [
27
28
 
28
29
  const MDM_TOOLS = {
29
30
  'cursor': { label: 'Cursor', script: 'cursor/mdm/setup.py' },
31
+ 'copilot': { label: 'GitHub Copilot', script: 'copilot/hooks/mdm/setup.py' },
30
32
  'claude-code-subscription': { label: 'Claude Code (subscription)', script: 'claude-code/hooks/mdm/setup.py' },
31
33
  'claude-code-gateway': { label: 'Claude Code (gateway)', script: 'claude-code/gateway/mdm/setup.py' },
32
34
  'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/mdm/setup.py' },
@@ -34,16 +36,22 @@ const MDM_TOOLS = {
34
36
  'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/mdm/setup.py' },
35
37
  };
36
38
 
37
- // Default tools for --all (uses subscription mode for Claude Code and Codex since only one can be active)
38
- const MDM_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription'];
39
+ // Default MDM tools for `unbound onboard-mdm` (subscription mode for Claude Code/Codex since only one can be active)
40
+ const MDM_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription', 'copilot'];
39
41
 
40
- // Default tools for user-level `unbound setup --all`.
41
- // Includes Cursor, Claude Code hooks, and Codex hooks (no Gemini CLI).
42
- const ALL_TOOLS = ['cursor', 'claude-code-subscription', 'codex-subscription'];
42
+ // Tools for `unbound setup mdm --all` — identical to MDM_ALL_TOOLS today; split kept for future flexibility.
43
+ const MDM_SETUP_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription', 'copilot'];
44
+
45
+ // Default tools for `unbound onboard` (Cursor, Claude Code hooks, Codex hooks, Copilot hooks; no Gemini CLI).
46
+ const ALL_TOOLS = ['cursor', 'claude-code-subscription', 'codex-subscription', 'copilot'];
47
+
48
+ // Tools for `unbound setup --all` — identical to ALL_TOOLS today; split kept for future flexibility.
49
+ const SETUP_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'codex-subscription', 'copilot'];
43
50
 
44
51
  // Tool name → script mapping for automated tools
45
52
  const SETUP_TOOL_MAP = {
46
53
  'cursor': { label: 'Cursor', script: 'cursor/setup.py' },
54
+ 'copilot': { label: 'GitHub Copilot', script: 'copilot/hooks/setup.py' },
47
55
  'claude-code-subscription': { label: 'Claude Code (subscription)', script: 'claude-code/hooks/setup.py' },
48
56
  'claude-code-gateway': { label: 'Claude Code (gateway)', script: 'claude-code/gateway/setup.py' },
49
57
  'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/setup.py' },
@@ -213,13 +221,15 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
213
221
  * have no browser-auth flow.
214
222
  */
215
223
  function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill } = {}) {
216
- let args = `--api-key ${shellEscape(apiKey)}`;
224
+ // --clear runs no auth in the Python scripts, so the key may be absent. Omit
225
+ // the flag entirely rather than passing --api-key 'undefined'.
226
+ let args = apiKey ? `--api-key ${shellEscape(apiKey)}` : '';
217
227
  if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
218
228
  if (gatewayUrl) args += ` --gateway-url ${shellEscape(gatewayUrl)}`;
219
229
  if (!mdm && frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
220
230
  if (clear) args += ' --clear';
221
231
  if (backfill) args += ' --backfill';
222
- return args;
232
+ return args.trim();
223
233
  }
224
234
 
225
235
  // Backfill only applies to the hooks variants of Claude Code / Codex; gateway
@@ -233,14 +243,16 @@ function scriptSupportsBackfill(scriptPath) {
233
243
  // Surfaces an early note when --backfill was requested for a tool that can't
234
244
  // use it. The setup scripts no-op safely too, but earlier user signal is better UX.
235
245
  function noteBackfillUnsupported(label, scriptPath) {
236
- if (scriptPath.startsWith('cursor/')) {
237
- output.info(`${label} backfill is not supported (no historical transcript data on disk). Continuing without backfill for ${label}.`);
238
- return;
239
- }
246
+ if (scriptSupportsBackfill(scriptPath)) return;
240
247
  if (scriptPath.includes('/gateway/')) {
241
248
  output.info(`--backfill is not supported in gateway mode for ${label}. Continuing without backfill for ${label}.`);
242
249
  return;
243
250
  }
251
+ if (scriptPath.startsWith('cursor/')) {
252
+ output.info(`${label} backfill is not supported (no historical transcript data on disk). Continuing without backfill for ${label}.`);
253
+ return;
254
+ }
255
+ output.info(`${label} does not support --backfill. Continuing without backfill for ${label}.`);
244
256
  }
245
257
 
246
258
  /**
@@ -336,17 +348,18 @@ function register(program) {
336
348
  'Run with no arguments for interactive setup, or specify tools directly.'
337
349
  )
338
350
  .option('--api-key <key>', 'Authenticate with an API key (skips browser login)')
339
- .option('--clear', 'Remove Unbound configuration for the specified tools')
351
+ .option('--clear', 'Remove Unbound configuration for the specified tools (no login or API key required)')
340
352
  .option('--subscription', 'Use subscription mode for Claude Code / Codex (hooks only)')
341
353
  .option('--gateway', 'Use gateway mode for Claude Code / Codex (Unbound as AI provider)')
342
- .option('--all', 'Set up the default bundle: Cursor, Claude Code (hooks), Codex (hooks)')
343
- .option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
354
+ .option('--all', 'Set up the default bundle: Cursor, Copilot, Claude Code (hooks), Codex (hooks)')
355
+ .option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor and Copilot unsupported)')
344
356
  .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
345
357
  .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
346
358
  .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
347
359
  .addHelpText('after', `
348
360
  Available tools:
349
361
  cursor Cursor IDE
362
+ copilot GitHub Copilot
350
363
  claude-code Claude Code (use --subscription or --gateway)
351
364
  gemini-cli Gemini CLI
352
365
  codex Codex (use --subscription or --gateway)
@@ -364,11 +377,12 @@ For multi-tool setup, use explicit mode names:
364
377
  Examples:
365
378
  Single tool:
366
379
  $ unbound setup cursor Set up Cursor
380
+ $ unbound setup copilot Set up GitHub Copilot
367
381
  $ unbound setup claude-code --gateway Claude Code gateway mode
368
382
  $ unbound setup claude-code --subscription Claude Code hooks only
369
383
  $ unbound setup codex --gateway Codex gateway mode
370
384
 
371
- Install the default bundle (Cursor + Claude Code hooks + Codex hooks):
385
+ Install the default bundle (Cursor + Copilot + Claude Code hooks + Codex hooks):
372
386
  $ unbound setup --all Set up the default bundle
373
387
  $ unbound setup --all --api-key <key> Login + set up the bundle
374
388
 
@@ -381,16 +395,19 @@ Examples:
381
395
  $ unbound setup cursor claude-code-gateway --api-key <key>
382
396
  Login + set up multiple tools
383
397
 
384
- Remove configuration:
398
+ Remove configuration (no login or API key required):
385
399
  $ unbound setup cursor --clear Remove Cursor config
386
- $ unbound setup claude-code --clear Remove Claude Code config
400
+ $ unbound setup copilot --clear Remove GitHub Copilot config
401
+ $ unbound setup claude-code --clear Remove BOTH Claude Code modes (subscription + gateway)
402
+ $ unbound setup codex --clear Remove BOTH Codex modes (subscription + gateway)
387
403
 
388
404
  Interactive:
389
405
  $ unbound setup Select tools interactively
390
406
  $ unbound setup --api-key <key> Login, then select interactively
391
407
 
392
- If not logged in and --api-key is not provided, the browser will open
393
- automatically to authenticate before proceeding.
408
+ When setting up, if you are not logged in and --api-key is not provided, the
409
+ browser opens automatically to authenticate first. Clearing (--clear) never
410
+ requires authentication.
394
411
  `)
395
412
  .action(async (tools, opts) => {
396
413
  try {
@@ -408,11 +425,15 @@ automatically to authenticate before proceeding.
408
425
  const frontendUrl = written.frontend_url || config.getFrontendUrl();
409
426
  const gatewayUrl = written.gateway_url || config.getGatewayUrl();
410
427
 
411
- await ensureLoggedIn({
412
- apiKey: opts.apiKey,
413
- baseUrl: written.base_url,
414
- frontendUrl: written.frontend_url,
415
- });
428
+ // Clearing config needs no credentials — the setup scripts remove
429
+ // files without calling the API — so don't force a login for --clear.
430
+ if (!opts.clear) {
431
+ await ensureLoggedIn({
432
+ apiKey: opts.apiKey,
433
+ baseUrl: written.base_url,
434
+ frontendUrl: written.frontend_url,
435
+ });
436
+ }
416
437
  const apiKey = config.getApiKey();
417
438
  const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
418
439
 
@@ -423,7 +444,7 @@ automatically to authenticate before proceeding.
423
444
  process.exitCode = 1;
424
445
  return;
425
446
  }
426
- tools = [...ALL_TOOLS];
447
+ tools = [...SETUP_ALL_TOOLS];
427
448
  }
428
449
 
429
450
  // No tools specified → interactive multi-select (existing flow)
@@ -480,30 +501,33 @@ automatically to authenticate before proceeding.
480
501
  return;
481
502
  }
482
503
 
483
- // Validate mutual exclusivity
484
- if (tools.includes('claude-code-subscription') && tools.includes('claude-code-gateway')) {
485
- output.error('Cannot set up both claude-code-subscription and claude-code-gateway. Choose one.');
486
- process.exitCode = 1;
487
- return;
488
- }
489
- if (tools.includes('codex-subscription') && tools.includes('codex-gateway')) {
490
- output.error('Cannot set up both codex-subscription and codex-gateway. Choose one.');
491
- process.exitCode = 1;
492
- return;
493
- }
504
+ // Mode mutual-exclusivity only applies when setting up — clearing both
505
+ // modes at once is valid (and is what bare claude-code/codex --clear does).
506
+ if (!opts.clear) {
507
+ if (tools.includes('claude-code-subscription') && tools.includes('claude-code-gateway')) {
508
+ output.error('Cannot set up both claude-code-subscription and claude-code-gateway. Choose one.');
509
+ process.exitCode = 1;
510
+ return;
511
+ }
512
+ if (tools.includes('codex-subscription') && tools.includes('codex-gateway')) {
513
+ output.error('Cannot set up both codex-subscription and codex-gateway. Choose one.');
514
+ process.exitCode = 1;
515
+ return;
516
+ }
494
517
 
495
- // Validate no bare + suffixed name conflicts
496
- if (tools.includes('claude-code') && (tools.includes('claude-code-subscription') || tools.includes('claude-code-gateway'))) {
497
- output.error('Cannot combine claude-code with claude-code-subscription or claude-code-gateway.');
498
- console.error(' Use --subscription or --gateway with claude-code, or use the explicit name directly.');
499
- process.exitCode = 1;
500
- return;
501
- }
502
- if (tools.includes('codex') && (tools.includes('codex-subscription') || tools.includes('codex-gateway'))) {
503
- output.error('Cannot combine codex with codex-subscription or codex-gateway.');
504
- console.error(' Use --subscription or --gateway with codex, or use the explicit name directly.');
505
- process.exitCode = 1;
506
- return;
518
+ // Validate no bare + suffixed name conflicts
519
+ if (tools.includes('claude-code') && (tools.includes('claude-code-subscription') || tools.includes('claude-code-gateway'))) {
520
+ output.error('Cannot combine claude-code with claude-code-subscription or claude-code-gateway.');
521
+ console.error(' Use --subscription or --gateway with claude-code, or use the explicit name directly.');
522
+ process.exitCode = 1;
523
+ return;
524
+ }
525
+ if (tools.includes('codex') && (tools.includes('codex-subscription') || tools.includes('codex-gateway'))) {
526
+ output.error('Cannot combine codex with codex-subscription or codex-gateway.');
527
+ console.error(' Use --subscription or --gateway with codex, or use the explicit name directly.');
528
+ process.exitCode = 1;
529
+ return;
530
+ }
507
531
  }
508
532
 
509
533
  // Validate --subscription/--gateway only with tools that need them
@@ -610,7 +634,9 @@ automatically to authenticate before proceeding.
610
634
 
611
635
  // --- MDM setup ---
612
636
 
613
- const mdmToolNames = Object.keys(MDM_TOOLS).join(', ');
637
+ // Bare claude-code/codex are accepted too: with --clear they remove both
638
+ // modes; for setup they default to subscription (MDM has no mode prompt).
639
+ const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
614
640
 
615
641
  setup
616
642
  .command('mdm')
@@ -619,30 +645,38 @@ automatically to authenticate before proceeding.
619
645
  'Used by organization admins to enroll devices via MDM.'
620
646
  )
621
647
  .argument('[tools...]', 'Tools to set up: ' + mdmToolNames)
622
- .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
623
- .option('--clear', 'Remove Unbound configuration for the specified tools')
648
+ .option('--admin-api-key <key>', 'Admin API key for MDM enrollment (not required with --clear)')
649
+ .option('--clear', 'Remove Unbound configuration for the specified tools (no API key required)')
624
650
  .option('--all', 'Set up all available tools')
625
- .option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
651
+ .option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor and Copilot unsupported)')
626
652
  .addHelpText('after', `
627
653
  Available tools:
628
654
  cursor Cursor IDE
655
+ copilot GitHub Copilot
629
656
  claude-code-subscription Claude Code with your own subscription (hooks only)
630
657
  claude-code-gateway Claude Code with Unbound as AI provider
658
+ claude-code Both Claude Code modes (clears both; sets up subscription)
631
659
  gemini-cli Gemini CLI
632
660
  codex-subscription Codex with your own subscription (hooks only)
633
661
  codex-gateway Codex with Unbound as AI provider
662
+ codex Both Codex modes (clears both; sets up subscription)
634
663
 
635
- Note: claude-code-subscription and claude-code-gateway are mutually exclusive.
636
- codex-subscription and codex-gateway are mutually exclusive.
637
- When using --all, subscription mode is used by default for Claude Code and Codex.
664
+ Note: claude-code-subscription and claude-code-gateway are mutually exclusive when
665
+ setting up; same for codex. Bare claude-code/codex set up subscription mode.
666
+ When using --all, subscription mode is used by default for Claude Code and Codex.
638
667
 
639
- Examples:
668
+ Setup examples (require --admin-api-key):
640
669
  $ sudo unbound setup mdm --admin-api-key KEY cursor
641
670
  $ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex-subscription
642
671
  $ sudo unbound setup mdm --admin-api-key KEY --all
643
- $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
644
672
  $ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription --backfill
645
673
  Install hooks AND backfill local history
674
+
675
+ Clear examples (no API key required):
676
+ $ sudo unbound setup mdm --clear cursor
677
+ $ sudo unbound setup mdm --clear claude-code Clears BOTH Claude Code modes
678
+ $ sudo unbound setup mdm --clear codex Clears BOTH Codex modes
679
+ $ sudo unbound setup mdm --clear --all Clears every tool
646
680
  `)
647
681
  .action(async (tools, opts, command) => {
648
682
  try {
@@ -651,6 +685,13 @@ Examples:
651
685
  // --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
652
686
  // Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
653
687
  const globalOpts = command.optsWithGlobals();
688
+ // Clearing removes config without calling the API, so a key is only
689
+ // required when actually enrolling tools.
690
+ if (!globalOpts.clear && !opts.adminApiKey) {
691
+ output.error('--admin-api-key is required to set up tools.');
692
+ process.exitCode = 1;
693
+ return;
694
+ }
654
695
  // Persist URLs first so this MDM run wires tools at the new tenant
655
696
  // and any subsequent non-MDM command on the same machine inherits.
656
697
  // Prefer just-persisted values over env-var-aware getters so a stale
@@ -670,7 +711,9 @@ Examples:
670
711
 
671
712
  let toolNames;
672
713
  if (globalOpts.all) {
673
- toolNames = MDM_ALL_TOOLS;
714
+ // --clear --all wipes every tool, including both Claude Code/Codex modes.
715
+ // Setup --all uses the subscription-default bundle (can't enroll both modes).
716
+ toolNames = globalOpts.clear ? Object.keys(MDM_TOOLS) : MDM_SETUP_ALL_TOOLS;
674
717
  } else if (tools.length > 0) {
675
718
  toolNames = tools;
676
719
  } else {
@@ -680,6 +723,14 @@ Examples:
680
723
  return;
681
724
  }
682
725
 
726
+ // Expand bare claude-code/codex (MDM has no interactive mode prompt):
727
+ // --clear removes both modes; setup defaults to subscription, matching --all.
728
+ toolNames = [...new Set(toolNames.flatMap(name => {
729
+ const mode = MODE_TOOLS[name];
730
+ if (!mode) return [name];
731
+ return globalOpts.clear ? [mode.subscription, mode.gateway] : [mode.subscription];
732
+ }))];
733
+
683
734
  const invalid = toolNames.filter(t => !MDM_TOOLS[t]);
684
735
  if (invalid.length > 0) {
685
736
  output.error(`Unknown tool(s): ${invalid.join(', ')}`);
@@ -688,16 +739,20 @@ Examples:
688
739
  return;
689
740
  }
690
741
 
691
- if (toolNames.includes('claude-code-subscription') && toolNames.includes('claude-code-gateway')) {
692
- output.error('Cannot use both claude-code-subscription and claude-code-gateway. Choose one.');
693
- process.exitCode = 1;
694
- return;
695
- }
742
+ // Mode mutual-exclusivity only applies when setting up — clearing both
743
+ // modes at once is valid (and is what bare claude-code/codex --clear does).
744
+ if (!globalOpts.clear) {
745
+ if (toolNames.includes('claude-code-subscription') && toolNames.includes('claude-code-gateway')) {
746
+ output.error('Cannot use both claude-code-subscription and claude-code-gateway. Choose one.');
747
+ process.exitCode = 1;
748
+ return;
749
+ }
696
750
 
697
- if (toolNames.includes('codex-subscription') && toolNames.includes('codex-gateway')) {
698
- output.error('Cannot use both codex-subscription and codex-gateway. Choose one.');
699
- process.exitCode = 1;
700
- return;
751
+ if (toolNames.includes('codex-subscription') && toolNames.includes('codex-gateway')) {
752
+ output.error('Cannot use both codex-subscription and codex-gateway. Choose one.');
753
+ process.exitCode = 1;
754
+ return;
755
+ }
701
756
  }
702
757
 
703
758
  const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
@@ -735,7 +790,7 @@ Examples:
735
790
  }
736
791
 
737
792
  /**
738
- * Runs the user-level default bundle (Cursor, Claude Code hooks, Codex hooks) with spinners.
793
+ * Runs the user-level default bundle (Cursor, Claude Code hooks, Codex hooks, Copilot hooks) with spinners.
739
794
  * Assumes the caller has already ensured the user is logged in.
740
795
  * Returns true on success, false on failure.
741
796
  */
@@ -760,7 +815,7 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
760
815
  }
761
816
 
762
817
  /**
763
- * Runs the MDM default bundle (Cursor, Claude Code hooks, Gemini CLI, Codex hooks) with spinners.
818
+ * Runs the MDM default bundle (Cursor, Claude Code hooks, Gemini CLI, Codex hooks, Copilot hooks) with spinners.
764
819
  * Caller must ensure the process is running as root.
765
820
  * Returns true on success, false on failure.
766
821
  */
@@ -787,4 +842,5 @@ module.exports = {
787
842
  checkRoot,
788
843
  ALL_TOOLS,
789
844
  MDM_ALL_TOOLS,
845
+ buildScriptArgs,
790
846
  };
package/src/index.js CHANGED
@@ -50,8 +50,9 @@ ONBOARDING (one-step install + discover)
50
50
 
51
51
  TOOL SETUP
52
52
  $ unbound setup Select and install multiple tools interactively
53
- $ unbound setup --all Set up the default bundle (Cursor + Claude Code hooks + Codex hooks)
53
+ $ unbound setup --all Set up the default bundle (Cursor + Copilot + Claude Code hooks + Codex hooks)
54
54
  $ unbound setup cursor Set up Cursor
55
+ $ unbound setup copilot Set up GitHub Copilot
55
56
  $ unbound setup claude-code Set up Claude Code (interactive mode selection)
56
57
  $ unbound setup claude-code --gateway Use Unbound as AI provider
57
58
  $ unbound setup claude-code --subscription Hooks only (keep your subscription)
@@ -74,13 +75,14 @@ TOOL SETUP
74
75
 
75
76
  Remove configuration:
76
77
  $ unbound setup cursor --clear Remove Unbound config for Cursor
78
+ $ unbound setup copilot --clear Remove Unbound config for GitHub Copilot
77
79
  $ unbound setup claude-code --clear Remove Unbound config for Claude Code
78
80
  $ unbound setup gemini-cli --clear Remove Unbound config for Gemini CLI
79
81
  $ unbound setup codex --clear Remove Unbound config for Codex
80
82
 
81
83
  MDM SETUP (admin, requires root)
82
84
  $ sudo unbound setup mdm --admin-api-key KEY --all
83
- $ sudo unbound setup mdm --admin-api-key KEY cursor codex-subscription
85
+ $ sudo unbound setup mdm --admin-api-key KEY cursor copilot codex-subscription
84
86
  $ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription codex-subscription gemini-cli
85
87
  $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
86
88
 
@@ -0,0 +1,21 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { classifyDiscoveryExit } = require('../src/commands/discover');
4
+
5
+ test('classifyDiscoveryExit: 0 is a successful scan', () => {
6
+ assert.equal(classifyDiscoveryExit(0), 'success');
7
+ });
8
+
9
+ test('classifyDiscoveryExit: 3 is an unsupported-OS skip, not a failure', () => {
10
+ assert.equal(classifyDiscoveryExit(3), 'unsupported');
11
+ });
12
+
13
+ test('classifyDiscoveryExit: other non-zero codes are failures', () => {
14
+ assert.equal(classifyDiscoveryExit(1), 'failure');
15
+ assert.equal(classifyDiscoveryExit(2), 'failure');
16
+ assert.equal(classifyDiscoveryExit(127), 'failure');
17
+ });
18
+
19
+ test('classifyDiscoveryExit: null (process killed by signal) is a failure', () => {
20
+ assert.equal(classifyDiscoveryExit(null), 'failure');
21
+ });
@@ -0,0 +1,85 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { buildScriptArgs } = require('../src/commands/setup');
4
+
5
+ // shellEscape single-quotes every value, so a real key surfaces as
6
+ // --api-key '<key>' at the head of the argv tail.
7
+ test('buildScriptArgs: real apiKey leads with single-quoted --api-key', () => {
8
+ const args = buildScriptArgs('sk-abc123', {});
9
+ assert.ok(args.startsWith("--api-key 'sk-abc123'"), args);
10
+ });
11
+
12
+ // WEB-4498 core behavior: --clear runs no auth, so a missing key must NOT
13
+ // produce --api-key and must NOT leak the literal string "undefined".
14
+ test('buildScriptArgs: clear without apiKey omits --api-key and never emits undefined', () => {
15
+ const args = buildScriptArgs(undefined, { clear: true });
16
+ assert.ok(!args.includes('--api-key'), args);
17
+ assert.ok(!args.includes('undefined'), args);
18
+ assert.ok(args.includes('--clear'), args);
19
+ });
20
+
21
+ // Empty-string key is falsy too — same omission rule applies.
22
+ test('buildScriptArgs: clear with empty-string apiKey still omits --api-key', () => {
23
+ const args = buildScriptArgs('', { clear: true });
24
+ assert.ok(!args.includes('--api-key'), args);
25
+ assert.ok(!args.includes('undefined'), args);
26
+ assert.ok(args.includes('--clear'), args);
27
+ });
28
+
29
+ // With no key, the first appended flag must not leave a leading space —
30
+ // the result is trimmed and carries the URL flags plus --clear.
31
+ test('buildScriptArgs: clear without key + URLs is trimmed and carries url flags', () => {
32
+ const args = buildScriptArgs(undefined, {
33
+ clear: true,
34
+ backendUrl: 'https://backend.acme.com',
35
+ gatewayUrl: 'https://gateway.acme.com',
36
+ });
37
+ assert.equal(args, args.trim());
38
+ assert.ok(!args.startsWith(' '), args);
39
+ assert.ok(args.includes('--backend-url'), args);
40
+ assert.ok(args.includes('--gateway-url'), args);
41
+ assert.ok(args.includes('--clear'), args);
42
+ });
43
+
44
+ test('buildScriptArgs: clear:true appends --clear', () => {
45
+ const args = buildScriptArgs('sk-x', { clear: true });
46
+ assert.ok(args.includes('--clear'), args);
47
+ });
48
+
49
+ test('buildScriptArgs: backfill:true appends --backfill', () => {
50
+ const args = buildScriptArgs('sk-x', { backfill: true });
51
+ assert.ok(args.includes('--backfill'), args);
52
+ });
53
+
54
+ // MDM scripts have no browser-auth flow, so --domain (frontend URL) is
55
+ // suppressed even when a frontendUrl is supplied.
56
+ test('buildScriptArgs: mdm:true suppresses --domain even with frontendUrl', () => {
57
+ const args = buildScriptArgs('sk-x', {
58
+ mdm: true,
59
+ frontendUrl: 'https://gateway.acme.com',
60
+ });
61
+ assert.ok(!args.includes('--domain'), args);
62
+ });
63
+
64
+ test('buildScriptArgs: non-mdm includes --domain when frontendUrl passed', () => {
65
+ const args = buildScriptArgs('sk-x', {
66
+ mdm: false,
67
+ frontendUrl: 'https://gateway.acme.com',
68
+ });
69
+ assert.ok(args.includes("--domain 'https://gateway.acme.com'"), args);
70
+ });
71
+
72
+ // The result is always trimmed regardless of which flags are present.
73
+ test('buildScriptArgs: result is always trimmed', () => {
74
+ const cases = [
75
+ ['sk-x', {}],
76
+ [undefined, { clear: true }],
77
+ ['', { clear: true, backendUrl: 'https://b.acme.com' }],
78
+ ['sk-x', { backfill: true, mdm: true, frontendUrl: 'https://f.acme.com' }],
79
+ [undefined, {}],
80
+ ];
81
+ for (const [key, opts] of cases) {
82
+ const args = buildScriptArgs(key, opts);
83
+ assert.equal(args, args.trim(), JSON.stringify([key, opts]));
84
+ }
85
+ });