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 +72 -25
- package/package.json +1 -1
- package/src/commands/discover.js +210 -0
- package/src/commands/setup.js +240 -18
- package/src/commands/status.js +8 -8
- package/src/commands/whoami.js +5 -3
- package/src/config.js +16 -0
- package/src/index.js +17 -1
- 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,77 @@ 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` | 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
|
@@ -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,112 @@
|
|
|
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', 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
|
-
*
|
|
29
|
+
* Builds a shell command that curls a setup script and pipes it to python3.
|
|
11
30
|
*/
|
|
12
|
-
function
|
|
31
|
+
function buildSetupCommand(scriptPath, args) {
|
|
13
32
|
const url = `${SETUP_BASE_URL}/${scriptPath}`;
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 };
|
package/src/commands/status.js
CHANGED
|
@@ -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,
|
package/src/commands/whoami.js
CHANGED
|
@@ -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:
|
|
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',
|
|
49
|
-
['Organization',
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|