unbound-cli 0.1.6 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -3,7 +3,8 @@ const http = require('http');
3
3
  const { URL } = require('url');
4
4
  const config = require('./config');
5
5
 
6
- const USER_AGENT = 'UnboundCLI/0.1.0';
6
+ const { version } = require('../package.json');
7
+ const USER_AGENT = `UnboundCLI/${version}`;
7
8
 
8
9
  const STATUS_MESSAGES = {
9
10
  400: 'Bad request. Check your input and try again.',
@@ -79,6 +80,11 @@ function request(method, path, { body, query, apiKey } = {}) {
79
80
  });
80
81
  });
81
82
 
83
+ req.setTimeout(30000, () => {
84
+ req.destroy();
85
+ reject(new Error(`Request to ${url.host} timed out after 30s. Please try again.`));
86
+ });
87
+
82
88
  req.on('error', (err) => {
83
89
  if (err.code === 'ECONNREFUSED') {
84
90
  reject(new Error(`Could not connect to ${url.host}. Check that the server is running.`));
package/src/auth.js CHANGED
@@ -74,19 +74,29 @@ async function loginWithBrowser(frontendUrl) {
74
74
  output.info(`If the browser does not open, visit:\n${authUrl}`);
75
75
  output.info('Waiting for authentication...');
76
76
 
77
- await open(authUrl);
77
+ try {
78
+ await open(authUrl);
79
+ } catch {
80
+ // Browser failed to open — URL is already printed above
81
+ }
78
82
 
79
83
  const timeout = setTimeout(() => {
80
84
  callbackReject(new Error('Authentication timed out after 120 seconds'));
81
85
  server.close();
82
86
  }, 120_000);
83
87
 
88
+ const reminder = setInterval(() => {
89
+ output.info('Still waiting for browser authentication...');
90
+ }, 30_000);
91
+
84
92
  try {
85
93
  const result = await callbackPromise;
86
94
  clearTimeout(timeout);
95
+ clearInterval(reminder);
87
96
  return result;
88
97
  } catch (err) {
89
98
  clearTimeout(timeout);
99
+ clearInterval(reminder);
90
100
  throw err;
91
101
  }
92
102
  }
@@ -1,3 +1,4 @@
1
+ const { Option } = require('commander');
1
2
  const config = require('../config');
2
3
  const output = require('../output');
3
4
  const api = require('../api');
@@ -8,7 +9,7 @@ function register(program) {
8
9
  .command('login')
9
10
  .description('Authenticate with Unbound. Opens a browser for interactive login, or use --api-key for CI/CD environments.')
10
11
  .option('--api-key <key>', 'Authenticate with an API key directly (non-interactive)')
11
- .option('--base-url <url>', 'Set a custom API base URL before logging in')
12
+ .addOption(new Option('--base-url <url>', 'Set a custom API base URL').hideHelp())
12
13
  .option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
13
14
  .addHelpText('after', `
14
15
  Authentication methods:
@@ -23,13 +24,11 @@ Authentication methods:
23
24
 
24
25
  Options:
25
26
  --domain sets a custom domain for organizations with self-hosted frontends.
26
- --base-url sets the backend API URL before authenticating (persisted).
27
27
 
28
28
  Examples:
29
29
  $ unbound login # Login via default gateway
30
30
  $ unbound login --domain custom.example.com # Login via custom domain
31
31
  $ unbound login --api-key sk-abc123 # Non-interactive login
32
- $ unbound login --base-url http://localhost:8000 # Use local backend
33
32
  `)
34
33
  .action(async (opts) => {
35
34
  try {
@@ -41,23 +40,31 @@ Examples:
41
40
 
42
41
  if (opts.apiKey) {
43
42
  apiKey = opts.apiKey;
43
+ // Validate the key before storing
44
+ const spin = output.spinner('Validating API key...');
45
+ 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.');
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ config.setApiKey(apiKey);
44
54
  } else {
45
- // Determine frontend URL: --domain flag or default
55
+ // Browser flow key is already saved by auth.loginWithBrowser()
46
56
  let frontendUrl;
47
57
  if (opts.domain) {
48
58
  frontendUrl = opts.domain.startsWith('http') ? opts.domain : `https://${opts.domain}`;
49
59
  } else {
50
60
  frontendUrl = config.getFrontendUrl();
51
61
  }
52
-
53
62
  await loginWithBrowser(frontendUrl);
54
63
  apiKey = config.getApiKey();
64
+ // Validate the stored key
65
+ await api.get('/api/v1/users/privileges/');
55
66
  }
56
67
 
57
- // Store the API key and validate through the backend
58
- config.setApiKey(apiKey);
59
- await api.get('/api/v1/users/privileges/');
60
-
61
68
  const cfg = config.readConfig();
62
69
  const parts = [];
63
70
  if (cfg.email) parts.push(`as ${cfg.email}`);
@@ -4,20 +4,24 @@ const output = require('../output');
4
4
  function register(program) {
5
5
  program
6
6
  .command('logout')
7
- .description('Log out of Unbound. Removes stored credentials and configuration from ~/.unbound/config.json.')
7
+ .description('Log out of Unbound. Removes stored credentials from ~/.unbound/config.json while preserving custom URL settings.')
8
8
  .addHelpText('after', `
9
9
  What this does:
10
- Clears all stored credentials (API key, email, organization) from
10
+ Clears stored credentials (API key, email, organization) from
11
11
  ~/.unbound/config.json. Custom URL settings (base_url, frontend_url)
12
- are also removed.
12
+ are preserved so you don't need to reconfigure them.
13
13
 
14
14
  Examples:
15
15
  $ unbound logout
16
16
  `)
17
17
  .action(() => {
18
18
  try {
19
- config.clearConfig();
20
- output.success('Logged out successfully.');
19
+ const cfg = config.readConfig();
20
+ delete cfg.api_key;
21
+ delete cfg.email;
22
+ delete cfg.org_name;
23
+ config.writeConfig(cfg);
24
+ output.success('Logged out successfully. Custom URL settings preserved.');
21
25
  } catch (err) {
22
26
  output.error(err.message);
23
27
  process.exitCode = 1;
@@ -8,13 +8,12 @@ function register(program) {
8
8
  .description('Show the current CLI status including config location, login state, and API connectivity. Useful for debugging connection issues.')
9
9
  .addHelpText('after', `
10
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)
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
+ Unbound Gateway - The Unbound gateway URL (if logged in)
16
+ API status - Connectivity check result (Connected / Error)
18
17
 
19
18
  Examples:
20
19
  $ unbound status
@@ -34,28 +33,33 @@ Examples:
34
33
  if (loggedIn) {
35
34
  pairs.push(['Email', cfg.email || '-']);
36
35
  pairs.push(['Organization', cfg.org_name || '-']);
37
- pairs.push(['API base URL', config.getBaseUrl()]);
38
- pairs.push(['Frontend URL', config.getFrontendUrl()]);
36
+ pairs.push(['Unbound Gateway', config.getFrontendUrl()]);
39
37
  }
40
38
 
41
39
  // Check API connectivity
42
40
  let connectivity = 'Not checked (not logged in)';
43
41
  if (loggedIn) {
42
+ const spin = output.spinner('Checking API connectivity...');
44
43
  try {
45
44
  await api.get('/api/v1/users/privileges/');
45
+ spin.stop();
46
46
  connectivity = 'Connected';
47
47
  } catch (err) {
48
+ spin.stop();
48
49
  connectivity = `Error: ${err.message}`;
49
50
  }
50
51
  }
51
52
  pairs.push(['API status', connectivity]);
52
53
 
53
54
  if (opts.json) {
54
- const obj = {};
55
- for (const [key, value] of pairs) {
56
- obj[key.toLowerCase().replace(/\s+/g, '_')] = value;
57
- }
58
- output.json(obj);
55
+ output.json({
56
+ config_file: config.CONFIG_FILE,
57
+ logged_in: loggedIn,
58
+ email: loggedIn ? (cfg.email || null) : null,
59
+ organization: loggedIn ? (cfg.org_name || null) : null,
60
+ gateway_url: loggedIn ? config.getFrontendUrl() : null,
61
+ api_status: connectivity,
62
+ });
59
63
  return;
60
64
  }
61
65
 
@@ -25,15 +25,18 @@ Examples:
25
25
  `)
26
26
  .option('--json', 'Output raw JSON')
27
27
  .action(async (opts) => {
28
- try {
29
- if (!config.isLoggedIn()) {
30
- output.error('Not logged in. Run `unbound login` first.');
31
- process.exitCode = 1;
32
- return;
33
- }
28
+ if (!config.isLoggedIn()) {
29
+ output.error('Not logged in. Run `unbound login` first.');
30
+ process.exitCode = 1;
31
+ return;
32
+ }
34
33
 
35
- const cfg = config.readConfig();
34
+ const cfg = config.readConfig();
35
+ const spin = output.spinner('Fetching user info...');
36
+ try {
36
37
  const privileges = await api.get('/api/v1/users/privileges/');
38
+ spin.stop();
39
+
37
40
  const role = roleFromPrivileges(privileges);
38
41
 
39
42
  if (opts.json) {
@@ -47,7 +50,7 @@ Examples:
47
50
  ['Role', role],
48
51
  ]);
49
52
  } catch (err) {
50
- output.error(err.message);
53
+ spin.fail(err.message);
51
54
  process.exitCode = 1;
52
55
  }
53
56
  });
package/src/config.js CHANGED
@@ -19,7 +19,7 @@ function readConfig() {
19
19
  return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
20
20
  }
21
21
  } catch {
22
- // Corrupted config, start fresh
22
+ console.error(`\x1b[33mWarning: Config file at ${CONFIG_FILE} is corrupted. Using defaults.\x1b[0m`);
23
23
  }
24
24
  return {};
25
25
  }
package/src/index.js CHANGED
@@ -9,31 +9,71 @@ const program = new Command();
9
9
 
10
10
  program
11
11
  .name('unbound')
12
- .description('Unbound CLI - Manage your AI Gateway from the command line.\n\nUnbound is an AI gateway that provides centralized policy management,\ncost controls, and security guardrails for AI coding tools like\nClaude Code, Cursor, Gemini CLI, and more.')
12
+ .description('Unbound CLI - Manage your AI Gateway from the command line.\n\nUnbound is an AI gateway that provides centralized policy management,\ncost controls, and security guardrails for AI coding tools like\nClaude Code, Cursor, Gemini CLI, Codex, and more.')
13
13
  .version(version)
14
14
  .addHelpText('after', `
15
- Examples:
16
- $ unbound login Sign in via browser
17
- $ unbound login --api-key <key> Sign in with an API key
18
- $ unbound whoami Show current user info
19
- $ unbound policy list List all policies
20
- $ unbound policy list --type SECURITY List security policies
21
- $ unbound users list List organization users
22
- $ unbound user-groups list List user groups
23
- $ unbound tools list List connected AI tools
24
- $ unbound setup cursor Configure Cursor to use Unbound
25
- $ unbound setup claude-code Configure Claude Code to use Unbound
26
- $ unbound config set-url http://localhost:8000 Use local backend
27
- $ unbound config set-frontend-url http://localhost:3000 Use local frontend
28
-
29
- Environment Variables:
30
- UNBOUND_API_URL Override the API base URL (e.g. http://localhost:8000)
31
- UNBOUND_FRONTEND_URL Override the frontend URL (e.g. http://localhost:3000)
32
-
33
- Configuration:
34
- Config is stored in ~/.unbound/config.json
35
- Production backend: https://backend.getunbound.ai
36
- Production frontend: https://gateway.getunbound.ai
15
+ AUTHENTICATION
16
+ $ unbound login Sign in via browser
17
+ $ unbound login --api-key <key> Sign in with an API key (for CI/CD)
18
+ $ unbound login --domain custom.co Sign in via custom domain
19
+ $ unbound logout Remove stored credentials
20
+ $ unbound whoami Show current user and organization
21
+ $ unbound status Show CLI status and API connectivity
22
+
23
+ TOOL SETUP
24
+ Automated setup (installs hooks, sets env vars, configures tool):
25
+ $ unbound setup cursor Set up Cursor
26
+ $ unbound setup claude-code Set up Claude Code (interactive mode selection)
27
+ $ unbound setup claude-code --gateway Use Unbound as AI provider
28
+ $ unbound setup claude-code --subscription Hooks only (keep your subscription)
29
+ $ unbound setup gemini-cli Set up Gemini CLI
30
+ $ unbound setup codex Set up Codex
31
+
32
+ Instruction-only (shows config values to set manually):
33
+ $ unbound setup roo-code Show Roo Code config values
34
+ $ unbound setup cline Show Cline config values
35
+ $ unbound setup kilo-code Show Kilo Code config values
36
+ $ unbound setup custom-access Show API key and base URL
37
+
38
+ Remove configuration:
39
+ $ unbound setup cursor --clear Remove Unbound config for Cursor
40
+ $ unbound setup claude-code --clear Remove Unbound config for Claude Code
41
+ $ unbound setup gemini-cli --clear Remove Unbound config for Gemini CLI
42
+ $ unbound setup codex --clear Remove Unbound config for Codex
43
+
44
+ POLICY MANAGEMENT
45
+ $ unbound policy list List all policies
46
+ $ unbound policy list --type SECURITY Filter by type (SECURITY, MODEL, COST)
47
+ $ unbound policy get <id> View policy details
48
+ $ unbound policy create --name <n> --type <t> Create a new policy
49
+ $ unbound policy update <id> --name <n> Update a policy
50
+ $ unbound policy delete <id> Delete a policy
51
+ $ unbound policy effective <id> View effective policies for a user/group
52
+ $ unbound policy form-data Get reference data for policy creation
53
+
54
+ USER & GROUP MANAGEMENT
55
+ $ unbound users list List organization members
56
+ $ unbound users effective-policies <id> View effective policies for a user
57
+ $ unbound user-groups list List user groups
58
+ $ unbound user-groups get <id> View group details and members
59
+ $ unbound user-groups create --name <n> Create a user group
60
+ $ unbound user-groups update <id> Update a user group
61
+ $ unbound user-groups delete <id> Delete a user group
62
+
63
+ TOOL CONNECTIONS
64
+ $ unbound tools list List connected AI tools
65
+ $ unbound tools connect <type> Connect a new tool
66
+ $ unbound tools approved List approved tool types
67
+
68
+ CONFIGURATION
69
+ $ unbound config show Show all settings
70
+
71
+ FILES
72
+ ~/.unbound/config.json Credentials and CLI settings
73
+
74
+ LEARN MORE
75
+ Use "unbound <command> --help" for more information about a command.
76
+ Use "unbound <command> <subcommand> --help" for subcommand details.
37
77
  `);
38
78
 
39
79
  // Register all command modules
@@ -50,31 +90,27 @@ require('./commands/setup').register(program);
50
90
  // config command for managing CLI settings
51
91
  const configCmd = program
52
92
  .command('config')
53
- .description('Manage CLI configuration. Set or view the API base URL and other settings.');
93
+ .description('Manage CLI configuration settings.');
54
94
 
95
+ // Internal/dev commands — hidden from help but still functional
55
96
  configCmd
56
- .command('set-url <url>')
57
- .description('Set the API base URL. Use http://localhost:8000 for local development.')
58
- .addHelpText('after', `
59
- Examples:
60
- $ unbound config set-url http://localhost:8000 # Local development
61
- $ unbound config set-url https://backend.getunbound.ai # Production (default)
62
- `)
97
+ .command('set-url <url>', { hidden: true })
98
+ .description('Set the API base URL (internal)')
63
99
  .action((url) => {
64
100
  config.setBaseUrl(url);
65
101
  output.success(`API base URL set to ${url}`);
66
102
  });
67
103
 
68
104
  configCmd
69
- .command('get-url')
70
- .description('Show the current API base URL.')
105
+ .command('get-url', { hidden: true })
106
+ .description('Show the current API base URL (internal)')
71
107
  .action(() => {
72
108
  console.log(config.getBaseUrl());
73
109
  });
74
110
 
75
111
  configCmd
76
- .command('reset-url')
77
- .description('Reset the API base URL to the default (https://backend.getunbound.ai).')
112
+ .command('reset-url', { hidden: true })
113
+ .description('Reset the API base URL to default (internal)')
78
114
  .action(() => {
79
115
  const cfg = config.readConfig();
80
116
  delete cfg.base_url;
@@ -83,28 +119,23 @@ configCmd
83
119
  });
84
120
 
85
121
  configCmd
86
- .command('set-frontend-url <url>')
87
- .description('Set the frontend URL. Use http://localhost:3000 for local development.')
88
- .addHelpText('after', `
89
- Examples:
90
- $ unbound config set-frontend-url http://localhost:3000 # Local development
91
- $ unbound config set-frontend-url https://gateway.getunbound.ai # Production (default)
92
- `)
122
+ .command('set-frontend-url <url>', { hidden: true })
123
+ .description('Set the frontend URL (internal)')
93
124
  .action((url) => {
94
125
  config.setFrontendUrl(url);
95
126
  output.success(`Frontend URL set to ${url}`);
96
127
  });
97
128
 
98
129
  configCmd
99
- .command('get-frontend-url')
100
- .description('Show the current frontend URL.')
130
+ .command('get-frontend-url', { hidden: true })
131
+ .description('Show the current frontend URL (internal)')
101
132
  .action(() => {
102
133
  console.log(config.getFrontendUrl());
103
134
  });
104
135
 
105
136
  configCmd
106
- .command('reset-frontend-url')
107
- .description('Reset the frontend URL to the default (https://gateway.getunbound.ai).')
137
+ .command('reset-frontend-url', { hidden: true })
138
+ .description('Reset the frontend URL to default (internal)')
108
139
  .action(() => {
109
140
  const cfg = config.readConfig();
110
141
  delete cfg.frontend_url;
@@ -122,8 +153,7 @@ configCmd
122
153
  if (opts.json) {
123
154
  output.json({
124
155
  config_file: config.CONFIG_FILE,
125
- api_base_url: config.getBaseUrl(),
126
- frontend_url: config.getFrontendUrl(),
156
+ gateway_url: config.getFrontendUrl(),
127
157
  logged_in: config.isLoggedIn(),
128
158
  email: cfg.email || null,
129
159
  organization: cfg.org_name || null,
@@ -133,8 +163,7 @@ configCmd
133
163
 
134
164
  output.keyValue([
135
165
  ['Config file', config.CONFIG_FILE],
136
- ['API base URL', config.getBaseUrl()],
137
- ['Frontend URL', config.getFrontendUrl()],
166
+ ['Unbound Gateway', config.getFrontendUrl()],
138
167
  ['Logged in', config.isLoggedIn() ? 'Yes' : 'No'],
139
168
  ['Email', cfg.email || '-'],
140
169
  ['Organization', cfg.org_name || '-'],
package/src/output.js CHANGED
@@ -77,4 +77,34 @@ function info(msg) {
77
77
  console.error(c.cyan(`\u2139 ${msg}`));
78
78
  }
79
79
 
80
- module.exports = { table, json, keyValue, success, error, warn, info };
80
+ function spinner(message) {
81
+ if (!process.stderr.isTTY) {
82
+ console.error(message);
83
+ return { stop: () => {}, succeed: (msg) => success(msg), fail: (msg) => error(msg) };
84
+ }
85
+
86
+ const frames = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'];
87
+ let i = 0;
88
+ const interval = setInterval(() => {
89
+ process.stderr.write(`\r${c.cyan(frames[i++ % frames.length])} ${message}`);
90
+ }, 80);
91
+
92
+ return {
93
+ stop() {
94
+ clearInterval(interval);
95
+ process.stderr.write('\r\x1b[K');
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
+ },
107
+ };
108
+ }
109
+
110
+ module.exports = { table, json, keyValue, success, error, warn, info, spinner };