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 +75 -26
- package/package.json +1 -1
- package/src/commands/discover.js +210 -0
- package/src/commands/setup.js +306 -28
- package/src/commands/tools.js +1 -0
- package/src/index.js +20 -2
- package/src/output.js +257 -15
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
|
|
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
|
|
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
|
@@ -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 };
|
package/src/commands/setup.js
CHANGED
|
@@ -1,21 +1,114 @@
|
|
|
1
|
-
const { execSync } = require('child_process');
|
|
2
|
-
const
|
|
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
|
-
*
|
|
31
|
+
* Builds a shell command that curls a setup script and pipes it to python3.
|
|
11
32
|
*/
|
|
12
|
-
function
|
|
33
|
+
function buildSetupCommand(scriptPath, args) {
|
|
13
34
|
const url = `${SETUP_BASE_URL}/${scriptPath}`;
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
184
|
-
'
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 --
|
|
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(
|
|
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 };
|
package/src/commands/tools.js
CHANGED
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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 };
|