unbound-cli 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,7 @@ unbound setup
30
30
  unbound setup cursor
31
31
  ```
32
32
 
33
+
33
34
  ## Commands
34
35
 
35
36
  ### Authentication
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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;
@@ -27,6 +27,54 @@ const MDM_TOOLS = {
27
27
  // Default tools for --all (uses subscription mode for Claude Code and Codex since only one can be active)
28
28
  const MDM_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription'];
29
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
+ }
77
+
30
78
  /**
31
79
  * Builds a shell command that curls a setup script and pipes it to python3.
32
80
  */
@@ -39,7 +87,7 @@ function buildSetupCommand(scriptPath, args) {
39
87
  * Runs a Python setup script from the setup repo with inherited stdio (live output).
40
88
  */
41
89
  function runSetupScript(scriptPath, apiKey, { clear = false } = {}) {
42
- const args = `--api-key "${apiKey}"${clear ? ' --clear' : ''}`;
90
+ const args = `--api-key ${shellEscape(apiKey)}${clear ? ' --clear' : ''}`;
43
91
  console.log('');
44
92
  try {
45
93
  execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
@@ -111,418 +159,218 @@ async function runBatch(tools, runFn, { clear = false } = {}) {
111
159
  return true;
112
160
  }
113
161
 
114
- /**
115
- * Ensures login, gets the stored API key, and runs the setup script(s).
116
- */
117
- function makeAction(...scriptPaths) {
118
- return async (opts) => {
119
- try {
120
- await ensureLoggedIn();
121
- const apiKey = config.getApiKey();
122
-
123
- for (const scriptPath of scriptPaths) {
124
- runSetupScript(scriptPath, apiKey, { clear: opts.clear });
125
- }
126
- } catch (err) {
127
- output.error(err.message);
128
- process.exitCode = 1;
129
- }
130
- };
131
- }
132
-
133
162
  function register(program) {
134
163
  const setup = program
135
164
  .command('setup')
165
+ .argument('[tools...]', 'Tools to set up')
136
166
  .description(
137
167
  'Configure AI coding tools to use Unbound as their API gateway. ' +
138
- '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.'
139
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)')
140
174
  .addHelpText('after', `
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):
145
- $ unbound setup cursor # Download hooks, set env, restart Cursor
146
- $ unbound setup claude-code # Set up gateway + hooks for Claude Code
147
- $ unbound setup gemini-cli # Set GEMINI_API_KEY and base URL
148
- $ unbound setup codex # Set up gateway + hooks for Codex
149
-
150
- Instruction-only (shows API key and base URL to configure manually):
151
- $ unbound setup roo-code # Show Roo Code config values
152
- $ unbound setup cline # Show Cline config values
153
- $ unbound setup kilo-code # Show Kilo Code config values
154
- $ unbound setup custom-access # Show API key and base URL for direct API access
155
-
156
- All setup commands require login. If not logged in, the browser will
157
- open automatically to authenticate before proceeding.
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
- });
192
-
193
- setup
194
- .command('cursor')
195
- .description(
196
- 'Set up Cursor to use Unbound. Downloads hook scripts, sets the ' +
197
- 'UNBOUND_CURSOR_API_KEY environment variable, and restarts Cursor.'
198
- )
199
- .option('--clear', 'Remove Unbound configuration for Cursor')
200
- .addHelpText('after', `
201
- What this does:
202
- 1. Downloads Cursor hook scripts from the Unbound setup repository
203
- 2. Sets the UNBOUND_CURSOR_API_KEY environment variable in your shell profile
204
- 3. Restarts Cursor to apply changes
205
-
206
- Prerequisites:
207
- - Must be logged in (will auto-open browser to authenticate if not)
208
- - Python 3 and curl must be installed
209
- - 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
210
190
 
211
191
  Examples:
212
- $ unbound setup cursor
213
- $ 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.
214
213
  `)
215
- .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();
216
219
 
217
- setup
218
- .command('claude-code')
219
- .description(
220
- 'Set up Claude Code to use Unbound. Prompts whether to use your existing ' +
221
- 'Claude subscription or use Unbound as the AI provider.'
222
- )
223
- .option('--subscription', 'Use your existing Claude subscription (hooks only)')
224
- .option('--gateway', 'Use Unbound as the AI provider (gateway mode)')
225
- .option('--clear', 'Remove Unbound configuration for Claude Code')
226
- .addHelpText('after', `
227
- Modes:
228
- Subscription (hooks only):
229
- Keep your existing Claude subscription. Installs Unbound hooks for
230
- policy enforcement (security guardrails, cost limits) without changing
231
- 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
+ );
232
226
 
233
- Gateway:
234
- Use Unbound as the AI provider. Routes all Claude Code requests through
235
- the Unbound gateway for full policy enforcement and model management.
236
- Runs claude-code/gateway/setup.py.
227
+ if (selected.length === 0) {
228
+ output.warn('No tools selected.');
229
+ return;
230
+ }
237
231
 
238
- If neither --subscription nor --gateway is provided, an interactive
239
- prompt will ask you to choose.
232
+ const selectedTools = SETUP_TOOLS.filter(t => selected.includes(t.value));
233
+ console.log('');
240
234
 
241
- Prerequisites:
242
- - Must be logged in (will auto-open browser to authenticate if not)
243
- - Python 3 and curl must be installed
244
- - 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;
245
239
 
246
- Examples:
247
- $ unbound setup claude-code # Interactive mode selection
248
- $ unbound setup claude-code --subscription # Hooks only (keep your subscription)
249
- $ unbound setup claude-code --gateway # Use Unbound as AI provider
250
- $ unbound setup claude-code --clear # Remove Unbound configuration
251
- `)
252
- .action(async (opts) => {
253
- try {
254
- await ensureLoggedIn();
255
- const apiKey = config.getApiKey();
256
- const scriptOpts = { clear: opts.clear };
240
+ console.log('');
241
+ output.success('All tools configured');
242
+ return;
243
+ }
257
244
 
245
+ // Validate --subscription and --gateway mutual exclusivity
258
246
  if (opts.subscription && opts.gateway) {
259
247
  output.error('Cannot use both --subscription and --gateway. Choose one.');
260
248
  process.exitCode = 1;
261
249
  return;
262
250
  }
263
251
 
264
- let useSubscription = opts.subscription;
265
- if (!opts.clear && !opts.subscription && !opts.gateway) {
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';
271
- }
252
+ // Deduplicate tool names
253
+ tools = [...new Set(tools)];
272
254
 
273
- if (opts.clear) {
274
- runSetupScript('claude-code/hooks/setup.py', apiKey, scriptOpts);
275
- runSetupScript('claude-code/gateway/setup.py', apiKey, scriptOpts);
276
- } else if (useSubscription) {
277
- runSetupScript('claude-code/hooks/setup.py', apiKey, scriptOpts);
278
- } else {
279
- 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;
280
263
  }
281
- } catch (err) {
282
- output.error(err.message);
283
- process.exitCode = 1;
284
- }
285
- });
286
264
 
287
- setup
288
- .command('gemini-cli')
289
- .description(
290
- 'Set up Gemini CLI to use Unbound. Sets GEMINI_API_KEY and ' +
291
- 'GOOGLE_GEMINI_BASE_URL environment variables.'
292
- )
293
- .option('--clear', 'Remove Unbound configuration for Gemini CLI')
294
- .addHelpText('after', `
295
- What this does:
296
- 1. Sets GEMINI_API_KEY to your Unbound API key in your shell profile
297
- 2. Sets GOOGLE_GEMINI_BASE_URL to the Unbound gateway endpoint
298
- 3. Gemini CLI will route all requests through Unbound
299
-
300
- Prerequisites:
301
- - Must be logged in (will auto-open browser to authenticate if not)
302
- - Python 3 and curl must be installed
303
- - Gemini CLI must be installed
304
-
305
- Examples:
306
- $ unbound setup gemini-cli
307
- $ unbound setup gemini-cli --clear # Remove Unbound configuration
308
- `)
309
- .action(makeAction('gemini-cli/gateway/setup.py'));
310
-
311
- setup
312
- .command('codex')
313
- .description(
314
- 'Set up Codex to use Unbound. Prompts whether to use your existing ' +
315
- 'OpenAI subscription or use Unbound as the AI provider.'
316
- )
317
- .option('--subscription', 'Use your existing OpenAI subscription (hooks only)')
318
- .option('--gateway', 'Use Unbound as the AI provider (gateway mode)')
319
- .option('--clear', 'Remove Unbound configuration for Codex')
320
- .addHelpText('after', `
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.
334
-
335
- Prerequisites:
336
- - Must be logged in (will auto-open browser to authenticate if not)
337
- - Python 3 and curl must be installed
338
- - Codex must be installed
339
-
340
- Examples:
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
345
- `)
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.');
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.');
354
268
  process.exitCode = 1;
355
269
  return;
356
270
  }
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';
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;
365
275
  }
366
276
 
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);
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;
374
289
  }
375
- } catch (err) {
376
- output.error(err.message);
377
- process.exitCode = 1;
378
- }
379
- });
380
-
381
- // --- Instruction-only tools ---
382
-
383
- setup
384
- .command('roo-code')
385
- .description('Show setup values for Roo Code (VS Code extension). Displays the API provider and API key to configure in Roo Code settings.')
386
- .addHelpText('after', `
387
- Output:
388
- API Provider - Select "unbound" in Roo Code provider settings
389
- API Key - Your Unbound API key to paste into the extension
390
-
391
- To configure:
392
- 1. Open Command Palette (Ctrl/Cmd + Shift + P)
393
- 2. Type "Roo Code: Open Settings"
394
- 3. Select "unbound" as the API provider
395
- 4. Paste the API key shown below
396
- 5. Select your preferred model from the dropdown
397
-
398
- Examples:
399
- $ unbound setup roo-code
400
- `)
401
- .action(async () => {
402
- try {
403
- await ensureLoggedIn();
404
- const apiKey = config.getApiKey();
405
-
406
- output.keyValue([
407
- ['API Provider', 'unbound'],
408
- ['API Key', apiKey],
409
- ]);
410
- } catch (err) {
411
- output.error(err.message);
412
- process.exitCode = 1;
413
- }
414
- });
415
-
416
- setup
417
- .command('cline')
418
- .description('Show setup values for Cline (VS Code extension). Displays the API provider, Base URL, and API key to configure in Cline settings.')
419
- .addHelpText('after', `
420
- Output:
421
- API Provider - Use "OpenAI Compatible" in Cline provider settings
422
- Base URL - The Unbound gateway URL to paste as the Base URL
423
- API Key - Your Unbound API key to paste into the extension
424
-
425
- To configure:
426
- 1. Open Cline extension in VS Code
427
- 2. Select "Bring your own API key"
428
- 3. Set API Provider to "OpenAI Compatible"
429
- 4. Paste the Base URL and API Key shown below
430
- 5. Select a model (e.g., gpt-5.1)
431
-
432
- Examples:
433
- $ unbound setup cline
434
- `)
435
- .action(async () => {
436
- try {
437
- await ensureLoggedIn();
438
- const apiKey = config.getApiKey();
439
- const frontendUrl = config.getFrontendUrl();
440
-
441
- output.keyValue([
442
- ['API Provider', 'OpenAI Compatible'],
443
- ['Base URL', frontendUrl],
444
- ['API Key', apiKey],
445
- ]);
446
- } catch (err) {
447
- output.error(err.message);
448
- process.exitCode = 1;
449
- }
450
- });
451
-
452
- setup
453
- .command('kilo-code')
454
- .description('Show setup values for Kilo Code (VS Code extension or CLI). Displays the API provider and API key to configure in Kilo Code.')
455
- .addHelpText('after', `
456
- Output:
457
- API Provider - Select "Unbound" in Kilo Code provider settings
458
- API Key - Your Unbound API key to paste into the extension or CLI
459
-
460
- To configure (IDE extension):
461
- 1. Open Extensions (Ctrl/Cmd + Shift + X), install Kilo Code
462
- 2. Select "Use your own API key"
463
- 3. Set API Provider to "Unbound"
464
- 4. Paste the API Key shown below
465
- 5. Select a model (e.g., claude-opus-4-5, gpt-5.1)
466
-
467
- To configure (CLI):
468
- 1. Install: npm install -g @kilocode/cli
469
- 2. Run: kilocode
470
- 3. Set API Provider to "Unbound"
471
- 4. Paste the API Key shown below
472
-
473
- Examples:
474
- $ unbound setup kilo-code
475
- `)
476
- .action(async () => {
477
- try {
478
- await ensureLoggedIn();
479
- const apiKey = config.getApiKey();
480
-
481
- output.keyValue([
482
- ['API Provider', 'Unbound'],
483
- ['API Key', apiKey],
484
- ]);
485
- } catch (err) {
486
- output.error(err.message);
487
- process.exitCode = 1;
488
- }
489
- });
490
290
 
491
- setup
492
- .command('custom-access')
493
- .description('Show API key and base URL for direct API access. Use this to build custom integrations with the Unbound gateway.')
494
- .addHelpText('after', `
495
- Output:
496
- API Key - Your Unbound API key for authentication
497
- 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
+ }
498
295
 
499
- 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
+ }
500
322
 
501
- curl -X POST {base_url}/v1/chat/completions \\
502
- -H "Authorization: Bearer {api_key}" \\
503
- -H "Content-Type: application/json" \\
504
- -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
+ }
505
349
 
506
- Or with the Python SDK:
507
- pip install unbound-gateway
508
- from unbound import Unbound
509
- 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
+ }
510
359
 
511
- Examples:
512
- $ unbound setup custom-access
513
- `)
514
- .action(async () => {
515
- try {
516
- await ensureLoggedIn();
517
- const apiKey = config.getApiKey();
518
- 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
+ }
519
366
 
520
- output.keyValue([
521
- ['API Key', apiKey],
522
- ['Base URL', frontendUrl],
523
- ]);
367
+ if (resolvedScripts.length > 0) {
368
+ console.log('');
369
+ output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
370
+ }
524
371
  } catch (err) {
525
- output.error(err.message);
372
+ if (err.message === 'Selection cancelled') return;
373
+ if (!err.displayed) output.error(err.message);
526
374
  process.exitCode = 1;
527
375
  }
528
376
  });
@@ -607,8 +455,8 @@ Examples:
607
455
  console.log('');
608
456
 
609
457
  const mdmArgs = (tool) => {
610
- let args = `--api_key "${opts.adminApiKey}"`;
611
- if (opts.url) args += ` --url "${opts.url}"`;
458
+ let args = `--api-key ${shellEscape(opts.adminApiKey)}`;
459
+ if (opts.url) args += ` --url ${shellEscape(opts.url)}`;
612
460
  if (opts.clear) args += ' --clear';
613
461
  return args;
614
462
  };
package/src/index.js CHANGED
@@ -34,6 +34,12 @@ TOOL SETUP
34
34
  $ unbound setup codex --gateway Use Unbound as AI provider
35
35
  $ unbound setup codex --subscription Hooks only (keep your subscription)
36
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
42
+
37
43
  Instruction-only (shows config values to set manually):
38
44
  $ unbound setup roo-code Show Roo Code config values
39
45
  $ unbound setup cline Show Cline config values