unbound-cli 0.1.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.
@@ -0,0 +1,341 @@
1
+ const { execSync } = require('child_process');
2
+ const readline = require('readline');
3
+ const config = require('../config');
4
+ const output = require('../output');
5
+ const { ensureLoggedIn } = require('../auth');
6
+
7
+ const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main';
8
+
9
+ /**
10
+ * Runs a Python setup script from the setup repo, passing the stored API key.
11
+ */
12
+ function runSetupScript(scriptPath, apiKey) {
13
+ const url = `${SETUP_BASE_URL}/${scriptPath}`;
14
+ const cmd = `curl -fsSL "${url}" | python3 - --api-key "${apiKey}"`;
15
+
16
+ console.log('');
17
+ execSync(cmd, { stdio: 'inherit' });
18
+ }
19
+
20
+ /**
21
+ * Ensures login, gets the stored API key, and runs the setup script(s).
22
+ */
23
+ function makeAction(...scriptPaths) {
24
+ return async () => {
25
+ try {
26
+ await ensureLoggedIn();
27
+ const apiKey = config.getApiKey();
28
+
29
+ for (const scriptPath of scriptPaths) {
30
+ runSetupScript(scriptPath, apiKey);
31
+ }
32
+ } catch (err) {
33
+ output.error(err.message);
34
+ process.exitCode = 1;
35
+ }
36
+ };
37
+ }
38
+
39
+ function register(program) {
40
+ const setup = program
41
+ .command('setup')
42
+ .description(
43
+ 'Configure AI coding tools to use Unbound as their API gateway. ' +
44
+ 'Supported tools: cursor, claude-code, gemini-cli, codex, roo-code, cline, kilo-code, custom-access.'
45
+ )
46
+ .addHelpText('after', `
47
+ Automated setup (downloads scripts, sets env vars, configures tool):
48
+ $ unbound setup cursor # Download hooks, set env, restart Cursor
49
+ $ unbound setup claude-code # Set up gateway + hooks for Claude Code
50
+ $ unbound setup gemini-cli # Set GEMINI_API_KEY and base URL
51
+ $ unbound setup codex # Set OPENAI_API_KEY and base URL
52
+
53
+ Instruction-only (shows API key and base URL to configure manually):
54
+ $ unbound setup roo-code # Show Roo Code config values
55
+ $ unbound setup cline # Show Cline config values
56
+ $ unbound setup kilo-code # Show Kilo Code config values
57
+ $ unbound setup custom-access # Show API key and base URL for direct API access
58
+
59
+ All setup commands require login. If not logged in, the browser will
60
+ open automatically to authenticate before proceeding.
61
+ `);
62
+
63
+ setup
64
+ .command('cursor')
65
+ .description(
66
+ 'Set up Cursor to use Unbound. Downloads hook scripts, sets the ' +
67
+ 'UNBOUND_CURSOR_API_KEY environment variable, and restarts Cursor.'
68
+ )
69
+ .addHelpText('after', `
70
+ What this does:
71
+ 1. Downloads Cursor hook scripts from the Unbound setup repository
72
+ 2. Sets the UNBOUND_CURSOR_API_KEY environment variable in your shell profile
73
+ 3. Restarts Cursor to apply changes
74
+
75
+ Prerequisites:
76
+ - Must be logged in (will auto-open browser to authenticate if not)
77
+ - Python 3 and curl must be installed
78
+ - Cursor must be installed
79
+
80
+ Examples:
81
+ $ unbound setup cursor
82
+ `)
83
+ .action(makeAction('cursor/setup.py'));
84
+
85
+ setup
86
+ .command('claude-code')
87
+ .description(
88
+ 'Set up Claude Code to use Unbound. Prompts whether to use your existing ' +
89
+ 'Claude subscription or use Unbound as the AI provider.'
90
+ )
91
+ .option('--subscription', 'Use your existing Claude subscription (hooks only)')
92
+ .option('--gateway', 'Use Unbound as the AI provider (gateway mode)')
93
+ .addHelpText('after', `
94
+ Modes:
95
+ Subscription (hooks only):
96
+ Keep your existing Claude subscription. Installs Unbound hooks for
97
+ policy enforcement (security guardrails, cost limits) without changing
98
+ the AI provider. Runs claude-code/hooks/setup.py.
99
+
100
+ Gateway:
101
+ Use Unbound as the AI provider. Routes all Claude Code requests through
102
+ the Unbound gateway for full policy enforcement and model management.
103
+ Runs claude-code/gateway/setup.py.
104
+
105
+ If neither --subscription nor --gateway is provided, an interactive
106
+ prompt will ask you to choose.
107
+
108
+ Prerequisites:
109
+ - Must be logged in (will auto-open browser to authenticate if not)
110
+ - Python 3 and curl must be installed
111
+ - Claude Code must be installed
112
+
113
+ Examples:
114
+ $ unbound setup claude-code # Interactive mode selection
115
+ $ unbound setup claude-code --subscription # Hooks only (keep your subscription)
116
+ $ unbound setup claude-code --gateway # Use Unbound as AI provider
117
+ `)
118
+ .action(async (opts) => {
119
+ try {
120
+ await ensureLoggedIn();
121
+ const apiKey = config.getApiKey();
122
+
123
+ let useSubscription = opts.subscription;
124
+ if (!opts.subscription && !opts.gateway) {
125
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
126
+ const answer = await new Promise((resolve) => {
127
+ console.log('\nHow do you want to use Claude Code with Unbound?\n');
128
+ console.log(' 1. I have my own Claude subscription (hooks only - policy enforcement)');
129
+ console.log(' 2. Use Unbound as the AI provider (gateway mode)\n');
130
+ rl.question('Choose (1 or 2): ', resolve);
131
+ });
132
+ rl.close();
133
+ useSubscription = answer.trim() === '1';
134
+ }
135
+
136
+ if (useSubscription) {
137
+ runSetupScript('claude-code/hooks/setup.py', apiKey);
138
+ } else {
139
+ runSetupScript('claude-code/gateway/setup.py', apiKey);
140
+ }
141
+ } catch (err) {
142
+ output.error(err.message);
143
+ process.exitCode = 1;
144
+ }
145
+ });
146
+
147
+ setup
148
+ .command('gemini-cli')
149
+ .description(
150
+ 'Set up Gemini CLI to use Unbound. Sets GEMINI_API_KEY and ' +
151
+ 'GOOGLE_GEMINI_BASE_URL environment variables.'
152
+ )
153
+ .addHelpText('after', `
154
+ What this does:
155
+ 1. Sets GEMINI_API_KEY to your Unbound API key in your shell profile
156
+ 2. Sets GOOGLE_GEMINI_BASE_URL to the Unbound gateway endpoint
157
+ 3. Gemini CLI will route all requests through Unbound
158
+
159
+ Prerequisites:
160
+ - Must be logged in (will auto-open browser to authenticate if not)
161
+ - Python 3 and curl must be installed
162
+ - Gemini CLI must be installed
163
+
164
+ Examples:
165
+ $ unbound setup gemini-cli
166
+ `)
167
+ .action(makeAction('gemini-cli/gateway/setup.py'));
168
+
169
+ setup
170
+ .command('codex')
171
+ .description(
172
+ 'Set up Codex to use Unbound. Sets OPENAI_API_KEY and ' +
173
+ 'OPENAI_BASE_URL environment variables.'
174
+ )
175
+ .addHelpText('after', `
176
+ What this does:
177
+ 1. Sets OPENAI_API_KEY to your Unbound API key in your shell profile
178
+ 2. Sets OPENAI_BASE_URL to the Unbound gateway endpoint
179
+ 3. Codex will route all requests through Unbound
180
+
181
+ Prerequisites:
182
+ - Must be logged in (will auto-open browser to authenticate if not)
183
+ - Python 3 and curl must be installed
184
+ - Codex must be installed
185
+
186
+ Examples:
187
+ $ unbound setup codex
188
+ `)
189
+ .action(makeAction('codex/gateway/setup.py'));
190
+
191
+ // --- Instruction-only tools ---
192
+
193
+ setup
194
+ .command('roo-code')
195
+ .description('Show setup values for Roo Code (VS Code extension). Displays the API provider and API key to configure in Roo Code settings.')
196
+ .addHelpText('after', `
197
+ Output:
198
+ API Provider - Select "unbound" in Roo Code provider settings
199
+ API Key - Your Unbound API key to paste into the extension
200
+
201
+ To configure:
202
+ 1. Open Command Palette (Ctrl/Cmd + Shift + P)
203
+ 2. Type "Roo Code: Open Settings"
204
+ 3. Select "unbound" as the API provider
205
+ 4. Paste the API key shown below
206
+ 5. Select your preferred model from the dropdown
207
+
208
+ Examples:
209
+ $ unbound setup roo-code
210
+ `)
211
+ .action(async () => {
212
+ try {
213
+ await ensureLoggedIn();
214
+ const apiKey = config.getApiKey();
215
+
216
+ output.keyValue([
217
+ ['API Provider', 'unbound'],
218
+ ['API Key', apiKey],
219
+ ]);
220
+ } catch (err) {
221
+ output.error(err.message);
222
+ process.exitCode = 1;
223
+ }
224
+ });
225
+
226
+ setup
227
+ .command('cline')
228
+ .description('Show setup values for Cline (VS Code extension). Displays the API provider, Base URL, and API key to configure in Cline settings.')
229
+ .addHelpText('after', `
230
+ Output:
231
+ API Provider - Use "OpenAI Compatible" in Cline provider settings
232
+ Base URL - The Unbound gateway URL to paste as the Base URL
233
+ API Key - Your Unbound API key to paste into the extension
234
+
235
+ To configure:
236
+ 1. Open Cline extension in VS Code
237
+ 2. Select "Bring your own API key"
238
+ 3. Set API Provider to "OpenAI Compatible"
239
+ 4. Paste the Base URL and API Key shown below
240
+ 5. Select a model (e.g., gpt-5.1)
241
+
242
+ Examples:
243
+ $ unbound setup cline
244
+ `)
245
+ .action(async () => {
246
+ try {
247
+ await ensureLoggedIn();
248
+ const apiKey = config.getApiKey();
249
+ const frontendUrl = config.getFrontendUrl();
250
+
251
+ output.keyValue([
252
+ ['API Provider', 'OpenAI Compatible'],
253
+ ['Base URL', frontendUrl],
254
+ ['API Key', apiKey],
255
+ ]);
256
+ } catch (err) {
257
+ output.error(err.message);
258
+ process.exitCode = 1;
259
+ }
260
+ });
261
+
262
+ setup
263
+ .command('kilo-code')
264
+ .description('Show setup values for Kilo Code (VS Code extension or CLI). Displays the API provider and API key to configure in Kilo Code.')
265
+ .addHelpText('after', `
266
+ Output:
267
+ API Provider - Select "Unbound" in Kilo Code provider settings
268
+ API Key - Your Unbound API key to paste into the extension or CLI
269
+
270
+ To configure (IDE extension):
271
+ 1. Open Extensions (Ctrl/Cmd + Shift + X), install Kilo Code
272
+ 2. Select "Use your own API key"
273
+ 3. Set API Provider to "Unbound"
274
+ 4. Paste the API Key shown below
275
+ 5. Select a model (e.g., claude-opus-4-5, gpt-5.1)
276
+
277
+ To configure (CLI):
278
+ 1. Install: npm install -g @kilocode/cli
279
+ 2. Run: kilocode
280
+ 3. Set API Provider to "Unbound"
281
+ 4. Paste the API Key shown below
282
+
283
+ Examples:
284
+ $ unbound setup kilo-code
285
+ `)
286
+ .action(async () => {
287
+ try {
288
+ await ensureLoggedIn();
289
+ const apiKey = config.getApiKey();
290
+
291
+ output.keyValue([
292
+ ['API Provider', 'Unbound'],
293
+ ['API Key', apiKey],
294
+ ]);
295
+ } catch (err) {
296
+ output.error(err.message);
297
+ process.exitCode = 1;
298
+ }
299
+ });
300
+
301
+ setup
302
+ .command('custom-access')
303
+ .description('Show API key and base URL for direct API access. Use this to build custom integrations with the Unbound gateway.')
304
+ .addHelpText('after', `
305
+ Output:
306
+ API Key - Your Unbound API key for authentication
307
+ Base URL - The Unbound gateway endpoint for API requests
308
+
309
+ The Base URL is OpenAI-compatible. Use it with any OpenAI SDK or HTTP client:
310
+
311
+ curl -X POST {base_url}/v1/chat/completions \\
312
+ -H "Authorization: Bearer {api_key}" \\
313
+ -H "Content-Type: application/json" \\
314
+ -d '{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Hello"}]}'
315
+
316
+ Or with the Python SDK:
317
+ pip install unbound-gateway
318
+ from unbound import Unbound
319
+ client = Unbound(base_url="{base_url}", api_key="{api_key}")
320
+
321
+ Examples:
322
+ $ unbound setup custom-access
323
+ `)
324
+ .action(async () => {
325
+ try {
326
+ await ensureLoggedIn();
327
+ const apiKey = config.getApiKey();
328
+ const frontendUrl = config.getFrontendUrl();
329
+
330
+ output.keyValue([
331
+ ['API Key', apiKey],
332
+ ['Base URL', frontendUrl],
333
+ ]);
334
+ } catch (err) {
335
+ output.error(err.message);
336
+ process.exitCode = 1;
337
+ }
338
+ });
339
+ }
340
+
341
+ module.exports = { register };
@@ -0,0 +1,59 @@
1
+ const config = require('../config');
2
+ const api = require('../api');
3
+ const output = require('../output');
4
+
5
+ function register(program) {
6
+ program
7
+ .command('status')
8
+ .description('Show the current CLI status including config location, login state, and API connectivity. Useful for debugging connection issues.')
9
+ .addHelpText('after', `
10
+ Output fields:
11
+ Config file - Path to the config file (~/.unbound/config.json)
12
+ Logged in - Whether credentials are stored (Yes/No)
13
+ Email - The authenticated user's email (if logged in)
14
+ Organization - The organization name (if logged in)
15
+ API base URL - The backend API URL being used (if logged in)
16
+ Frontend URL - The frontend URL for browser auth (if logged in)
17
+ API status - Connectivity check result (Connected / Error)
18
+
19
+ Examples:
20
+ $ unbound status
21
+ `)
22
+ .action(async () => {
23
+ try {
24
+ const loggedIn = config.isLoggedIn();
25
+ const cfg = config.readConfig();
26
+
27
+ const pairs = [
28
+ ['Config file', config.CONFIG_FILE],
29
+ ['Logged in', loggedIn ? 'Yes' : 'No'],
30
+ ];
31
+
32
+ if (loggedIn) {
33
+ pairs.push(['Email', cfg.email || '-']);
34
+ pairs.push(['Organization', cfg.org_name || '-']);
35
+ pairs.push(['API base URL', config.getBaseUrl()]);
36
+ pairs.push(['Frontend URL', config.getFrontendUrl()]);
37
+ }
38
+
39
+ // Check API connectivity
40
+ let connectivity = 'Not checked (not logged in)';
41
+ if (loggedIn) {
42
+ try {
43
+ await api.get('/api/v1/users/privileges/');
44
+ connectivity = 'Connected';
45
+ } catch (err) {
46
+ connectivity = `Error: ${err.message}`;
47
+ }
48
+ }
49
+ pairs.push(['API status', connectivity]);
50
+
51
+ output.keyValue(pairs);
52
+ } catch (err) {
53
+ output.error(err.message);
54
+ process.exitCode = 1;
55
+ }
56
+ });
57
+ }
58
+
59
+ module.exports = { register };
@@ -0,0 +1,176 @@
1
+ const config = require('../config');
2
+ const api = require('../api');
3
+ const output = require('../output');
4
+
5
+ function formatDate(dateStr) {
6
+ if (!dateStr) return '-';
7
+ return new Date(dateStr).toLocaleDateString();
8
+ }
9
+
10
+ const SUPPORTED_TOOL_TYPES = [
11
+ 'CLAUDE_CODE',
12
+ 'CURSOR',
13
+ 'COPILOT',
14
+ 'ROO_CODE',
15
+ 'CLINE',
16
+ 'GEMINI_CLI',
17
+ 'CODEX',
18
+ 'KILO_CODE',
19
+ 'CUSTOM_ACCESS',
20
+ ];
21
+
22
+ function register(program) {
23
+ const tools = program
24
+ .command('tools')
25
+ .description(
26
+ 'Manage connected AI coding tools. List active tool connections, ' +
27
+ 'connect new tools, and view approved tool types. ' +
28
+ `Supported types: ${SUPPORTED_TOOL_TYPES.join(', ')}.`
29
+ );
30
+
31
+ // tools list
32
+ tools
33
+ .command('list')
34
+ .description('List all connected tools across the organization, showing their status and usage.')
35
+ .option('--json', 'Output raw JSON instead of a table')
36
+ .addHelpText('after', `
37
+ Output columns:
38
+ Tool Type - The type of AI tool (e.g. CURSOR, CLAUDE_CODE)
39
+ User Email - The user who connected the tool
40
+ Status - Connection status
41
+ Monthly Cost - Cost incurred this month
42
+ Connected At - Date the tool was connected
43
+
44
+ Examples:
45
+ $ unbound tools list
46
+ $ unbound tools list --json
47
+ `)
48
+ .action(async (opts) => {
49
+ try {
50
+ if (!config.isLoggedIn()) {
51
+ output.error('Not logged in. Run `unbound login` first.');
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+
56
+ const data = await api.get('/api/v1/agents/');
57
+
58
+ if (opts.json) {
59
+ output.json(data);
60
+ return;
61
+ }
62
+
63
+ output.table(data.agents, [
64
+ { key: 'tool_type', header: 'Tool Type' },
65
+ { key: 'user', header: 'User Email', format: (v) => (v ? v.email : '-') },
66
+ { key: 'status', header: 'Status' },
67
+ { key: 'monthly_cost', header: 'Monthly Cost', format: (v) => (v != null ? String(v) : '-') },
68
+ { key: 'connected_at', header: 'Connected At', format: (v) => formatDate(v) },
69
+ ]);
70
+ } catch (err) {
71
+ output.error(err.message);
72
+ process.exitCode = 1;
73
+ }
74
+ });
75
+
76
+ // tools connect
77
+ tools
78
+ .command('connect <tool-type>')
79
+ .description(`Connect a new AI coding tool. Generates an API key and connection details for the specified tool type. Supported types: ${SUPPORTED_TOOL_TYPES.join(', ')}.`)
80
+ .addHelpText('after', `
81
+ Supported tool types:
82
+ ${SUPPORTED_TOOL_TYPES.join(', ')}
83
+
84
+ Output fields:
85
+ Tool Type - The connected tool type
86
+ Status - Connection status
87
+ API Key - Generated API key for the tool
88
+ Application ID - Application identifier
89
+ Application Name - Application display name
90
+
91
+ Examples:
92
+ $ unbound tools connect CLAUDE_CODE
93
+ $ unbound tools connect CURSOR
94
+ $ unbound tools connect GEMINI_CLI
95
+ `)
96
+ .action(async (toolType) => {
97
+ try {
98
+ if (!config.isLoggedIn()) {
99
+ output.error('Not logged in. Run `unbound login` first.');
100
+ process.exitCode = 1;
101
+ return;
102
+ }
103
+
104
+ const normalized = toolType.toUpperCase();
105
+ if (!SUPPORTED_TOOL_TYPES.includes(normalized)) {
106
+ output.error(`Unsupported tool type: ${toolType}. Supported types: ${SUPPORTED_TOOL_TYPES.join(', ')}`);
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+
111
+ const data = await api.post('/api/v1/agents/connect/', { body: { tool_type: normalized } });
112
+
113
+ output.success(`Connected ${normalized}.`);
114
+ output.keyValue([
115
+ ['Tool Type', data.tool_type],
116
+ ['Status', data.status],
117
+ ['API Key', data.api_key || '-'],
118
+ ['Application ID', data.application_id || '-'],
119
+ ['Application Name', data.application_name || '-'],
120
+ ]);
121
+ } catch (err) {
122
+ output.error(err.message);
123
+ process.exitCode = 1;
124
+ }
125
+ });
126
+
127
+ // tools approved
128
+ tools
129
+ .command('approved')
130
+ .description('List approved tool types for the organization and whether tool restrictions are enforced.')
131
+ .option('--json', 'Output raw JSON')
132
+ .addHelpText('after', `
133
+ Output:
134
+ Restriction Enabled - Whether the organization restricts which tools can connect
135
+ Approved Tool Types - List of tool types allowed to connect
136
+
137
+ Examples:
138
+ $ unbound tools approved
139
+ $ unbound tools approved --json
140
+ `)
141
+ .action(async (opts) => {
142
+ try {
143
+ if (!config.isLoggedIn()) {
144
+ output.error('Not logged in. Run `unbound login` first.');
145
+ process.exitCode = 1;
146
+ return;
147
+ }
148
+
149
+ const data = await api.get('/api/v1/organizations/approved-tools/');
150
+
151
+ if (opts.json) {
152
+ output.json(data);
153
+ return;
154
+ }
155
+
156
+ if (data.restriction_enabled !== undefined) {
157
+ console.log(`Restriction Enabled: ${data.restriction_enabled}`);
158
+ console.log('');
159
+ }
160
+
161
+ if (data.approved_tools && data.approved_tools.length > 0) {
162
+ console.log('Approved Tool Types:');
163
+ for (const tool of data.approved_tools) {
164
+ console.log(` ${typeof tool === 'string' ? tool : tool.tool_type || tool.name || JSON.stringify(tool)}`);
165
+ }
166
+ } else {
167
+ console.log('No approved tools configured.');
168
+ }
169
+ } catch (err) {
170
+ output.error(err.message);
171
+ process.exitCode = 1;
172
+ }
173
+ });
174
+ }
175
+
176
+ module.exports = { register };