unbound-cli 0.2.0 → 0.3.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
@@ -30,6 +30,7 @@ unbound setup
30
30
  unbound setup cursor
31
31
  ```
32
32
 
33
+
33
34
  ## Commands
34
35
 
35
36
  ### Authentication
@@ -60,7 +61,9 @@ Automated setup (downloads scripts, sets env vars, configures tool):
60
61
  | `unbound setup claude-code --subscription` | Hooks only (keep your Claude subscription) |
61
62
  | `unbound setup claude-code --gateway` | Use Unbound as the AI provider |
62
63
  | `unbound setup gemini-cli` | Set GEMINI_API_KEY and base URL |
63
- | `unbound setup codex` | Set OPENAI_API_KEY and base URL |
64
+ | `unbound setup codex` | Interactive mode selection (subscription or gateway) |
65
+ | `unbound setup codex --subscription` | Hooks only (keep your OpenAI subscription) |
66
+ | `unbound setup codex --gateway` | Use Unbound as the AI provider |
64
67
 
65
68
  Instruction-only (shows API key and base URL to configure manually):
66
69
 
@@ -87,12 +90,12 @@ Configure all users on a device via MDM. Requires root.
87
90
  | Command | Description |
88
91
  |---------|-------------|
89
92
  | `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 |
93
+ | `sudo unbound setup mdm --admin-api-key KEY cursor codex-subscription` | Set up specific tools |
91
94
  | `sudo unbound setup mdm --admin-api-key KEY --clear cursor` | Remove config for specific tools |
92
95
 
93
- Available tools: `cursor`, `claude-code-subscription`, `claude-code-gateway`, `gemini-cli`, `codex`
96
+ Available tools: `cursor`, `claude-code-subscription`, `claude-code-gateway`, `gemini-cli`, `codex-subscription`, `codex-gateway`
94
97
 
95
- `claude-code-subscription` and `claude-code-gateway` are mutually exclusive. When using `--all`, `claude-code-subscription` is used by default.
98
+ `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.
96
99
 
97
100
  ### MDM AI Tools Discovery
98
101
 
@@ -149,7 +152,7 @@ Alias: `unbound groups` works the same as `unbound user-groups`.
149
152
  | `unbound tools connect <type>` | Connect a tool |
150
153
  | `unbound tools approved` | List approved tool types |
151
154
 
152
- Supported tool types: `CLAUDE_CODE`, `CURSOR`, `COPILOT`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
155
+ Supported tool types: `CLAUDE_CODE`, `UNBOUND_CLAUDE_CODE`, `CURSOR`, `COPILOT`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `UNBOUND_CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
153
156
 
154
157
  ## Configuration
155
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/auth.js CHANGED
@@ -6,6 +6,7 @@ const http = require('http');
6
6
  const { URL } = require('url');
7
7
  const config = require('./config');
8
8
  const output = require('./output');
9
+ const api = require('./api');
9
10
 
10
11
  /**
11
12
  * Opens the browser for authentication and waits for the callback.
@@ -102,10 +103,53 @@ async function loginWithBrowser(frontendUrl) {
102
103
  }
103
104
 
104
105
  /**
105
- * Ensures the user is logged in. If not, triggers browser-based login.
106
+ * Validates an API key against the backend and stores it.
107
+ * Shows a spinner during validation and a success message with user info.
108
+ *
109
+ * @param {string} apiKey - The API key to validate and store
110
+ * @returns {Promise<true>}
111
+ */
112
+ async function loginWithApiKey(apiKey) {
113
+ const spin = output.spinner('Authenticating...');
114
+ let response;
115
+ try {
116
+ response = await api.get('/api/v1/users/privileges/', { apiKey });
117
+ } catch (err) {
118
+ let msg;
119
+ if (err.statusCode === 401 || err.statusCode === 403) {
120
+ msg = 'Authentication failed. The API key may be invalid or expired.';
121
+ } else if (err.statusCode) {
122
+ msg = `Authentication failed (HTTP ${err.statusCode}). Please try again.`;
123
+ } else {
124
+ msg = 'Unable to reach the server. Please check your internet connection.';
125
+ }
126
+ spin.fail(msg);
127
+ err.displayed = true;
128
+ throw err;
129
+ }
130
+ config.setApiKey(apiKey);
131
+ config.backfillUserInfo(response);
132
+
133
+ const parts = [];
134
+ if (response.email) parts.push(response.email);
135
+ const orgName = response.org_name || response.organization_name || response.organization;
136
+ if (orgName) parts.push(orgName);
137
+ spin.succeed(parts.length > 0 ? `Authenticated as ${parts.join(' \u00b7 ')}` : 'Authenticated');
138
+
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * Ensures the user is logged in. If an apiKey is provided, validates and
144
+ * stores it first. If not logged in, triggers browser-based login.
106
145
  * Returns true if logged in (or just logged in), false on failure.
107
146
  */
108
- async function ensureLoggedIn() {
147
+ async function ensureLoggedIn({ apiKey } = {}) {
148
+ if (apiKey) {
149
+ await loginWithApiKey(apiKey);
150
+ return true;
151
+ }
152
+
109
153
  if (config.isLoggedIn()) {
110
154
  return true;
111
155
  }
@@ -122,4 +166,4 @@ async function ensureLoggedIn() {
122
166
  return true;
123
167
  }
124
168
 
125
- module.exports = { loginWithBrowser, ensureLoggedIn };
169
+ module.exports = { loginWithBrowser, loginWithApiKey, ensureLoggedIn };
@@ -2,7 +2,7 @@ const { Option } = require('commander');
2
2
  const config = require('../config');
3
3
  const output = require('../output');
4
4
  const api = require('../api');
5
- const { loginWithBrowser } = require('../auth');
5
+ const { loginWithBrowser, loginWithApiKey } = require('../auth');
6
6
 
7
7
  function register(program) {
8
8
  program
@@ -39,18 +39,13 @@ Examples:
39
39
  let apiKey;
40
40
 
41
41
  if (opts.apiKey) {
42
- apiKey = opts.apiKey;
43
- // Validate the key before storing
44
- const spin = output.spinner('Validating API key...');
45
42
  try {
46
- await api.get('/api/v1/users/privileges/', { apiKey });
47
- spin.stop();
48
- } catch (err) {
49
- spin.fail('Invalid API key. Check your key and try again.');
43
+ await loginWithApiKey(opts.apiKey);
44
+ } catch {
50
45
  process.exitCode = 1;
51
46
  return;
52
47
  }
53
- config.setApiKey(apiKey);
48
+ return;
54
49
  } else {
55
50
  // Browser flow — key is already saved by auth.loginWithBrowser()
56
51
  let frontendUrl;
@@ -11,7 +11,8 @@ const SETUP_TOOLS = [
11
11
  { label: 'Claude Code \u2014 subscription (hooks)', value: 'claude-sub', script: 'claude-code/hooks/setup.py', group: 'claude-code' },
12
12
  { label: 'Claude Code \u2014 gateway (gateway)', value: 'claude-gw', script: 'claude-code/gateway/setup.py', group: 'claude-code' },
13
13
  { label: 'Gemini CLI', value: 'gemini', script: 'gemini-cli/gateway/setup.py' },
14
- { label: 'Codex', value: 'codex', script: 'codex/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' },
15
16
  ];
16
17
 
17
18
  const MDM_TOOLS = {
@@ -19,11 +20,60 @@ const MDM_TOOLS = {
19
20
  'claude-code-subscription': { label: 'Claude Code (subscription)', script: 'claude-code/hooks/mdm/setup.py' },
20
21
  'claude-code-gateway': { label: 'Claude Code (gateway)', script: 'claude-code/gateway/mdm/setup.py' },
21
22
  'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/mdm/setup.py' },
22
- 'codex': { label: 'Codex', script: 'codex/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' },
23
25
  };
24
26
 
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
+ // 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
+
30
+ // Tool name → script mapping for automated tools
31
+ const SETUP_TOOL_MAP = {
32
+ 'cursor': { label: 'Cursor', script: 'cursor/setup.py' },
33
+ 'claude-code-subscription': { label: 'Claude Code (subscription)', script: 'claude-code/hooks/setup.py' },
34
+ 'claude-code-gateway': { label: 'Claude Code (gateway)', script: 'claude-code/gateway/setup.py' },
35
+ 'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/setup.py' },
36
+ 'codex-subscription': { label: 'Codex (subscription)', script: 'codex/hooks/setup.py' },
37
+ 'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/setup.py' },
38
+ };
39
+
40
+ // Instruction-only tools (display config values, no setup scripts)
41
+ const INSTRUCTION_TOOLS = {
42
+ 'roo-code': { label: 'Roo Code', values: (apiKey) => [['API Provider', 'unbound'], ['API Key', apiKey]] },
43
+ 'cline': { label: 'Cline', values: (apiKey, frontendUrl) => [['API Provider', 'OpenAI Compatible'], ['Base URL', frontendUrl], ['API Key', apiKey]] },
44
+ 'kilo-code': { label: 'Kilo Code', values: (apiKey) => [['API Provider', 'Unbound'], ['API Key', apiKey]] },
45
+ 'custom-access': { label: 'Custom Access', values: (apiKey, frontendUrl) => [['API Key', apiKey], ['Base URL', frontendUrl]] },
46
+ };
47
+
48
+ // Tools that need interactive mode selection (claude-code, codex without suffix)
49
+ const MODE_TOOLS = {
50
+ 'claude-code': {
51
+ prompt: 'How do you want to use Claude Code with Unbound?',
52
+ options: [
53
+ { label: 'Use my Claude subscription (hooks)', value: 'subscription' },
54
+ { label: 'Use Unbound as the AI provider (gateway)', value: 'gateway' },
55
+ ],
56
+ subscription: 'claude-code-subscription',
57
+ gateway: 'claude-code-gateway',
58
+ },
59
+ 'codex': {
60
+ prompt: 'How do you want to use Codex with Unbound?',
61
+ options: [
62
+ { label: 'Use my OpenAI subscription (hooks)', value: 'subscription' },
63
+ { label: 'Use Unbound as the AI provider (gateway)', value: 'gateway' },
64
+ ],
65
+ subscription: 'codex-subscription',
66
+ gateway: 'codex-gateway',
67
+ },
68
+ };
69
+
70
+ /**
71
+ * Escapes a string for safe embedding in a shell command.
72
+ * Wraps in single quotes and escapes any embedded single quotes.
73
+ */
74
+ function shellEscape(str) {
75
+ return "'" + str.replace(/'/g, "'\\''") + "'";
76
+ }
27
77
 
28
78
  /**
29
79
  * Builds a shell command that curls a setup script and pipes it to python3.
@@ -37,7 +87,7 @@ function buildSetupCommand(scriptPath, args) {
37
87
  * Runs a Python setup script from the setup repo with inherited stdio (live output).
38
88
  */
39
89
  function runSetupScript(scriptPath, apiKey, { clear = false } = {}) {
40
- const args = `--api-key "${apiKey}"${clear ? ' --clear' : ''}`;
90
+ const args = `--api-key ${shellEscape(apiKey)}${clear ? ' --clear' : ''}`;
41
91
  console.log('');
42
92
  try {
43
93
  execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
@@ -109,372 +159,218 @@ async function runBatch(tools, runFn, { clear = false } = {}) {
109
159
  return true;
110
160
  }
111
161
 
112
- /**
113
- * Ensures login, gets the stored API key, and runs the setup script(s).
114
- */
115
- function makeAction(...scriptPaths) {
116
- return async (opts) => {
117
- try {
118
- await ensureLoggedIn();
119
- const apiKey = config.getApiKey();
120
-
121
- for (const scriptPath of scriptPaths) {
122
- runSetupScript(scriptPath, apiKey, { clear: opts.clear });
123
- }
124
- } catch (err) {
125
- output.error(err.message);
126
- process.exitCode = 1;
127
- }
128
- };
129
- }
130
-
131
162
  function register(program) {
132
163
  const setup = program
133
164
  .command('setup')
165
+ .argument('[tools...]', 'Tools to set up')
134
166
  .description(
135
167
  'Configure AI coding tools to use Unbound as their API gateway. ' +
136
- 'Supported tools: cursor, claude-code, gemini-cli, codex, roo-code, cline, kilo-code, custom-access.'
168
+ 'Run with no arguments for interactive setup, or specify tools directly.'
137
169
  )
170
+ .option('--api-key <key>', 'Authenticate with an API key (skips browser login)')
171
+ .option('--clear', 'Remove Unbound configuration for the specified tools')
172
+ .option('--subscription', 'Use subscription mode for Claude Code / Codex (hooks only)')
173
+ .option('--gateway', 'Use gateway mode for Claude Code / Codex (Unbound as AI provider)')
138
174
  .addHelpText('after', `
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):
143
- $ unbound setup cursor # Download hooks, set env, restart Cursor
144
- $ unbound setup claude-code # Set up gateway + hooks for Claude Code
145
- $ unbound setup gemini-cli # Set GEMINI_API_KEY and base URL
146
- $ unbound setup codex # Set OPENAI_API_KEY and base URL
147
-
148
- Instruction-only (shows API key and base URL to configure manually):
149
- $ unbound setup roo-code # Show Roo Code config values
150
- $ unbound setup cline # Show Cline config values
151
- $ unbound setup kilo-code # Show Kilo Code config values
152
- $ unbound setup custom-access # Show API key and base URL for direct API access
153
-
154
- All setup commands require login. If not logged in, the browser will
155
- open automatically to authenticate before proceeding.
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
- });
190
-
191
- setup
192
- .command('cursor')
193
- .description(
194
- 'Set up Cursor to use Unbound. Downloads hook scripts, sets the ' +
195
- 'UNBOUND_CURSOR_API_KEY environment variable, and restarts Cursor.'
196
- )
197
- .option('--clear', 'Remove Unbound configuration for Cursor')
198
- .addHelpText('after', `
199
- What this does:
200
- 1. Downloads Cursor hook scripts from the Unbound setup repository
201
- 2. Sets the UNBOUND_CURSOR_API_KEY environment variable in your shell profile
202
- 3. Restarts Cursor to apply changes
203
-
204
- Prerequisites:
205
- - Must be logged in (will auto-open browser to authenticate if not)
206
- - Python 3 and curl must be installed
207
- - Cursor must be installed
175
+ Available tools:
176
+ cursor Cursor IDE
177
+ claude-code Claude Code (use --subscription or --gateway)
178
+ gemini-cli Gemini CLI
179
+ codex Codex (use --subscription or --gateway)
180
+ roo-code Roo Code (shows config values)
181
+ cline Cline (shows config values)
182
+ kilo-code Kilo Code (shows config values)
183
+ custom-access Direct API access (shows config values)
184
+
185
+ For multi-tool setup, use explicit mode names:
186
+ claude-code-subscription Claude Code with your own subscription (hooks)
187
+ claude-code-gateway Claude Code with Unbound as AI provider
188
+ codex-subscription Codex with your own subscription (hooks)
189
+ codex-gateway Codex with Unbound as AI provider
208
190
 
209
191
  Examples:
210
- $ unbound setup cursor
211
- $ unbound setup cursor --clear # Remove Unbound configuration
192
+ Single tool:
193
+ $ unbound setup cursor Set up Cursor
194
+ $ unbound setup claude-code --gateway Claude Code gateway mode
195
+ $ unbound setup claude-code --subscription Claude Code hooks only
196
+ $ unbound setup codex --gateway Codex gateway mode
197
+
198
+ One-step login and setup:
199
+ $ unbound setup cursor --api-key <key> Login + set up Cursor
200
+ $ unbound setup cursor claude-code-gateway --api-key <key>
201
+ Login + set up multiple tools
202
+
203
+ Remove configuration:
204
+ $ unbound setup cursor --clear Remove Cursor config
205
+ $ unbound setup claude-code --clear Remove Claude Code config
206
+
207
+ Interactive:
208
+ $ unbound setup Select tools interactively
209
+ $ unbound setup --api-key <key> Login, then select interactively
210
+
211
+ If not logged in and --api-key is not provided, the browser will open
212
+ automatically to authenticate before proceeding.
212
213
  `)
213
- .action(makeAction('cursor/setup.py'));
214
+ .action(async (tools, opts) => {
215
+ try {
216
+ await ensureLoggedIn({ apiKey: opts.apiKey });
217
+ const apiKey = config.getApiKey();
218
+ const frontendUrl = config.getFrontendUrl();
214
219
 
215
- setup
216
- .command('claude-code')
217
- .description(
218
- 'Set up Claude Code to use Unbound. Prompts whether to use your existing ' +
219
- 'Claude subscription or use Unbound as the AI provider.'
220
- )
221
- .option('--subscription', 'Use your existing Claude subscription (hooks only)')
222
- .option('--gateway', 'Use Unbound as the AI provider (gateway mode)')
223
- .option('--clear', 'Remove Unbound configuration for Claude Code')
224
- .addHelpText('after', `
225
- Modes:
226
- Subscription (hooks only):
227
- Keep your existing Claude subscription. Installs Unbound hooks for
228
- policy enforcement (security guardrails, cost limits) without changing
229
- the AI provider. Runs claude-code/hooks/setup.py.
220
+ // No tools specified → interactive multi-select (existing flow)
221
+ if (tools.length === 0) {
222
+ const selected = await output.multiSelect(
223
+ 'Select tools to set up with Unbound:',
224
+ SETUP_TOOLS
225
+ );
230
226
 
231
- Gateway:
232
- Use Unbound as the AI provider. Routes all Claude Code requests through
233
- the Unbound gateway for full policy enforcement and model management.
234
- Runs claude-code/gateway/setup.py.
227
+ if (selected.length === 0) {
228
+ output.warn('No tools selected.');
229
+ return;
230
+ }
235
231
 
236
- If neither --subscription nor --gateway is provided, an interactive
237
- prompt will ask you to choose.
232
+ const selectedTools = SETUP_TOOLS.filter(t => selected.includes(t.value));
233
+ console.log('');
238
234
 
239
- Prerequisites:
240
- - Must be logged in (will auto-open browser to authenticate if not)
241
- - Python 3 and curl must be installed
242
- - Claude Code must be installed
235
+ const ok = await runBatch(selectedTools, (tool) =>
236
+ runScriptPiped(tool.script, `--api-key ${shellEscape(apiKey)}`)
237
+ );
238
+ if (!ok) return;
243
239
 
244
- Examples:
245
- $ unbound setup claude-code # Interactive mode selection
246
- $ unbound setup claude-code --subscription # Hooks only (keep your subscription)
247
- $ unbound setup claude-code --gateway # Use Unbound as AI provider
248
- $ unbound setup claude-code --clear # Remove Unbound configuration
249
- `)
250
- .action(async (opts) => {
251
- try {
252
- await ensureLoggedIn();
253
- const apiKey = config.getApiKey();
254
- const scriptOpts = { clear: opts.clear };
240
+ console.log('');
241
+ output.success('All tools configured');
242
+ return;
243
+ }
255
244
 
245
+ // Validate --subscription and --gateway mutual exclusivity
256
246
  if (opts.subscription && opts.gateway) {
257
247
  output.error('Cannot use both --subscription and --gateway. Choose one.');
258
248
  process.exitCode = 1;
259
249
  return;
260
250
  }
261
251
 
262
- let useSubscription = opts.subscription;
263
- if (!opts.clear && !opts.subscription && !opts.gateway) {
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';
269
- }
252
+ // Deduplicate tool names
253
+ tools = [...new Set(tools)];
270
254
 
271
- if (opts.clear) {
272
- runSetupScript('claude-code/hooks/setup.py', apiKey, scriptOpts);
273
- runSetupScript('claude-code/gateway/setup.py', apiKey, scriptOpts);
274
- } else if (useSubscription) {
275
- runSetupScript('claude-code/hooks/setup.py', apiKey, scriptOpts);
276
- } else {
277
- runSetupScript('claude-code/gateway/setup.py', apiKey, scriptOpts);
255
+ // Validate all tool names
256
+ const allKnown = { ...SETUP_TOOL_MAP, ...MODE_TOOLS, ...INSTRUCTION_TOOLS };
257
+ const invalid = tools.filter(t => !allKnown[t]);
258
+ if (invalid.length > 0) {
259
+ output.error(`Unknown tool(s): ${invalid.join(', ')}`);
260
+ console.error(' Run "unbound setup --help" to see available tools.');
261
+ process.exitCode = 1;
262
+ return;
278
263
  }
279
- } catch (err) {
280
- output.error(err.message);
281
- process.exitCode = 1;
282
- }
283
- });
284
-
285
- setup
286
- .command('gemini-cli')
287
- .description(
288
- 'Set up Gemini CLI to use Unbound. Sets GEMINI_API_KEY and ' +
289
- 'GOOGLE_GEMINI_BASE_URL environment variables.'
290
- )
291
- .option('--clear', 'Remove Unbound configuration for Gemini CLI')
292
- .addHelpText('after', `
293
- What this does:
294
- 1. Sets GEMINI_API_KEY to your Unbound API key in your shell profile
295
- 2. Sets GOOGLE_GEMINI_BASE_URL to the Unbound gateway endpoint
296
- 3. Gemini CLI will route all requests through Unbound
297
-
298
- Prerequisites:
299
- - Must be logged in (will auto-open browser to authenticate if not)
300
- - Python 3 and curl must be installed
301
- - Gemini CLI must be installed
302
-
303
- Examples:
304
- $ unbound setup gemini-cli
305
- $ unbound setup gemini-cli --clear # Remove Unbound configuration
306
- `)
307
- .action(makeAction('gemini-cli/gateway/setup.py'));
308
264
 
309
- setup
310
- .command('codex')
311
- .description(
312
- 'Set up Codex to use Unbound. Sets OPENAI_API_KEY and ' +
313
- 'OPENAI_BASE_URL environment variables.'
314
- )
315
- .option('--clear', 'Remove Unbound configuration for Codex')
316
- .addHelpText('after', `
317
- What this does:
318
- 1. Sets OPENAI_API_KEY to your Unbound API key in your shell profile
319
- 2. Sets OPENAI_BASE_URL to the Unbound gateway endpoint
320
- 3. Codex will route all requests through Unbound
321
-
322
- Prerequisites:
323
- - Must be logged in (will auto-open browser to authenticate if not)
324
- - Python 3 and curl must be installed
325
- - Codex must be installed
326
-
327
- Examples:
328
- $ unbound setup codex
329
- $ unbound setup codex --clear # Remove Unbound configuration
330
- `)
331
- .action(makeAction('codex/gateway/setup.py'));
332
-
333
- // --- Instruction-only tools ---
334
-
335
- setup
336
- .command('roo-code')
337
- .description('Show setup values for Roo Code (VS Code extension). Displays the API provider and API key to configure in Roo Code settings.')
338
- .addHelpText('after', `
339
- Output:
340
- API Provider - Select "unbound" in Roo Code provider settings
341
- API Key - Your Unbound API key to paste into the extension
342
-
343
- To configure:
344
- 1. Open Command Palette (Ctrl/Cmd + Shift + P)
345
- 2. Type "Roo Code: Open Settings"
346
- 3. Select "unbound" as the API provider
347
- 4. Paste the API key shown below
348
- 5. Select your preferred model from the dropdown
349
-
350
- Examples:
351
- $ unbound setup roo-code
352
- `)
353
- .action(async () => {
354
- try {
355
- await ensureLoggedIn();
356
- const apiKey = config.getApiKey();
357
-
358
- output.keyValue([
359
- ['API Provider', 'unbound'],
360
- ['API Key', apiKey],
361
- ]);
362
- } catch (err) {
363
- output.error(err.message);
364
- process.exitCode = 1;
365
- }
366
- });
367
-
368
- setup
369
- .command('cline')
370
- .description('Show setup values for Cline (VS Code extension). Displays the API provider, Base URL, and API key to configure in Cline settings.')
371
- .addHelpText('after', `
372
- Output:
373
- API Provider - Use "OpenAI Compatible" in Cline provider settings
374
- Base URL - The Unbound gateway URL to paste as the Base URL
375
- API Key - Your Unbound API key to paste into the extension
376
-
377
- To configure:
378
- 1. Open Cline extension in VS Code
379
- 2. Select "Bring your own API key"
380
- 3. Set API Provider to "OpenAI Compatible"
381
- 4. Paste the Base URL and API Key shown below
382
- 5. Select a model (e.g., gpt-5.1)
383
-
384
- Examples:
385
- $ unbound setup cline
386
- `)
387
- .action(async () => {
388
- try {
389
- await ensureLoggedIn();
390
- const apiKey = config.getApiKey();
391
- const frontendUrl = config.getFrontendUrl();
392
-
393
- output.keyValue([
394
- ['API Provider', 'OpenAI Compatible'],
395
- ['Base URL', frontendUrl],
396
- ['API Key', apiKey],
397
- ]);
398
- } catch (err) {
399
- output.error(err.message);
400
- process.exitCode = 1;
401
- }
402
- });
403
-
404
- setup
405
- .command('kilo-code')
406
- .description('Show setup values for Kilo Code (VS Code extension or CLI). Displays the API provider and API key to configure in Kilo Code.')
407
- .addHelpText('after', `
408
- Output:
409
- API Provider - Select "Unbound" in Kilo Code provider settings
410
- API Key - Your Unbound API key to paste into the extension or CLI
411
-
412
- To configure (IDE extension):
413
- 1. Open Extensions (Ctrl/Cmd + Shift + X), install Kilo Code
414
- 2. Select "Use your own API key"
415
- 3. Set API Provider to "Unbound"
416
- 4. Paste the API Key shown below
417
- 5. Select a model (e.g., claude-opus-4-5, gpt-5.1)
418
-
419
- To configure (CLI):
420
- 1. Install: npm install -g @kilocode/cli
421
- 2. Run: kilocode
422
- 3. Set API Provider to "Unbound"
423
- 4. Paste the API Key shown below
424
-
425
- Examples:
426
- $ unbound setup kilo-code
427
- `)
428
- .action(async () => {
429
- try {
430
- await ensureLoggedIn();
431
- const apiKey = config.getApiKey();
265
+ // Validate mutual exclusivity
266
+ if (tools.includes('claude-code-subscription') && tools.includes('claude-code-gateway')) {
267
+ output.error('Cannot set up both claude-code-subscription and claude-code-gateway. Choose one.');
268
+ process.exitCode = 1;
269
+ return;
270
+ }
271
+ if (tools.includes('codex-subscription') && tools.includes('codex-gateway')) {
272
+ output.error('Cannot set up both codex-subscription and codex-gateway. Choose one.');
273
+ process.exitCode = 1;
274
+ return;
275
+ }
432
276
 
433
- output.keyValue([
434
- ['API Provider', 'Unbound'],
435
- ['API Key', apiKey],
436
- ]);
437
- } catch (err) {
438
- output.error(err.message);
439
- process.exitCode = 1;
440
- }
441
- });
277
+ // Validate no bare + suffixed name conflicts
278
+ if (tools.includes('claude-code') && (tools.includes('claude-code-subscription') || tools.includes('claude-code-gateway'))) {
279
+ output.error('Cannot combine claude-code with claude-code-subscription or claude-code-gateway.');
280
+ console.error(' Use --subscription or --gateway with claude-code, or use the explicit name directly.');
281
+ process.exitCode = 1;
282
+ return;
283
+ }
284
+ if (tools.includes('codex') && (tools.includes('codex-subscription') || tools.includes('codex-gateway'))) {
285
+ output.error('Cannot combine codex with codex-subscription or codex-gateway.');
286
+ console.error(' Use --subscription or --gateway with codex, or use the explicit name directly.');
287
+ process.exitCode = 1;
288
+ return;
289
+ }
442
290
 
443
- setup
444
- .command('custom-access')
445
- .description('Show API key and base URL for direct API access. Use this to build custom integrations with the Unbound gateway.')
446
- .addHelpText('after', `
447
- Output:
448
- API Key - Your Unbound API key for authentication
449
- Base URL - The Unbound gateway endpoint for API requests
291
+ // Validate --subscription/--gateway only with tools that need them
292
+ if ((opts.subscription || opts.gateway) && !tools.some(t => MODE_TOOLS[t])) {
293
+ output.warn('--subscription and --gateway only apply to claude-code and codex. Ignoring.');
294
+ }
450
295
 
451
- The Base URL is OpenAI-compatible. Use it with any OpenAI SDK or HTTP client:
296
+ // Single tool live output (inherited stdio)
297
+ if (tools.length === 1) {
298
+ const toolName = tools[0];
299
+
300
+ if (SETUP_TOOL_MAP[toolName]) {
301
+ runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear });
302
+ } else if (MODE_TOOLS[toolName]) {
303
+ const mode = MODE_TOOLS[toolName];
304
+ if (opts.clear) {
305
+ // Clear both modes
306
+ runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true });
307
+ runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true });
308
+ } else {
309
+ let useSubscription = opts.subscription;
310
+ if (!opts.subscription && !opts.gateway) {
311
+ const choice = await output.select(mode.prompt, mode.options);
312
+ useSubscription = choice === 'subscription';
313
+ }
314
+ const resolved = useSubscription ? mode.subscription : mode.gateway;
315
+ runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, {});
316
+ }
317
+ } else if (INSTRUCTION_TOOLS[toolName]) {
318
+ output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
319
+ }
320
+ return;
321
+ }
452
322
 
453
- curl -X POST {base_url}/v1/chat/completions \\
454
- -H "Authorization: Bearer {api_key}" \\
455
- -H "Content-Type: application/json" \\
456
- -d '{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Hello"}]}'
323
+ // Multi-tool resolve all tools, then batch with spinners
324
+ const resolvedScripts = []; // { label, script } for automated tools
325
+ const instructionResults = []; // toolName for instruction-only
326
+
327
+ for (const toolName of tools) {
328
+ if (SETUP_TOOL_MAP[toolName]) {
329
+ resolvedScripts.push({ ...SETUP_TOOL_MAP[toolName], name: toolName });
330
+ } else if (MODE_TOOLS[toolName]) {
331
+ const mode = MODE_TOOLS[toolName];
332
+ if (opts.clear) {
333
+ // Expand to both scripts for clearing
334
+ resolvedScripts.push({ ...SETUP_TOOL_MAP[mode.subscription], name: mode.subscription });
335
+ resolvedScripts.push({ ...SETUP_TOOL_MAP[mode.gateway], name: mode.gateway });
336
+ } else {
337
+ let useSubscription = opts.subscription;
338
+ if (!opts.subscription && !opts.gateway) {
339
+ const choice = await output.select(mode.prompt, mode.options);
340
+ useSubscription = choice === 'subscription';
341
+ }
342
+ const resolved = useSubscription ? mode.subscription : mode.gateway;
343
+ resolvedScripts.push({ ...SETUP_TOOL_MAP[resolved], name: resolved });
344
+ }
345
+ } else if (INSTRUCTION_TOOLS[toolName]) {
346
+ instructionResults.push(toolName);
347
+ }
348
+ }
457
349
 
458
- Or with the Python SDK:
459
- pip install unbound-gateway
460
- from unbound import Unbound
461
- client = Unbound(base_url="{base_url}", api_key="{api_key}")
350
+ // Run automated tools with spinners
351
+ if (resolvedScripts.length > 0) {
352
+ console.log('');
353
+ const args = `--api-key ${shellEscape(apiKey)}${opts.clear ? ' --clear' : ''}`;
354
+ const ok = await runBatch(resolvedScripts, (tool) =>
355
+ runScriptPiped(tool.script, args)
356
+ , { clear: opts.clear });
357
+ if (!ok) return;
358
+ }
462
359
 
463
- Examples:
464
- $ unbound setup custom-access
465
- `)
466
- .action(async () => {
467
- try {
468
- await ensureLoggedIn();
469
- const apiKey = config.getApiKey();
470
- const frontendUrl = config.getFrontendUrl();
360
+ // Display instruction-only tool values
361
+ for (const toolName of instructionResults) {
362
+ console.log('');
363
+ output.info(`${INSTRUCTION_TOOLS[toolName].label}:`);
364
+ output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
365
+ }
471
366
 
472
- output.keyValue([
473
- ['API Key', apiKey],
474
- ['Base URL', frontendUrl],
475
- ]);
367
+ if (resolvedScripts.length > 0) {
368
+ console.log('');
369
+ output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
370
+ }
476
371
  } catch (err) {
477
- output.error(err.message);
372
+ if (err.message === 'Selection cancelled') return;
373
+ if (!err.displayed) output.error(err.message);
478
374
  process.exitCode = 1;
479
375
  }
480
376
  });
@@ -500,16 +396,18 @@ Available tools:
500
396
  claude-code-subscription Claude Code with your own subscription (hooks only)
501
397
  claude-code-gateway Claude Code with Unbound as AI provider
502
398
  gemini-cli Gemini CLI
503
- codex Codex CLI
399
+ codex-subscription Codex with your own subscription (hooks only)
400
+ codex-gateway Codex with Unbound as AI provider
504
401
 
505
402
  Note: claude-code-subscription and claude-code-gateway are mutually exclusive.
506
- When using --all, claude-code-subscription is used by default.
403
+ codex-subscription and codex-gateway are mutually exclusive.
404
+ When using --all, subscription mode is used by default for Claude Code and Codex.
507
405
 
508
406
  Examples:
509
407
  $ sudo unbound setup mdm --admin-api-key KEY cursor
510
- $ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex
408
+ $ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex-subscription
511
409
  $ sudo unbound setup mdm --admin-api-key KEY --all
512
- $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex
410
+ $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
513
411
  `)
514
412
  .action(async (tools, opts) => {
515
413
  try {
@@ -547,12 +445,18 @@ Examples:
547
445
  return;
548
446
  }
549
447
 
448
+ if (toolNames.includes('codex-subscription') && toolNames.includes('codex-gateway')) {
449
+ output.error('Cannot use both codex-subscription and codex-gateway. Choose one.');
450
+ process.exitCode = 1;
451
+ return;
452
+ }
453
+
550
454
  const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
551
455
  console.log('');
552
456
 
553
457
  const mdmArgs = (tool) => {
554
- let args = `--api_key "${opts.adminApiKey}"`;
555
- if (opts.url) args += ` --url "${opts.url}"`;
458
+ let args = `--api_key ${shellEscape(opts.adminApiKey)}`;
459
+ if (opts.url) args += ` --url ${shellEscape(opts.url)}`;
556
460
  if (opts.clear) args += ' --clear';
557
461
  return args;
558
462
  };
@@ -11,6 +11,7 @@ const SUPPORTED_TOOL_TYPES = [
11
11
  'CLINE',
12
12
  'GEMINI_CLI',
13
13
  'CODEX',
14
+ 'UNBOUND_CODEX',
14
15
  'KILO_CODE',
15
16
  'CUSTOM_ACCESS',
16
17
  ];
package/src/index.js CHANGED
@@ -30,7 +30,15 @@ TOOL SETUP
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)
36
+
37
+ One-step login and setup (--api-key):
38
+ $ unbound setup cursor --api-key <key> Login and set up Cursor
39
+ $ unbound setup cursor claude-code-gateway --api-key <key>
40
+ Login and set up multiple tools
41
+ $ unbound setup --api-key <key> Login, then select interactively
34
42
 
35
43
  Instruction-only (shows config values to set manually):
36
44
  $ unbound setup roo-code Show Roo Code config values
@@ -46,9 +54,9 @@ TOOL SETUP
46
54
 
47
55
  MDM SETUP (admin, requires root)
48
56
  $ 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
57
+ $ sudo unbound setup mdm --admin-api-key KEY cursor codex-subscription
58
+ $ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription codex-subscription gemini-cli
59
+ $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
52
60
 
53
61
  MDM AI TOOLS DISCOVERY
54
62
  --domain defaults to https://backend.getunbound.ai