unbound-cli 0.1.11 → 0.2.1

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
@@ -14,7 +14,7 @@ npm install -g unbound-cli
14
14
  # Login via browser
15
15
  unbound login
16
16
 
17
- # Or login with an API key
17
+ # Or login with an API key (for CI/CD or headless environments)
18
18
  unbound login --api-key <your-api-key>
19
19
 
20
20
  # Check who you are
@@ -23,7 +23,10 @@ unbound whoami
23
23
  # List policies
24
24
  unbound policy list
25
25
 
26
- # Set up Cursor to use Unbound
26
+ # Set up multiple tools interactively
27
+ unbound setup
28
+
29
+ # Set up a single tool
27
30
  unbound setup cursor
28
31
  ```
29
32
 
@@ -33,11 +36,79 @@ unbound setup cursor
33
36
 
34
37
  | Command | Description |
35
38
  |---------|-------------|
36
- | `unbound login` | Sign in via browser or `--api-key` |
39
+ | `unbound login` | Sign in via browser |
40
+ | `unbound login --api-key <key>` | Sign in with an API key (non-interactive) |
41
+ | `unbound login --domain <domain>` | Sign in via a custom domain |
37
42
  | `unbound logout` | Remove stored credentials |
38
43
  | `unbound whoami` | Show current user, org, and role |
39
44
  | `unbound status` | Show CLI status and API connectivity |
40
45
 
46
+ ### Tool Setup
47
+
48
+ Interactive batch setup:
49
+
50
+ | Command | Description |
51
+ |---------|-------------|
52
+ | `unbound setup` | Select and install multiple tools interactively |
53
+
54
+ Automated setup (downloads scripts, sets env vars, configures tool):
55
+
56
+ | Command | Description |
57
+ |---------|-------------|
58
+ | `unbound setup cursor` | Download hooks, set env, restart Cursor |
59
+ | `unbound setup claude-code` | Interactive mode selection (subscription or gateway) |
60
+ | `unbound setup claude-code --subscription` | Hooks only (keep your Claude subscription) |
61
+ | `unbound setup claude-code --gateway` | Use Unbound as the AI provider |
62
+ | `unbound setup gemini-cli` | Set GEMINI_API_KEY and base URL |
63
+ | `unbound setup codex` | Interactive mode selection (subscription or gateway) |
64
+ | `unbound setup codex --subscription` | Hooks only (keep your OpenAI subscription) |
65
+ | `unbound setup codex --gateway` | Use Unbound as the AI provider |
66
+
67
+ Instruction-only (shows API key and base URL to configure manually):
68
+
69
+ | Command | Description |
70
+ |---------|-------------|
71
+ | `unbound setup roo-code` | Show Roo Code config values |
72
+ | `unbound setup cline` | Show Cline config values |
73
+ | `unbound setup kilo-code` | Show Kilo Code config values |
74
+ | `unbound setup custom-access` | Show API key and base URL for direct API access |
75
+
76
+ Remove configuration:
77
+
78
+ | Command | Description |
79
+ |---------|-------------|
80
+ | `unbound setup cursor --clear` | Remove Unbound config for Cursor |
81
+ | `unbound setup claude-code --clear` | Remove Unbound config for Claude Code |
82
+ | `unbound setup gemini-cli --clear` | Remove Unbound config for Gemini CLI |
83
+ | `unbound setup codex --clear` | Remove Unbound config for Codex |
84
+
85
+ ### MDM Setup (Admin)
86
+
87
+ Configure all users on a device via MDM. Requires root.
88
+
89
+ | Command | Description |
90
+ |---------|-------------|
91
+ | `sudo unbound setup mdm --admin-api-key KEY --all` | Set up all tools |
92
+ | `sudo unbound setup mdm --admin-api-key KEY cursor codex-subscription` | Set up specific tools |
93
+ | `sudo unbound setup mdm --admin-api-key KEY --clear cursor` | Remove config for specific tools |
94
+
95
+ Available tools: `cursor`, `claude-code-subscription`, `claude-code-gateway`, `gemini-cli`, `codex-subscription`, `codex-gateway`
96
+
97
+ `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.
98
+
99
+ ### MDM AI Tools Discovery
100
+
101
+ Scan a device for installed AI coding tools and report findings to Unbound. Uses a separate discovery-specific API key. `--domain` defaults to `https://backend.getunbound.ai`.
102
+
103
+ | Command | Description |
104
+ |---------|-------------|
105
+ | `sudo unbound discover --api-key KEY` | Scan all users on the device (requires root) |
106
+ | `unbound discover --api-key KEY` | Scan current user only |
107
+ | `sudo unbound discover --api-key KEY --domain URL` | Scan with a custom backend URL |
108
+ | `unbound discover schedule --api-key KEY` | Set up 12-hour recurring scan (macOS only) |
109
+ | `unbound discover unschedule` | Remove the scheduled scan |
110
+ | `unbound discover status` | Show scan schedule and log paths |
111
+
41
112
  ### Policies (Admin only)
42
113
 
43
114
  | Command | Description |
@@ -80,28 +151,7 @@ Alias: `unbound groups` works the same as `unbound user-groups`.
80
151
  | `unbound tools connect <type>` | Connect a tool |
81
152
  | `unbound tools approved` | List approved tool types |
82
153
 
83
- Supported tool types: `CLAUDE_CODE`, `CURSOR`, `COPILOT`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
84
-
85
- ### Setup
86
-
87
- Automated setup (downloads scripts, sets env vars, configures tool):
88
-
89
- | Command | Description |
90
- |---------|-------------|
91
- | `unbound setup cursor` | Download hooks, set env, restart Cursor |
92
- | `unbound setup claude-code` | Configure gateway + install hooks |
93
- | `unbound setup gemini-cli` | Set GEMINI_API_KEY and base URL |
94
- | `unbound setup codex` | Set OPENAI_API_KEY and base URL |
95
-
96
- Instruction-only (shows API key and base URL to configure manually):
97
-
98
- | Command | Description |
99
- |---------|-------------|
100
- | `unbound setup roo-code` | Show Roo Code config values |
101
- | `unbound setup cline` | Show Cline config values |
102
- | `unbound setup kilo-code` | Show Kilo Code config values |
103
- | `unbound setup custom-access` | Show API key and base URL for direct API access |
104
-
154
+ Supported tool types: `CLAUDE_CODE`, `UNBOUND_CLAUDE_CODE`, `CURSOR`, `COPILOT`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `UNBOUND_CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
105
155
 
106
156
  ## Configuration
107
157
 
@@ -117,7 +167,6 @@ Config is stored in `~/.unbound/config.json`.
117
167
  2. `frontend_url` in `~/.unbound/config.json` (set via `unbound config set-frontend-url`)
118
168
  3. Default: `https://gateway.getunbound.ai`
119
169
 
120
-
121
170
  ## Global Options
122
171
 
123
172
  All list/get commands support `--json` for machine-readable JSON output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.1.11",
3
+ "version": "0.2.1",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,210 @@
1
+ const { spawn, execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const output = require('../output');
6
+
7
+ const DISCOVER_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/coding-discovery-tool/refs/heads/main';
8
+ const DEFAULT_DOMAIN = 'https://backend.getunbound.ai';
9
+ const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
10
+
11
+ function isRoot() {
12
+ if (process.platform === 'win32') return false;
13
+ return typeof process.getuid === 'function' && process.getuid() === 0;
14
+ }
15
+
16
+ /**
17
+ * Downloads a bash script from the discovery repo and executes it with arguments.
18
+ * Uses stdio: 'inherit' so the script's output is shown live.
19
+ */
20
+ function runDiscoveryScript(scriptName, args) {
21
+ return new Promise((resolve, reject) => {
22
+ const url = `${DISCOVER_BASE_URL}/${scriptName}`;
23
+ const cmd = `curl -fsSL "${url}" | bash -s -- ${args}`;
24
+
25
+ const child = spawn(cmd, { shell: true, stdio: 'inherit' });
26
+
27
+ child.on('close', (code) => {
28
+ if (code === 0) {
29
+ resolve();
30
+ } else {
31
+ reject(new Error(`Discovery script failed with exit code ${code}`));
32
+ }
33
+ });
34
+
35
+ child.on('error', reject);
36
+ });
37
+ }
38
+
39
+ function register(program) {
40
+ const discover = program
41
+ .command('discover')
42
+ .description(
43
+ 'AI Tools Discovery: scan this device for installed AI coding tools ' +
44
+ 'and report findings to Unbound.'
45
+ )
46
+ .option('--api-key <key>', 'Discovery API key (required)')
47
+ .option('--domain <url>', 'Backend URL', DEFAULT_DOMAIN)
48
+ .addHelpText('after', `
49
+ Scans this device for installed AI coding tools (Cursor, Claude Code,
50
+ Gemini CLI, Codex, Windsurf, Roo Code, Cline, GitHub Copilot, JetBrains,
51
+ and more) and reports findings to the Unbound backend.
52
+
53
+ The --api-key is a discovery-specific key (separate from login credentials).
54
+ The --domain defaults to https://backend.getunbound.ai.
55
+
56
+ Run as root (sudo) to scan all users on the device.
57
+ Run without root to scan the current user only.
58
+
59
+ Prerequisites:
60
+ - Python 3 and curl must be installed
61
+
62
+ Examples:
63
+ $ sudo unbound discover --api-key KEY Scan all users
64
+ $ unbound discover --api-key KEY Scan current user only
65
+ $ sudo unbound discover --api-key KEY --domain https://custom.backend.com
66
+ `)
67
+ .action(async (opts) => {
68
+ try {
69
+ if (!opts.apiKey) {
70
+ output.error('--api-key is required.');
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+
75
+ if (!isRoot()) {
76
+ output.warn('Running without root. Only scanning current user\'s tools.');
77
+ output.info('Run with sudo to scan all users on this device.');
78
+ console.log('');
79
+ }
80
+
81
+ const args = `--api-key "${opts.apiKey}" --domain "${opts.domain}"`;
82
+ await runDiscoveryScript('install.sh', args);
83
+
84
+ console.log('');
85
+ output.success('Discovery complete');
86
+ } catch (err) {
87
+ output.error(err.message);
88
+ process.exitCode = 1;
89
+ }
90
+ });
91
+
92
+ // --- Schedule / Unschedule / Status (macOS only) ---
93
+
94
+ discover
95
+ .command('schedule')
96
+ .description('Set up a recurring 12-hour discovery scan via macOS LaunchAgent.')
97
+ .option('--api-key <key>', 'Discovery API key (required)')
98
+ .option('--domain <url>', 'Backend URL', DEFAULT_DOMAIN)
99
+ .addHelpText('after', `
100
+ Creates a macOS LaunchAgent that runs the discovery scan every 12 hours.
101
+ Credentials are stored securely in the macOS Keychain (not in files).
102
+
103
+ The scan runs immediately on install, then every 12 hours.
104
+ Logs are written to ~/Library/Logs/unbound/scan.log.
105
+
106
+ Prerequisites:
107
+ - macOS only (uses launchd)
108
+ - Python 3 and curl must be installed
109
+
110
+ Examples:
111
+ $ unbound discover schedule --api-key KEY
112
+ $ unbound discover schedule --api-key KEY --domain https://custom.backend.com
113
+ `)
114
+ .action(async (opts) => {
115
+ try {
116
+ if (process.platform !== 'darwin') {
117
+ output.error('Scheduled scans are only supported on macOS.');
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+
122
+ if (!opts.apiKey) {
123
+ output.error('--api-key is required.');
124
+ process.exitCode = 1;
125
+ return;
126
+ }
127
+
128
+ const args = `--api-key "${opts.apiKey}" --domain "${opts.domain}"`;
129
+ await runDiscoveryScript('setup-scheduled-scan.sh', args);
130
+ } catch (err) {
131
+ output.error(err.message);
132
+ process.exitCode = 1;
133
+ }
134
+ });
135
+
136
+ discover
137
+ .command('unschedule')
138
+ .description('Remove the scheduled discovery scan and clean up (macOS only).')
139
+ .addHelpText('after', `
140
+ Removes the LaunchAgent, Keychain credentials, wrapper script,
141
+ and install directory created by "unbound discover schedule".
142
+
143
+ Examples:
144
+ $ unbound discover unschedule
145
+ `)
146
+ .action(async () => {
147
+ try {
148
+ if (process.platform !== 'darwin') {
149
+ output.error('Scheduled scans are only supported on macOS.');
150
+ process.exitCode = 1;
151
+ return;
152
+ }
153
+
154
+ await runDiscoveryScript('setup-scheduled-scan.sh', '--uninstall');
155
+ } catch (err) {
156
+ output.error(err.message);
157
+ process.exitCode = 1;
158
+ }
159
+ });
160
+
161
+ discover
162
+ .command('status')
163
+ .description('Show scheduled scan status and log paths (macOS only).')
164
+ .action(() => {
165
+ if (process.platform !== 'darwin') {
166
+ output.error('Scheduled scans are only supported on macOS.');
167
+ process.exitCode = 1;
168
+ return;
169
+ }
170
+
171
+ const home = os.homedir();
172
+ const plistPath = path.join(home, 'Library/LaunchAgents', `${LAUNCH_AGENT_LABEL}.plist`);
173
+ const logPath = path.join(home, 'Library/Logs/unbound/scan.log');
174
+ const errPath = path.join(home, 'Library/Logs/unbound/scan.err');
175
+
176
+ const plistExists = fs.existsSync(plistPath);
177
+
178
+ let isLoaded = false;
179
+ try {
180
+ execSync(`launchctl list "${LAUNCH_AGENT_LABEL}"`, { stdio: 'ignore' });
181
+ isLoaded = true;
182
+ } catch {
183
+ // not loaded
184
+ }
185
+
186
+ let lastScan = '-';
187
+ if (fs.existsSync(logPath)) {
188
+ try {
189
+ const log = fs.readFileSync(logPath, 'utf8');
190
+ const matches = log.match(/\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] === Starting/g);
191
+ if (matches && matches.length > 0) {
192
+ const ts = matches[matches.length - 1].match(/\[(.+?)\]/);
193
+ if (ts) lastScan = ts[1];
194
+ }
195
+ } catch {
196
+ // ignore read errors
197
+ }
198
+ }
199
+
200
+ output.keyValue([
201
+ ['Scheduled', plistExists && isLoaded ? 'Yes (every 12 hours)' : 'No'],
202
+ ['LaunchAgent', plistExists ? plistPath : 'Not installed'],
203
+ ['Last scan', lastScan],
204
+ ['Log file', logPath],
205
+ ['Error log', errPath],
206
+ ]);
207
+ });
208
+ }
209
+
210
+ module.exports = { register };
@@ -1,21 +1,114 @@
1
- const { execSync } = require('child_process');
2
- const readline = require('readline');
1
+ const { execSync, spawn } = require('child_process');
2
+ const { Option } = require('commander');
3
3
  const config = require('../config');
4
4
  const output = require('../output');
5
5
  const { ensureLoggedIn } = require('../auth');
6
6
 
7
7
  const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main';
8
8
 
9
+ const SETUP_TOOLS = [
10
+ { label: 'Cursor', value: 'cursor', script: 'cursor/setup.py' },
11
+ { label: 'Claude Code \u2014 subscription (hooks)', value: 'claude-sub', script: 'claude-code/hooks/setup.py', group: 'claude-code' },
12
+ { label: 'Claude Code \u2014 gateway (gateway)', value: 'claude-gw', script: 'claude-code/gateway/setup.py', group: 'claude-code' },
13
+ { label: 'Gemini CLI', value: 'gemini', script: 'gemini-cli/gateway/setup.py' },
14
+ { label: 'Codex \u2014 subscription (hooks)', value: 'codex-sub', script: 'codex/hooks/setup.py', group: 'codex' },
15
+ { label: 'Codex \u2014 gateway (gateway)', value: 'codex-gw', script: 'codex/gateway/setup.py', group: 'codex' },
16
+ ];
17
+
18
+ const MDM_TOOLS = {
19
+ 'cursor': { label: 'Cursor', script: 'cursor/mdm/setup.py' },
20
+ 'claude-code-subscription': { label: 'Claude Code (subscription)', script: 'claude-code/hooks/mdm/setup.py' },
21
+ 'claude-code-gateway': { label: 'Claude Code (gateway)', script: 'claude-code/gateway/mdm/setup.py' },
22
+ 'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/mdm/setup.py' },
23
+ 'codex-subscription': { label: 'Codex (subscription)', script: 'codex/hooks/mdm/setup.py' },
24
+ 'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/mdm/setup.py' },
25
+ };
26
+
27
+ // Default tools for --all (uses subscription mode for Claude Code and Codex since only one can be active)
28
+ const MDM_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription'];
29
+
9
30
  /**
10
- * Runs a Python setup script from the setup repo, passing the stored API key.
31
+ * Builds a shell command that curls a setup script and pipes it to python3.
11
32
  */
12
- function runSetupScript(scriptPath, apiKey, { clear = false } = {}) {
33
+ function buildSetupCommand(scriptPath, args) {
13
34
  const url = `${SETUP_BASE_URL}/${scriptPath}`;
14
- const extraArgs = clear ? ' --clear' : '';
15
- const cmd = `curl -fsSL "${url}" | python3 - --api-key "${apiKey}"${extraArgs}`;
35
+ return `curl -fsSL "${url}" | python3 - ${args}`;
36
+ }
16
37
 
38
+ /**
39
+ * Runs a Python setup script from the setup repo with inherited stdio (live output).
40
+ */
41
+ function runSetupScript(scriptPath, apiKey, { clear = false } = {}) {
42
+ const args = `--api-key "${apiKey}"${clear ? ' --clear' : ''}`;
17
43
  console.log('');
18
- execSync(cmd, { stdio: 'inherit' });
44
+ try {
45
+ execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
46
+ } catch (err) {
47
+ throw new Error(`Setup script failed with exit code ${err.status || 1}. Check the output above for details.`);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Runs a setup script with piped output (captured silently for spinner display).
53
+ * Returns a promise that resolves on success, rejects with captured output on failure.
54
+ */
55
+ function runScriptPiped(scriptPath, args) {
56
+ return new Promise((resolve, reject) => {
57
+ const child = spawn(buildSetupCommand(scriptPath, args), {
58
+ shell: true,
59
+ stdio: ['pipe', 'pipe', 'pipe'],
60
+ });
61
+ child.stdin.on('error', () => {});
62
+ child.stdin.end();
63
+
64
+ let captured = '';
65
+ child.stdout.on('data', (d) => { captured += d.toString(); });
66
+ child.stderr.on('data', (d) => { captured += d.toString(); });
67
+
68
+ child.on('close', (code) => {
69
+ if (code === 0) {
70
+ resolve();
71
+ } else {
72
+ const err = new Error(captured.trim() || `Setup failed with exit code ${code}`);
73
+ err.setupOutput = captured.trim();
74
+ reject(err);
75
+ }
76
+ });
77
+
78
+ child.on('error', reject);
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Checks that the process is running as root (macOS/Linux).
84
+ * Windows admin check is handled by the Python MDM scripts themselves.
85
+ */
86
+ function checkRoot() {
87
+ if (process.platform === 'win32') return;
88
+ if (typeof process.getuid !== 'function' || process.getuid() !== 0) {
89
+ throw new Error('MDM setup requires root. Run with: sudo unbound setup mdm ...');
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Runs a batch of tools sequentially with spinners.
95
+ * Stops on first failure. Returns true if all succeeded.
96
+ */
97
+ async function runBatch(tools, runFn, { clear = false } = {}) {
98
+ const action = clear ? 'Clearing' : 'Setting up';
99
+ for (const tool of tools) {
100
+ const s = output.spinner(`${action} ${tool.label}...`);
101
+ try {
102
+ await runFn(tool);
103
+ s.succeed(tool.label);
104
+ } catch (err) {
105
+ s.fail(`Failed: ${tool.label}`);
106
+ if (err.setupOutput) console.error('\n' + err.setupOutput);
107
+ process.exitCode = 1;
108
+ return false;
109
+ }
110
+ }
111
+ return true;
19
112
  }
20
113
 
21
114
  /**
@@ -45,11 +138,14 @@ function register(program) {
45
138
  'Supported tools: cursor, claude-code, gemini-cli, codex, roo-code, cline, kilo-code, custom-access.'
46
139
  )
47
140
  .addHelpText('after', `
48
- Automated setup (downloads scripts, sets env vars, configures tool):
141
+ Interactive batch setup:
142
+ $ unbound setup # Select and install multiple tools at once
143
+
144
+ Single-tool setup (downloads scripts, sets env vars, configures tool):
49
145
  $ unbound setup cursor # Download hooks, set env, restart Cursor
50
146
  $ unbound setup claude-code # Set up gateway + hooks for Claude Code
51
147
  $ unbound setup gemini-cli # Set GEMINI_API_KEY and base URL
52
- $ unbound setup codex # Set OPENAI_API_KEY and base URL
148
+ $ unbound setup codex # Set up gateway + hooks for Codex
53
149
 
54
150
  Instruction-only (shows API key and base URL to configure manually):
55
151
  $ unbound setup roo-code # Show Roo Code config values
@@ -59,7 +155,40 @@ Instruction-only (shows API key and base URL to configure manually):
59
155
 
60
156
  All setup commands require login. If not logged in, the browser will
61
157
  open automatically to authenticate before proceeding.
62
- `);
158
+ `)
159
+ // Parent action: runs when `unbound setup` is invoked with no subcommand.
160
+ // Subcommand actions (cursor, claude-code, etc.) take precedence when specified.
161
+ .action(async () => {
162
+ try {
163
+ await ensureLoggedIn();
164
+ const apiKey = config.getApiKey();
165
+
166
+ const selected = await output.multiSelect(
167
+ 'Select tools to set up with Unbound:',
168
+ SETUP_TOOLS
169
+ );
170
+
171
+ if (selected.length === 0) {
172
+ output.warn('No tools selected.');
173
+ return;
174
+ }
175
+
176
+ const selectedTools = SETUP_TOOLS.filter(t => selected.includes(t.value));
177
+ console.log('');
178
+
179
+ const ok = await runBatch(selectedTools, (tool) =>
180
+ runScriptPiped(tool.script, `--api-key "${apiKey}"`)
181
+ );
182
+ if (!ok) return;
183
+
184
+ console.log('');
185
+ output.success('All tools configured');
186
+ } catch (err) {
187
+ if (err.message === 'Selection cancelled') return;
188
+ output.error(err.message);
189
+ process.exitCode = 1;
190
+ }
191
+ });
63
192
 
64
193
  setup
65
194
  .command('cursor')
@@ -126,17 +255,19 @@ Examples:
126
255
  const apiKey = config.getApiKey();
127
256
  const scriptOpts = { clear: opts.clear };
128
257
 
258
+ if (opts.subscription && opts.gateway) {
259
+ output.error('Cannot use both --subscription and --gateway. Choose one.');
260
+ process.exitCode = 1;
261
+ return;
262
+ }
263
+
129
264
  let useSubscription = opts.subscription;
130
265
  if (!opts.clear && !opts.subscription && !opts.gateway) {
131
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
132
- const answer = await new Promise((resolve) => {
133
- console.log('\nHow do you want to use Claude Code with Unbound?\n');
134
- console.log(' 1. I have my own Claude subscription (hooks only - policy enforcement)');
135
- console.log(' 2. Use Unbound as the AI provider (gateway mode)\n');
136
- rl.question('Choose (1 or 2): ', resolve);
137
- });
138
- rl.close();
139
- useSubscription = answer.trim() === '1';
266
+ const mode = await output.select('How do you want to use Claude Code with Unbound?', [
267
+ { label: 'Use my Claude subscription (hooks)', value: 'subscription' },
268
+ { label: 'Use Unbound as the AI provider (gateway)', value: 'gateway' },
269
+ ]);
270
+ useSubscription = mode === 'subscription';
140
271
  }
141
272
 
142
273
  if (opts.clear) {
@@ -180,15 +311,26 @@ Examples:
180
311
  setup
181
312
  .command('codex')
182
313
  .description(
183
- 'Set up Codex to use Unbound. Sets OPENAI_API_KEY and ' +
184
- 'OPENAI_BASE_URL environment variables.'
314
+ 'Set up Codex to use Unbound. Prompts whether to use your existing ' +
315
+ 'OpenAI subscription or use Unbound as the AI provider.'
185
316
  )
317
+ .option('--subscription', 'Use your existing OpenAI subscription (hooks only)')
318
+ .option('--gateway', 'Use Unbound as the AI provider (gateway mode)')
186
319
  .option('--clear', 'Remove Unbound configuration for Codex')
187
320
  .addHelpText('after', `
188
- What this does:
189
- 1. Sets OPENAI_API_KEY to your Unbound API key in your shell profile
190
- 2. Sets OPENAI_BASE_URL to the Unbound gateway endpoint
191
- 3. Codex will route all requests through Unbound
321
+ Modes:
322
+ Subscription (hooks only):
323
+ Keep your existing OpenAI subscription. Installs Unbound hooks for
324
+ policy enforcement (security guardrails, cost limits) without changing
325
+ the AI provider. Runs codex/hooks/setup.py.
326
+
327
+ Gateway:
328
+ Use Unbound as the AI provider. Routes all Codex requests through
329
+ the Unbound gateway for full policy enforcement and model management.
330
+ Runs codex/gateway/setup.py.
331
+
332
+ If neither --subscription nor --gateway is provided, an interactive
333
+ prompt will ask you to choose.
192
334
 
193
335
  Prerequisites:
194
336
  - Must be logged in (will auto-open browser to authenticate if not)
@@ -196,10 +338,45 @@ Prerequisites:
196
338
  - Codex must be installed
197
339
 
198
340
  Examples:
199
- $ unbound setup codex
200
- $ unbound setup codex --clear # Remove Unbound configuration
341
+ $ unbound setup codex # Interactive mode selection
342
+ $ unbound setup codex --subscription # Hooks only (keep your subscription)
343
+ $ unbound setup codex --gateway # Use Unbound as AI provider
344
+ $ unbound setup codex --clear # Remove Unbound configuration
201
345
  `)
202
- .action(makeAction('codex/gateway/setup.py'));
346
+ .action(async (opts) => {
347
+ try {
348
+ await ensureLoggedIn();
349
+ const apiKey = config.getApiKey();
350
+ const scriptOpts = { clear: opts.clear };
351
+
352
+ if (opts.subscription && opts.gateway) {
353
+ output.error('Cannot use both --subscription and --gateway. Choose one.');
354
+ process.exitCode = 1;
355
+ return;
356
+ }
357
+
358
+ let useSubscription = opts.subscription;
359
+ if (!opts.clear && !opts.subscription && !opts.gateway) {
360
+ const mode = await output.select('How do you want to use Codex with Unbound?', [
361
+ { label: 'Use my OpenAI subscription (hooks)', value: 'subscription' },
362
+ { label: 'Use Unbound as the AI provider (gateway)', value: 'gateway' },
363
+ ]);
364
+ useSubscription = mode === 'subscription';
365
+ }
366
+
367
+ if (opts.clear) {
368
+ runSetupScript('codex/hooks/setup.py', apiKey, scriptOpts);
369
+ runSetupScript('codex/gateway/setup.py', apiKey, scriptOpts);
370
+ } else if (useSubscription) {
371
+ runSetupScript('codex/hooks/setup.py', apiKey, scriptOpts);
372
+ } else {
373
+ runSetupScript('codex/gateway/setup.py', apiKey, scriptOpts);
374
+ }
375
+ } catch (err) {
376
+ output.error(err.message);
377
+ process.exitCode = 1;
378
+ }
379
+ });
203
380
 
204
381
  // --- Instruction-only tools ---
205
382
 
@@ -349,6 +526,107 @@ Examples:
349
526
  process.exitCode = 1;
350
527
  }
351
528
  });
529
+
530
+ // --- MDM setup ---
531
+
532
+ const mdmToolNames = Object.keys(MDM_TOOLS).join(', ');
533
+
534
+ setup
535
+ .command('mdm')
536
+ .description(
537
+ 'MDM setup: configure all users on this device. Requires root. ' +
538
+ 'Used by organization admins to enroll devices via MDM.'
539
+ )
540
+ .argument('[tools...]', 'Tools to set up: ' + mdmToolNames)
541
+ .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
542
+ .option('--clear', 'Remove Unbound configuration for the specified tools')
543
+ .option('--all', 'Set up all available tools')
544
+ .addOption(new Option('--url <url>', 'Override backend URL').hideHelp())
545
+ .addHelpText('after', `
546
+ Available tools:
547
+ cursor Cursor IDE
548
+ claude-code-subscription Claude Code with your own subscription (hooks only)
549
+ claude-code-gateway Claude Code with Unbound as AI provider
550
+ gemini-cli Gemini CLI
551
+ codex-subscription Codex with your own subscription (hooks only)
552
+ codex-gateway Codex with Unbound as AI provider
553
+
554
+ Note: claude-code-subscription and claude-code-gateway are mutually exclusive.
555
+ codex-subscription and codex-gateway are mutually exclusive.
556
+ When using --all, subscription mode is used by default for Claude Code and Codex.
557
+
558
+ Examples:
559
+ $ sudo unbound setup mdm --admin-api-key KEY cursor
560
+ $ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex-subscription
561
+ $ sudo unbound setup mdm --admin-api-key KEY --all
562
+ $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
563
+ `)
564
+ .action(async (tools, opts) => {
565
+ try {
566
+ checkRoot();
567
+
568
+ if (opts.all && tools.length > 0) {
569
+ output.error('Cannot combine --all with specific tool names. Use one or the other.');
570
+ process.exitCode = 1;
571
+ return;
572
+ }
573
+
574
+ let toolNames;
575
+ if (opts.all) {
576
+ toolNames = MDM_ALL_TOOLS;
577
+ } else if (tools.length > 0) {
578
+ toolNames = tools;
579
+ } else {
580
+ output.error('Specify tools to set up, or use --all.');
581
+ console.error(' Available: ' + mdmToolNames);
582
+ process.exitCode = 1;
583
+ return;
584
+ }
585
+
586
+ const invalid = toolNames.filter(t => !MDM_TOOLS[t]);
587
+ if (invalid.length > 0) {
588
+ output.error(`Unknown tool(s): ${invalid.join(', ')}`);
589
+ console.error(' Available: ' + mdmToolNames);
590
+ process.exitCode = 1;
591
+ return;
592
+ }
593
+
594
+ if (toolNames.includes('claude-code-subscription') && toolNames.includes('claude-code-gateway')) {
595
+ output.error('Cannot use both claude-code-subscription and claude-code-gateway. Choose one.');
596
+ process.exitCode = 1;
597
+ return;
598
+ }
599
+
600
+ if (toolNames.includes('codex-subscription') && toolNames.includes('codex-gateway')) {
601
+ output.error('Cannot use both codex-subscription and codex-gateway. Choose one.');
602
+ process.exitCode = 1;
603
+ return;
604
+ }
605
+
606
+ const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
607
+ console.log('');
608
+
609
+ const mdmArgs = (tool) => {
610
+ let args = `--api_key "${opts.adminApiKey}"`;
611
+ if (opts.url) args += ` --url "${opts.url}"`;
612
+ if (opts.clear) args += ' --clear';
613
+ return args;
614
+ };
615
+
616
+ const ok = await runBatch(
617
+ resolvedTools,
618
+ (tool) => runScriptPiped(tool.script, mdmArgs(tool)),
619
+ { clear: opts.clear }
620
+ );
621
+ if (!ok) return;
622
+
623
+ console.log('');
624
+ output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
625
+ } catch (err) {
626
+ output.error(err.message);
627
+ process.exitCode = 1;
628
+ }
629
+ });
352
630
  }
353
631
 
354
632
  module.exports = { register };
@@ -11,6 +11,7 @@ const SUPPORTED_TOOL_TYPES = [
11
11
  'CLINE',
12
12
  'GEMINI_CLI',
13
13
  'CODEX',
14
+ 'UNBOUND_CODEX',
14
15
  'KILO_CODE',
15
16
  'CUSTOM_ACCESS',
16
17
  ];
package/src/index.js CHANGED
@@ -24,13 +24,15 @@ AUTHENTICATION
24
24
  $ unbound status Show CLI status and API connectivity
25
25
 
26
26
  TOOL SETUP
27
- Automated setup (installs hooks, sets env vars, configures tool):
27
+ $ unbound setup Select and install multiple tools interactively
28
28
  $ unbound setup cursor Set up Cursor
29
29
  $ unbound setup claude-code Set up Claude Code (interactive mode selection)
30
30
  $ unbound setup claude-code --gateway Use Unbound as AI provider
31
31
  $ unbound setup claude-code --subscription Hooks only (keep your subscription)
32
32
  $ unbound setup gemini-cli Set up Gemini CLI
33
- $ unbound setup codex Set up Codex
33
+ $ unbound setup codex Set up Codex (interactive mode selection)
34
+ $ unbound setup codex --gateway Use Unbound as AI provider
35
+ $ unbound setup codex --subscription Hooks only (keep your subscription)
34
36
 
35
37
  Instruction-only (shows config values to set manually):
36
38
  $ unbound setup roo-code Show Roo Code config values
@@ -44,6 +46,21 @@ TOOL SETUP
44
46
  $ unbound setup gemini-cli --clear Remove Unbound config for Gemini CLI
45
47
  $ unbound setup codex --clear Remove Unbound config for Codex
46
48
 
49
+ MDM SETUP (admin, requires root)
50
+ $ sudo unbound setup mdm --admin-api-key KEY --all
51
+ $ sudo unbound setup mdm --admin-api-key KEY cursor codex-subscription
52
+ $ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription codex-subscription gemini-cli
53
+ $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
54
+
55
+ MDM AI TOOLS DISCOVERY
56
+ --domain defaults to https://backend.getunbound.ai
57
+ $ sudo unbound discover --api-key KEY Scan all users (requires root)
58
+ $ unbound discover --api-key KEY Scan current user only
59
+ $ sudo unbound discover --api-key KEY --domain https://custom.backend.com
60
+ $ unbound discover schedule --api-key KEY Set up 12h recurring scan (macOS)
61
+ $ unbound discover unschedule Remove scheduled scan
62
+ $ unbound discover status Show scan schedule and logs
63
+
47
64
  POLICY MANAGEMENT
48
65
  $ unbound policy list List all policies
49
66
  $ unbound policy list --type SECURITY Filter by type (SECURITY, MODEL, COST)
@@ -89,6 +106,7 @@ require('./commands/users').register(program);
89
106
  require('./commands/user-groups').register(program);
90
107
  require('./commands/tools').register(program);
91
108
  require('./commands/setup').register(program);
109
+ require('./commands/discover').register(program);
92
110
 
93
111
  // config command for managing CLI settings
94
112
  const configCmd = program
package/src/output.js CHANGED
@@ -89,22 +89,264 @@ function spinner(message) {
89
89
  process.stderr.write(`\r${c.cyan(frames[i++ % frames.length])} ${message}`);
90
90
  }, 80);
91
91
 
92
+ function clear() {
93
+ clearInterval(interval);
94
+ process.stderr.write('\r\x1b[K');
95
+ process.removeListener('SIGINT', onSigint);
96
+ }
97
+ function onSigint() { clear(); process.exit(130); }
98
+ process.on('SIGINT', onSigint);
99
+
92
100
  return {
93
- stop() {
94
- clearInterval(interval);
95
- process.stderr.write('\r\x1b[K');
96
- },
97
- succeed(msg) {
98
- clearInterval(interval);
99
- process.stderr.write('\r\x1b[K');
100
- success(msg);
101
- },
102
- fail(msg) {
103
- clearInterval(interval);
104
- process.stderr.write('\r\x1b[K');
105
- error(msg);
106
- },
101
+ stop: clear,
102
+ succeed(msg) { clear(); success(msg); },
103
+ fail(msg) { clear(); error(msg); },
107
104
  };
108
105
  }
109
106
 
110
- module.exports = { table, json, keyValue, success, error, warn, info, spinner };
107
+ /**
108
+ * Interactive select prompt with arrow-key navigation.
109
+ * Falls back to a numbered prompt in non-TTY environments.
110
+ *
111
+ * @param {string} message - The prompt message
112
+ * @param {Array<{label: string, value: any}>} options - Options to choose from
113
+ * @returns {Promise<any>} The value of the selected option
114
+ */
115
+ function select(message, options) {
116
+ if (!options || options.length === 0) {
117
+ return Promise.reject(new Error('No options provided'));
118
+ }
119
+ return new Promise((resolve, reject) => {
120
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
121
+ const readline = require('readline');
122
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
123
+ console.log(`\n${message}\n`);
124
+ options.forEach((opt, i) => console.log(` ${i + 1}. ${opt.label}`));
125
+ console.log('');
126
+ rl.question(`Choose (1-${options.length}): `, (answer) => {
127
+ rl.close();
128
+ const idx = parseInt(answer.trim(), 10) - 1;
129
+ resolve(idx >= 0 && idx < options.length ? options[idx].value : options[0].value);
130
+ });
131
+ return;
132
+ }
133
+
134
+ let selected = 0;
135
+ const { stdin, stdout } = process;
136
+ const hint = c.dim(' \u2191/\u2193 to move, enter to select');
137
+
138
+ function render() {
139
+ return options.map((opt, i) =>
140
+ i === selected
141
+ ? ` ${c.cyan('\u276f')} ${c.cyan(opt.label)}`
142
+ : ` ${c.dim(opt.label)}`
143
+ );
144
+ }
145
+
146
+ // Static header
147
+ stdout.write(`\n${message}\n\n`);
148
+ // Hide cursor, render options + hint
149
+ stdout.write('\x1b[?25l');
150
+ const restoreCursor = () => stdout.write('\x1b[?25h');
151
+ process.on('exit', restoreCursor);
152
+ stdout.write(render().join('\n') + '\n\n' + hint);
153
+
154
+ // Dynamic area: options + blank line + hint line
155
+ const dynamicLines = options.length + 2;
156
+
157
+ stdin.setRawMode(true);
158
+ stdin.resume();
159
+ stdin.setEncoding('utf8');
160
+
161
+ function cleanup() {
162
+ stdin.setRawMode(false);
163
+ stdin.pause();
164
+ stdin.removeListener('data', onData);
165
+ process.removeListener('exit', restoreCursor);
166
+ restoreCursor();
167
+ }
168
+
169
+ function onData(key) {
170
+ if (key === '\x03') {
171
+ stdout.write(`\r\x1b[${dynamicLines - 1}A\x1b[J`);
172
+ cleanup();
173
+ stdout.write('\n');
174
+ reject(new Error('Selection cancelled'));
175
+ return;
176
+ }
177
+
178
+ if (key === '\r' || key === '\n') {
179
+ stdout.write(`\r\x1b[${dynamicLines - 1}A\x1b[J`);
180
+ const check = process.stdout.isTTY ? '\u2714' : '\u221A';
181
+ stdout.write(` ${c.green(check)} ${options[selected].label}\n`);
182
+ cleanup();
183
+ resolve(options[selected].value);
184
+ return;
185
+ }
186
+
187
+ let changed = false;
188
+ if (key === '\x1b[A') {
189
+ selected = selected > 0 ? selected - 1 : options.length - 1;
190
+ changed = true;
191
+ } else if (key === '\x1b[B') {
192
+ selected = selected < options.length - 1 ? selected + 1 : 0;
193
+ changed = true;
194
+ }
195
+ if (!changed) return;
196
+
197
+ stdout.write(`\r\x1b[${dynamicLines - 1}A`);
198
+ for (const line of render()) {
199
+ stdout.write(`\x1b[2K${line}\n`);
200
+ }
201
+ stdout.write(`\x1b[2K\n\x1b[2K${hint}`);
202
+ }
203
+
204
+ stdin.on('data', onData);
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Interactive multi-select prompt with arrow-key navigation and space to toggle.
210
+ * Items sharing the same `group` are mutually exclusive (radio within the group).
211
+ * Falls back to a comma-separated prompt in non-TTY environments.
212
+ *
213
+ * @param {string} message - The prompt message
214
+ * @param {Array<{label: string, value: any, group?: string}>} options
215
+ * @returns {Promise<Array<any>>} The values of selected options
216
+ */
217
+ function multiSelect(message, options) {
218
+ if (!options || options.length === 0) {
219
+ return Promise.resolve([]);
220
+ }
221
+ return new Promise((resolve, reject) => {
222
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
223
+ const readline = require('readline');
224
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
225
+ console.log(`\n${message}\n`);
226
+ options.forEach((opt, i) => console.log(` ${i + 1}. ${opt.label}`));
227
+ console.log('');
228
+ rl.question('Select (comma-separated numbers): ', (answer) => {
229
+ rl.close();
230
+ const indices = answer.split(',').map(s => parseInt(s.trim(), 10) - 1);
231
+ resolve(
232
+ indices.filter(i => i >= 0 && i < options.length).map(i => options[i].value)
233
+ );
234
+ });
235
+ return;
236
+ }
237
+
238
+ const checked = new Set();
239
+ let cursor = 0;
240
+ const { stdin, stdout } = process;
241
+
242
+ function getHint() {
243
+ const n = checked.size;
244
+ const count = n > 0 ? c.cyan(` (${n} selected)`) : '';
245
+ return c.dim(' \u2191/\u2193 move \u00b7 space select \u00b7 a all \u00b7 enter confirm') + count;
246
+ }
247
+
248
+ function render() {
249
+ return options.map((opt, i) => {
250
+ const focused = i === cursor;
251
+ const on = checked.has(i);
252
+ const pointer = focused ? c.cyan('\u276f') : ' ';
253
+ const box = on ? c.green('\u25fc') : c.dim('\u25fb');
254
+ const label = focused ? c.cyan(opt.label) : on ? opt.label : c.dim(opt.label);
255
+ return ` ${pointer} ${box} ${label}`;
256
+ });
257
+ }
258
+
259
+ stdout.write(`\n${message}\n\n`);
260
+ stdout.write('\x1b[?25l');
261
+ const restoreCursor = () => stdout.write('\x1b[?25h');
262
+ process.on('exit', restoreCursor);
263
+ stdout.write(render().join('\n') + '\n\n' + getHint());
264
+
265
+ const dynamicLines = options.length + 2;
266
+
267
+ stdin.setRawMode(true);
268
+ stdin.resume();
269
+ stdin.setEncoding('utf8');
270
+
271
+ function cleanup() {
272
+ stdin.setRawMode(false);
273
+ stdin.pause();
274
+ stdin.removeListener('data', onData);
275
+ process.removeListener('exit', restoreCursor);
276
+ restoreCursor();
277
+ }
278
+
279
+ function redraw() {
280
+ stdout.write(`\r\x1b[${dynamicLines - 1}A`);
281
+ for (const line of render()) {
282
+ stdout.write(`\x1b[2K${line}\n`);
283
+ }
284
+ stdout.write(`\x1b[2K\n\x1b[2K${getHint()}`);
285
+ }
286
+
287
+ function onData(key) {
288
+ if (key === '\x03') {
289
+ stdout.write(`\r\x1b[${dynamicLines - 1}A\x1b[J`);
290
+ cleanup();
291
+ stdout.write('\n');
292
+ reject(new Error('Selection cancelled'));
293
+ return;
294
+ }
295
+
296
+ if (key === '\r' || key === '\n') {
297
+ stdout.write(`\r\x1b[${dynamicLines - 1}A\x1b[J`);
298
+ const check = process.stdout.isTTY ? '\u2714' : '\u221a';
299
+ const selected = options.filter((_, i) => checked.has(i));
300
+ if (selected.length > 0) {
301
+ const names = selected.map(opt => opt.label).join(', ');
302
+ stdout.write(` ${c.green(check)} ${c.dim(names)}\n`);
303
+ } else {
304
+ stdout.write(c.dim(' No tools selected') + '\n');
305
+ }
306
+ cleanup();
307
+ resolve(selected.map(opt => opt.value));
308
+ return;
309
+ }
310
+
311
+ if (key === '\x1b[A') {
312
+ cursor = cursor > 0 ? cursor - 1 : options.length - 1;
313
+ redraw();
314
+ } else if (key === '\x1b[B') {
315
+ cursor = cursor < options.length - 1 ? cursor + 1 : 0;
316
+ redraw();
317
+ } else if (key === ' ') {
318
+ if (checked.has(cursor)) {
319
+ checked.delete(cursor);
320
+ } else {
321
+ const opt = options[cursor];
322
+ if (opt.group) {
323
+ for (let i = 0; i < options.length; i++) {
324
+ if (i !== cursor && options[i].group === opt.group) checked.delete(i);
325
+ }
326
+ }
327
+ checked.add(cursor);
328
+ }
329
+ redraw();
330
+ } else if (key === 'a') {
331
+ if (checked.size > 0) {
332
+ checked.clear();
333
+ } else {
334
+ const seenGroups = new Set();
335
+ for (let i = 0; i < options.length; i++) {
336
+ const g = options[i].group;
337
+ if (g) {
338
+ if (seenGroups.has(g)) continue;
339
+ seenGroups.add(g);
340
+ }
341
+ checked.add(i);
342
+ }
343
+ }
344
+ redraw();
345
+ }
346
+ }
347
+
348
+ stdin.on('data', onData);
349
+ });
350
+ }
351
+
352
+ module.exports = { table, json, keyValue, success, error, warn, info, spinner, select, multiSelect };