unbound-cli 0.1.10 → 0.2.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/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,77 @@ 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` | Set OPENAI_API_KEY and base URL |
64
+
65
+ Instruction-only (shows API key and base URL to configure manually):
66
+
67
+ | Command | Description |
68
+ |---------|-------------|
69
+ | `unbound setup roo-code` | Show Roo Code config values |
70
+ | `unbound setup cline` | Show Cline config values |
71
+ | `unbound setup kilo-code` | Show Kilo Code config values |
72
+ | `unbound setup custom-access` | Show API key and base URL for direct API access |
73
+
74
+ Remove configuration:
75
+
76
+ | Command | Description |
77
+ |---------|-------------|
78
+ | `unbound setup cursor --clear` | Remove Unbound config for Cursor |
79
+ | `unbound setup claude-code --clear` | Remove Unbound config for Claude Code |
80
+ | `unbound setup gemini-cli --clear` | Remove Unbound config for Gemini CLI |
81
+ | `unbound setup codex --clear` | Remove Unbound config for Codex |
82
+
83
+ ### MDM Setup (Admin)
84
+
85
+ Configure all users on a device via MDM. Requires root.
86
+
87
+ | Command | Description |
88
+ |---------|-------------|
89
+ | `sudo unbound setup mdm --admin-api-key KEY --all` | Set up all tools |
90
+ | `sudo unbound setup mdm --admin-api-key KEY cursor codex` | Set up specific tools |
91
+ | `sudo unbound setup mdm --admin-api-key KEY --clear cursor` | Remove config for specific tools |
92
+
93
+ Available tools: `cursor`, `claude-code-subscription`, `claude-code-gateway`, `gemini-cli`, `codex`
94
+
95
+ `claude-code-subscription` and `claude-code-gateway` are mutually exclusive. When using `--all`, `claude-code-subscription` is used by default.
96
+
97
+ ### MDM AI Tools Discovery
98
+
99
+ 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`.
100
+
101
+ | Command | Description |
102
+ |---------|-------------|
103
+ | `sudo unbound discover --api-key KEY` | Scan all users on the device (requires root) |
104
+ | `unbound discover --api-key KEY` | Scan current user only |
105
+ | `sudo unbound discover --api-key KEY --domain URL` | Scan with a custom backend URL |
106
+ | `unbound discover schedule --api-key KEY` | Set up 12-hour recurring scan (macOS only) |
107
+ | `unbound discover unschedule` | Remove the scheduled scan |
108
+ | `unbound discover status` | Show scan schedule and log paths |
109
+
41
110
  ### Policies (Admin only)
42
111
 
43
112
  | Command | Description |
@@ -82,27 +151,6 @@ Alias: `unbound groups` works the same as `unbound user-groups`.
82
151
 
83
152
  Supported tool types: `CLAUDE_CODE`, `CURSOR`, `COPILOT`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
84
153
 
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
-
105
-
106
154
  ## Configuration
107
155
 
108
156
  Config is stored in `~/.unbound/config.json`.
@@ -117,7 +165,6 @@ Config is stored in `~/.unbound/config.json`.
117
165
  2. `frontend_url` in `~/.unbound/config.json` (set via `unbound config set-frontend-url`)
118
166
  3. Default: `https://gateway.getunbound.ai`
119
167
 
120
-
121
168
  ## Global Options
122
169
 
123
170
  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.10",
3
+ "version": "0.2.0",
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,112 @@
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', value: 'codex', script: 'codex/gateway/setup.py' },
15
+ ];
16
+
17
+ const MDM_TOOLS = {
18
+ 'cursor': { label: 'Cursor', script: 'cursor/mdm/setup.py' },
19
+ 'claude-code-subscription': { label: 'Claude Code (subscription)', script: 'claude-code/hooks/mdm/setup.py' },
20
+ 'claude-code-gateway': { label: 'Claude Code (gateway)', script: 'claude-code/gateway/mdm/setup.py' },
21
+ 'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/mdm/setup.py' },
22
+ 'codex': { label: 'Codex', script: 'codex/gateway/mdm/setup.py' },
23
+ };
24
+
25
+ // Default tools for --all (uses subscription mode for Claude Code since only one can be active)
26
+ const MDM_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex'];
27
+
9
28
  /**
10
- * Runs a Python setup script from the setup repo, passing the stored API key.
29
+ * Builds a shell command that curls a setup script and pipes it to python3.
11
30
  */
12
- function runSetupScript(scriptPath, apiKey, { clear = false } = {}) {
31
+ function buildSetupCommand(scriptPath, args) {
13
32
  const url = `${SETUP_BASE_URL}/${scriptPath}`;
14
- const extraArgs = clear ? ' --clear' : '';
15
- const cmd = `curl -fsSL "${url}" | python3 - --api-key "${apiKey}"${extraArgs}`;
33
+ return `curl -fsSL "${url}" | python3 - ${args}`;
34
+ }
16
35
 
36
+ /**
37
+ * Runs a Python setup script from the setup repo with inherited stdio (live output).
38
+ */
39
+ function runSetupScript(scriptPath, apiKey, { clear = false } = {}) {
40
+ const args = `--api-key "${apiKey}"${clear ? ' --clear' : ''}`;
17
41
  console.log('');
18
- execSync(cmd, { stdio: 'inherit' });
42
+ try {
43
+ execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
44
+ } catch (err) {
45
+ throw new Error(`Setup script failed with exit code ${err.status || 1}. Check the output above for details.`);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Runs a setup script with piped output (captured silently for spinner display).
51
+ * Returns a promise that resolves on success, rejects with captured output on failure.
52
+ */
53
+ function runScriptPiped(scriptPath, args) {
54
+ return new Promise((resolve, reject) => {
55
+ const child = spawn(buildSetupCommand(scriptPath, args), {
56
+ shell: true,
57
+ stdio: ['pipe', 'pipe', 'pipe'],
58
+ });
59
+ child.stdin.on('error', () => {});
60
+ child.stdin.end();
61
+
62
+ let captured = '';
63
+ child.stdout.on('data', (d) => { captured += d.toString(); });
64
+ child.stderr.on('data', (d) => { captured += d.toString(); });
65
+
66
+ child.on('close', (code) => {
67
+ if (code === 0) {
68
+ resolve();
69
+ } else {
70
+ const err = new Error(captured.trim() || `Setup failed with exit code ${code}`);
71
+ err.setupOutput = captured.trim();
72
+ reject(err);
73
+ }
74
+ });
75
+
76
+ child.on('error', reject);
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Checks that the process is running as root (macOS/Linux).
82
+ * Windows admin check is handled by the Python MDM scripts themselves.
83
+ */
84
+ function checkRoot() {
85
+ if (process.platform === 'win32') return;
86
+ if (typeof process.getuid !== 'function' || process.getuid() !== 0) {
87
+ throw new Error('MDM setup requires root. Run with: sudo unbound setup mdm ...');
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Runs a batch of tools sequentially with spinners.
93
+ * Stops on first failure. Returns true if all succeeded.
94
+ */
95
+ async function runBatch(tools, runFn, { clear = false } = {}) {
96
+ const action = clear ? 'Clearing' : 'Setting up';
97
+ for (const tool of tools) {
98
+ const s = output.spinner(`${action} ${tool.label}...`);
99
+ try {
100
+ await runFn(tool);
101
+ s.succeed(tool.label);
102
+ } catch (err) {
103
+ s.fail(`Failed: ${tool.label}`);
104
+ if (err.setupOutput) console.error('\n' + err.setupOutput);
105
+ process.exitCode = 1;
106
+ return false;
107
+ }
108
+ }
109
+ return true;
19
110
  }
20
111
 
21
112
  /**
@@ -45,7 +136,10 @@ function register(program) {
45
136
  'Supported tools: cursor, claude-code, gemini-cli, codex, roo-code, cline, kilo-code, custom-access.'
46
137
  )
47
138
  .addHelpText('after', `
48
- Automated setup (downloads scripts, sets env vars, configures tool):
139
+ Interactive batch setup:
140
+ $ unbound setup # Select and install multiple tools at once
141
+
142
+ Single-tool setup (downloads scripts, sets env vars, configures tool):
49
143
  $ unbound setup cursor # Download hooks, set env, restart Cursor
50
144
  $ unbound setup claude-code # Set up gateway + hooks for Claude Code
51
145
  $ unbound setup gemini-cli # Set GEMINI_API_KEY and base URL
@@ -59,7 +153,40 @@ Instruction-only (shows API key and base URL to configure manually):
59
153
 
60
154
  All setup commands require login. If not logged in, the browser will
61
155
  open automatically to authenticate before proceeding.
62
- `);
156
+ `)
157
+ // Parent action: runs when `unbound setup` is invoked with no subcommand.
158
+ // Subcommand actions (cursor, claude-code, etc.) take precedence when specified.
159
+ .action(async () => {
160
+ try {
161
+ await ensureLoggedIn();
162
+ const apiKey = config.getApiKey();
163
+
164
+ const selected = await output.multiSelect(
165
+ 'Select tools to set up with Unbound:',
166
+ SETUP_TOOLS
167
+ );
168
+
169
+ if (selected.length === 0) {
170
+ output.warn('No tools selected.');
171
+ return;
172
+ }
173
+
174
+ const selectedTools = SETUP_TOOLS.filter(t => selected.includes(t.value));
175
+ console.log('');
176
+
177
+ const ok = await runBatch(selectedTools, (tool) =>
178
+ runScriptPiped(tool.script, `--api-key "${apiKey}"`)
179
+ );
180
+ if (!ok) return;
181
+
182
+ console.log('');
183
+ output.success('All tools configured');
184
+ } catch (err) {
185
+ if (err.message === 'Selection cancelled') return;
186
+ output.error(err.message);
187
+ process.exitCode = 1;
188
+ }
189
+ });
63
190
 
64
191
  setup
65
192
  .command('cursor')
@@ -126,17 +253,19 @@ Examples:
126
253
  const apiKey = config.getApiKey();
127
254
  const scriptOpts = { clear: opts.clear };
128
255
 
256
+ if (opts.subscription && opts.gateway) {
257
+ output.error('Cannot use both --subscription and --gateway. Choose one.');
258
+ process.exitCode = 1;
259
+ return;
260
+ }
261
+
129
262
  let useSubscription = opts.subscription;
130
263
  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';
264
+ const mode = await output.select('How do you want to use Claude Code with Unbound?', [
265
+ { label: 'Use my Claude subscription (hooks)', value: 'subscription' },
266
+ { label: 'Use Unbound as the AI provider (gateway)', value: 'gateway' },
267
+ ]);
268
+ useSubscription = mode === 'subscription';
140
269
  }
141
270
 
142
271
  if (opts.clear) {
@@ -349,6 +478,99 @@ Examples:
349
478
  process.exitCode = 1;
350
479
  }
351
480
  });
481
+
482
+ // --- MDM setup ---
483
+
484
+ const mdmToolNames = Object.keys(MDM_TOOLS).join(', ');
485
+
486
+ setup
487
+ .command('mdm')
488
+ .description(
489
+ 'MDM setup: configure all users on this device. Requires root. ' +
490
+ 'Used by organization admins to enroll devices via MDM.'
491
+ )
492
+ .argument('[tools...]', 'Tools to set up: ' + mdmToolNames)
493
+ .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
494
+ .option('--clear', 'Remove Unbound configuration for the specified tools')
495
+ .option('--all', 'Set up all available tools')
496
+ .addOption(new Option('--url <url>', 'Override backend URL').hideHelp())
497
+ .addHelpText('after', `
498
+ Available tools:
499
+ cursor Cursor IDE
500
+ claude-code-subscription Claude Code with your own subscription (hooks only)
501
+ claude-code-gateway Claude Code with Unbound as AI provider
502
+ gemini-cli Gemini CLI
503
+ codex Codex CLI
504
+
505
+ Note: claude-code-subscription and claude-code-gateway are mutually exclusive.
506
+ When using --all, claude-code-subscription is used by default.
507
+
508
+ Examples:
509
+ $ sudo unbound setup mdm --admin-api-key KEY cursor
510
+ $ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex
511
+ $ sudo unbound setup mdm --admin-api-key KEY --all
512
+ $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex
513
+ `)
514
+ .action(async (tools, opts) => {
515
+ try {
516
+ checkRoot();
517
+
518
+ if (opts.all && tools.length > 0) {
519
+ output.error('Cannot combine --all with specific tool names. Use one or the other.');
520
+ process.exitCode = 1;
521
+ return;
522
+ }
523
+
524
+ let toolNames;
525
+ if (opts.all) {
526
+ toolNames = MDM_ALL_TOOLS;
527
+ } else if (tools.length > 0) {
528
+ toolNames = tools;
529
+ } else {
530
+ output.error('Specify tools to set up, or use --all.');
531
+ console.error(' Available: ' + mdmToolNames);
532
+ process.exitCode = 1;
533
+ return;
534
+ }
535
+
536
+ const invalid = toolNames.filter(t => !MDM_TOOLS[t]);
537
+ if (invalid.length > 0) {
538
+ output.error(`Unknown tool(s): ${invalid.join(', ')}`);
539
+ console.error(' Available: ' + mdmToolNames);
540
+ process.exitCode = 1;
541
+ return;
542
+ }
543
+
544
+ if (toolNames.includes('claude-code-subscription') && toolNames.includes('claude-code-gateway')) {
545
+ output.error('Cannot use both claude-code-subscription and claude-code-gateway. Choose one.');
546
+ process.exitCode = 1;
547
+ return;
548
+ }
549
+
550
+ const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
551
+ console.log('');
552
+
553
+ const mdmArgs = (tool) => {
554
+ let args = `--api_key "${opts.adminApiKey}"`;
555
+ if (opts.url) args += ` --url "${opts.url}"`;
556
+ if (opts.clear) args += ' --clear';
557
+ return args;
558
+ };
559
+
560
+ const ok = await runBatch(
561
+ resolvedTools,
562
+ (tool) => runScriptPiped(tool.script, mdmArgs(tool)),
563
+ { clear: opts.clear }
564
+ );
565
+ if (!ok) return;
566
+
567
+ console.log('');
568
+ output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
569
+ } catch (err) {
570
+ output.error(err.message);
571
+ process.exitCode = 1;
572
+ }
573
+ });
352
574
  }
353
575
 
354
576
  module.exports = { register };
@@ -23,35 +23,35 @@ Examples:
23
23
  .action(async (opts) => {
24
24
  try {
25
25
  const loggedIn = config.isLoggedIn();
26
- const cfg = config.readConfig();
27
26
 
28
27
  const pairs = [
29
28
  ['Config file', config.CONFIG_FILE],
30
29
  ['Logged in', loggedIn ? 'Yes' : 'No'],
31
30
  ];
32
31
 
33
- if (loggedIn) {
34
- pairs.push(['Email', cfg.email || '-']);
35
- pairs.push(['Organization', cfg.org_name || '-']);
36
- pairs.push(['Unbound Gateway', config.getFrontendUrl()]);
37
- }
38
-
39
32
  // Check API connectivity
40
33
  let connectivity = 'Not checked (not logged in)';
41
34
  if (loggedIn) {
42
35
  const spin = output.spinner('Checking API connectivity...');
43
36
  try {
44
- await api.get('/api/v1/users/privileges/');
37
+ const privileges = await api.get('/api/v1/users/privileges/');
38
+ config.backfillUserInfo(privileges);
45
39
  spin.stop();
46
40
  connectivity = 'Connected';
47
41
  } catch (err) {
48
42
  spin.stop();
49
43
  connectivity = `Error: ${err.message}`;
50
44
  }
45
+
46
+ const cfg = config.readConfig();
47
+ pairs.push(['Email', cfg.email || '-']);
48
+ pairs.push(['Organization', cfg.org_name || '-']);
49
+ pairs.push(['Unbound Gateway', config.getFrontendUrl()]);
51
50
  }
52
51
  pairs.push(['API status', connectivity]);
53
52
 
54
53
  if (opts.json) {
54
+ const cfg = loggedIn ? config.readConfig() : {};
55
55
  output.json({
56
56
  config_file: config.CONFIG_FILE,
57
57
  logged_in: loggedIn,
@@ -36,17 +36,19 @@ Examples:
36
36
  try {
37
37
  const privileges = await api.get('/api/v1/users/privileges/');
38
38
  spin.stop();
39
+ config.backfillUserInfo(privileges);
39
40
 
40
41
  const role = roleFromPrivileges(privileges);
42
+ const freshCfg = config.readConfig();
41
43
 
42
44
  if (opts.json) {
43
- output.json({ email: cfg.email || null, organization: cfg.org_name || null, role });
45
+ output.json({ email: freshCfg.email || null, organization: freshCfg.org_name || null, role });
44
46
  return;
45
47
  }
46
48
 
47
49
  output.keyValue([
48
- ['Email', cfg.email || '-'],
49
- ['Organization', cfg.org_name || '-'],
50
+ ['Email', freshCfg.email || '-'],
51
+ ['Organization', freshCfg.org_name || '-'],
50
52
  ['Role', role],
51
53
  ]);
52
54
  } catch (err) {
package/src/config.js CHANGED
@@ -70,6 +70,21 @@ function isLoggedIn() {
70
70
  return !!getApiKey();
71
71
  }
72
72
 
73
+ function backfillUserInfo(apiResponse) {
74
+ if (!apiResponse) return;
75
+ const cfg = readConfig();
76
+ let changed = false;
77
+ if (!cfg.email && apiResponse.email) {
78
+ cfg.email = apiResponse.email;
79
+ changed = true;
80
+ }
81
+ if (!cfg.org_name && (apiResponse.org_name || apiResponse.organization_name || apiResponse.organization)) {
82
+ cfg.org_name = apiResponse.org_name || apiResponse.organization_name || apiResponse.organization;
83
+ changed = true;
84
+ }
85
+ if (changed) writeConfig(cfg);
86
+ }
87
+
73
88
  module.exports = {
74
89
  CONFIG_DIR,
75
90
  CONFIG_FILE,
@@ -84,4 +99,5 @@ module.exports = {
84
99
  setFrontendUrl,
85
100
  clearConfig,
86
101
  isLoggedIn,
102
+ backfillUserInfo,
87
103
  };
package/src/index.js CHANGED
@@ -24,7 +24,7 @@ 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
@@ -44,6 +44,21 @@ TOOL SETUP
44
44
  $ unbound setup gemini-cli --clear Remove Unbound config for Gemini CLI
45
45
  $ unbound setup codex --clear Remove Unbound config for Codex
46
46
 
47
+ MDM SETUP (admin, requires root)
48
+ $ sudo unbound setup mdm --admin-api-key KEY --all
49
+ $ sudo unbound setup mdm --admin-api-key KEY cursor codex
50
+ $ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription gemini-cli
51
+ $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex
52
+
53
+ MDM AI TOOLS DISCOVERY
54
+ --domain defaults to https://backend.getunbound.ai
55
+ $ sudo unbound discover --api-key KEY Scan all users (requires root)
56
+ $ unbound discover --api-key KEY Scan current user only
57
+ $ sudo unbound discover --api-key KEY --domain https://custom.backend.com
58
+ $ unbound discover schedule --api-key KEY Set up 12h recurring scan (macOS)
59
+ $ unbound discover unschedule Remove scheduled scan
60
+ $ unbound discover status Show scan schedule and logs
61
+
47
62
  POLICY MANAGEMENT
48
63
  $ unbound policy list List all policies
49
64
  $ unbound policy list --type SECURITY Filter by type (SECURITY, MODEL, COST)
@@ -89,6 +104,7 @@ require('./commands/users').register(program);
89
104
  require('./commands/user-groups').register(program);
90
105
  require('./commands/tools').register(program);
91
106
  require('./commands/setup').register(program);
107
+ require('./commands/discover').register(program);
92
108
 
93
109
  // config command for managing CLI settings
94
110
  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 };