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,27 @@
1
+ name: Publish to NPM
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout
13
+ uses: actions/checkout@v4
14
+
15
+ - name: Setup Node.js
16
+ uses: actions/setup-node@v4
17
+ with:
18
+ node-version: '20'
19
+ registry-url: 'https://registry.npmjs.org'
20
+
21
+ - name: Install dependencies
22
+ run: npm ci
23
+
24
+ - name: Publish to NPM
25
+ run: npm publish --access public
26
+ env:
27
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,35 @@
1
+ name: Version Check
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ check-version:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout PR branch
13
+ uses: actions/checkout@v4
14
+
15
+ - name: Get PR version
16
+ id: pr_version
17
+ run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
18
+
19
+ - name: Checkout main branch
20
+ uses: actions/checkout@v4
21
+ with:
22
+ ref: main
23
+ path: main-branch
24
+
25
+ - name: Get main version
26
+ id: main_version
27
+ run: echo "version=$(node -p "require('./main-branch/package.json').version")" >> $GITHUB_OUTPUT
28
+
29
+ - name: Compare versions
30
+ run: |
31
+ if [ "${{ steps.pr_version.outputs.version }}" = "${{ steps.main_version.outputs.version }}" ]; then
32
+ echo "::error::Version in package.json (${{ steps.pr_version.outputs.version }}) is the same as main. Please bump the version before merging."
33
+ exit 1
34
+ fi
35
+ echo "Version check passed: main=${{ steps.main_version.outputs.version }} → PR=${{ steps.pr_version.outputs.version }}"
package/LOCAL_DEV.md ADDED
@@ -0,0 +1,126 @@
1
+ # Local Development Guide
2
+
3
+ ## Prerequisites
4
+
5
+ - Node.js >= 18
6
+ - npm
7
+
8
+ ## Setup
9
+
10
+ ```bash
11
+ cd unbound-cli
12
+ npm install
13
+ ```
14
+
15
+ ## Run locally (without installing globally)
16
+
17
+ ```bash
18
+ # Run directly
19
+ node src/index.js --help
20
+ node src/index.js status
21
+ node src/index.js policy list
22
+
23
+ # Or use npm link to get the `unbound` command locally
24
+ npm link
25
+ unbound --help
26
+ ```
27
+
28
+ ## Point to local backend
29
+
30
+ ```bash
31
+ # Option 1: Set in config (persists across commands)
32
+ node src/index.js config set-url http://localhost:8000
33
+ node src/index.js config set-frontend-url http://localhost:3000
34
+
35
+ # Option 2: Environment variable (per-command)
36
+ UNBOUND_API_URL=http://localhost:8000 node src/index.js policy list
37
+
38
+ # Option 3: Set during login
39
+ node src/index.js login --base-url http://localhost:8000 --frontend-url http://localhost:3000
40
+
41
+ # Reset to production
42
+ node src/index.js config reset-url
43
+ node src/index.js config reset-frontend-url
44
+ ```
45
+
46
+ ## Verify config
47
+
48
+ ```bash
49
+ node src/index.js config show
50
+ node src/index.js config get-url
51
+ node src/index.js config get-frontend-url
52
+ ```
53
+
54
+ ## Test login flow
55
+
56
+ ```bash
57
+ # Browser-based login (starts local HTTP server, opens browser)
58
+ node src/index.js login --base-url http://localhost:8000 --frontend-url http://localhost:3000
59
+
60
+ # Direct API key login (skip browser)
61
+ node src/index.js login --base-url http://localhost:8000 --api-key <your-key>
62
+ ```
63
+
64
+ ## Test commands
65
+
66
+ ```bash
67
+ # Auth
68
+ node src/index.js whoami
69
+ node src/index.js status
70
+
71
+ # Policies
72
+ node src/index.js policy list
73
+ node src/index.js policy list --type SECURITY --json
74
+ node src/index.js policy get 1
75
+ node src/index.js policy form-data
76
+
77
+ # Users
78
+ node src/index.js users list
79
+ node src/index.js users effective-policies 1
80
+
81
+ # Groups
82
+ node src/index.js user-groups list
83
+ node src/index.js groups list # alias
84
+
85
+ # Tools
86
+ node src/index.js tools list
87
+ node src/index.js tools connect CURSOR
88
+ node src/index.js tools approved
89
+
90
+ # Setup
91
+ node src/index.js setup cursor
92
+ node src/index.js setup claude-code
93
+ ```
94
+
95
+ ## Unlink when done
96
+
97
+ ```bash
98
+ npm unlink -g unbound-cli
99
+ ```
100
+
101
+ ### Switching environments
102
+
103
+ ```bash
104
+ # Use local backend and frontend
105
+ unbound config set-url http://localhost:8000
106
+ unbound config set-frontend-url http://localhost:3000
107
+
108
+ # Reset to production
109
+ unbound config reset-url
110
+ unbound config reset-frontend-url
111
+
112
+ # One-time override via env vars
113
+ UNBOUND_API_URL=http://localhost:8000 UNBOUND_FRONTEND_URL=http://localhost:3000 unbound login
114
+ ```
115
+
116
+ ### Configuration
117
+
118
+ | Command | Description |
119
+ |---------|-------------|
120
+ | `unbound config set-url <url>` | Set API base URL |
121
+ | `unbound config get-url` | Show current API base URL |
122
+ | `unbound config reset-url` | Reset to default URL |
123
+ | `unbound config set-frontend-url <url>` | Set frontend URL |
124
+ | `unbound config get-frontend-url` | Show current frontend URL |
125
+ | `unbound config reset-frontend-url` | Reset to default frontend URL |
126
+ | `unbound config show` | Show all config values |
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # Unbound CLI
2
+
3
+ Command-line tool for managing your Unbound AI Gateway. Configure policies, manage users and groups, connect AI coding tools, and set up tools like Cursor, Claude Code, Gemini CLI, and more.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g unbound-cli
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # Login via browser
15
+ unbound login
16
+
17
+ # Or login with an API key
18
+ unbound login --api-key <your-api-key>
19
+
20
+ # Check who you are
21
+ unbound whoami
22
+
23
+ # List policies
24
+ unbound policy list
25
+
26
+ # Set up Cursor to use Unbound
27
+ unbound setup cursor
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ ### Authentication
33
+
34
+ | Command | Description |
35
+ |---------|-------------|
36
+ | `unbound login` | Sign in via browser or `--api-key` |
37
+ | `unbound logout` | Remove stored credentials |
38
+ | `unbound whoami` | Show current user, org, and role |
39
+ | `unbound status` | Show CLI status and API connectivity |
40
+
41
+ ### Policies (Admin only)
42
+
43
+ | Command | Description |
44
+ |---------|-------------|
45
+ | `unbound policy list` | List all policies |
46
+ | `unbound policy get <id>` | Get policy details |
47
+ | `unbound policy create --name <n> --type <t>` | Create a policy |
48
+ | `unbound policy update <id>` | Update a policy |
49
+ | `unbound policy delete <id>` | Delete a policy |
50
+ | `unbound policy form-data` | Get reference data for policy creation |
51
+ | `unbound policy effective <id>` | View effective policies for a user/group |
52
+
53
+ Policy types: `SECURITY`, `MODEL`, `COST`
54
+
55
+ ### Users
56
+
57
+ | Command | Description |
58
+ |---------|-------------|
59
+ | `unbound users list` | List organization members |
60
+ | `unbound users effective-policies <id>` | View effective policies for a user |
61
+
62
+ ### User Groups (Admin only)
63
+
64
+ | Command | Description |
65
+ |---------|-------------|
66
+ | `unbound user-groups list` | List all groups |
67
+ | `unbound user-groups get <id>` | Get group details |
68
+ | `unbound user-groups create --name <n>` | Create a group |
69
+ | `unbound user-groups update <id>` | Update a group |
70
+ | `unbound user-groups delete <id>` | Delete a group |
71
+ | `unbound user-groups effective-policies <id>` | View effective policies |
72
+
73
+ Alias: `unbound groups` works the same as `unbound user-groups`.
74
+
75
+ ### Tools
76
+
77
+ | Command | Description |
78
+ |---------|-------------|
79
+ | `unbound tools list` | List connected tools |
80
+ | `unbound tools connect <type>` | Connect a tool |
81
+ | `unbound tools approved` | List approved tool types |
82
+
83
+ Supported tool types: `CLAUDE_CODE`, `CURSOR`, `COPILOT`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
84
+
85
+ ### Setup
86
+
87
+ Automated setup (downloads scripts, sets env vars, configures tool):
88
+
89
+ | Command | Description |
90
+ |---------|-------------|
91
+ | `unbound setup cursor` | Download hooks, set env, restart Cursor |
92
+ | `unbound setup claude-code` | Configure gateway + install hooks |
93
+ | `unbound setup gemini-cli` | Set GEMINI_API_KEY and base URL |
94
+ | `unbound setup codex` | Set OPENAI_API_KEY and base URL |
95
+
96
+ Instruction-only (shows API key and base URL to configure manually):
97
+
98
+ | Command | Description |
99
+ |---------|-------------|
100
+ | `unbound setup roo-code` | Show Roo Code config values |
101
+ | `unbound setup cline` | Show Cline config values |
102
+ | `unbound setup kilo-code` | Show Kilo Code config values |
103
+ | `unbound setup custom-access` | Show API key and base URL for direct API access |
104
+
105
+
106
+ ## Configuration
107
+
108
+ Config is stored in `~/.unbound/config.json`.
109
+
110
+ **Backend URL priority** (highest to lowest):
111
+ 1. `UNBOUND_API_URL` environment variable
112
+ 2. `base_url` in `~/.unbound/config.json` (set via `unbound config set-url`)
113
+ 3. Default: `https://backend.getunbound.ai`
114
+
115
+ **Frontend URL priority** (highest to lowest):
116
+ 1. `UNBOUND_FRONTEND_URL` environment variable
117
+ 2. `frontend_url` in `~/.unbound/config.json` (set via `unbound config set-frontend-url`)
118
+ 3. Default: `https://gateway.getunbound.ai`
119
+
120
+
121
+ ## Global Options
122
+
123
+ All list/get commands support `--json` for machine-readable JSON output.
124
+
125
+ ```bash
126
+ unbound policy list --json
127
+ unbound users list --json | jq '.members[].email'
128
+ ```
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "unbound-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool for Unbound - AI Gateway management",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "unbound": "src/index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/index.js",
11
+ "lint": "eslint src/",
12
+ "test": "node --test test/"
13
+ },
14
+ "keywords": [
15
+ "unbound",
16
+ "ai-gateway",
17
+ "cli"
18
+ ],
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "commander": "^12.1.0",
22
+ "open": "^10.1.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ }
27
+ }
package/src/api.js ADDED
@@ -0,0 +1,81 @@
1
+ const https = require('https');
2
+ const http = require('http');
3
+ const { URL } = require('url');
4
+ const config = require('./config');
5
+
6
+ const USER_AGENT = 'UnboundCLI/0.1.0';
7
+
8
+ class ApiError extends Error {
9
+ constructor(statusCode, body) {
10
+ const message = body?.error || body?.message || `HTTP ${statusCode}`;
11
+ super(message);
12
+ this.name = 'ApiError';
13
+ this.statusCode = statusCode;
14
+ this.body = body;
15
+ }
16
+ }
17
+
18
+ function request(method, path, { body, query, apiKey } = {}) {
19
+ const baseUrl = config.getBaseUrl();
20
+ const key = apiKey || config.getApiKey();
21
+
22
+ if (!key) {
23
+ return Promise.reject(new Error('Not logged in. Run `unbound login` first.'));
24
+ }
25
+
26
+ const url = new URL(path, baseUrl);
27
+ if (query) {
28
+ for (const [k, v] of Object.entries(query)) {
29
+ if (v !== undefined && v !== null) {
30
+ url.searchParams.set(k, v);
31
+ }
32
+ }
33
+ }
34
+
35
+ const headers = {
36
+ 'Authorization': `Bearer ${key}`,
37
+ 'User-Agent': USER_AGENT,
38
+ 'Accept': 'application/json',
39
+ };
40
+
41
+ if (body) {
42
+ headers['Content-Type'] = 'application/json';
43
+ }
44
+
45
+ const payload = body ? JSON.stringify(body) : null;
46
+ const transport = url.protocol === 'https:' ? https : http;
47
+
48
+ return new Promise((resolve, reject) => {
49
+ const req = transport.request(url, { method, headers }, (res) => {
50
+ const chunks = [];
51
+ res.on('data', (chunk) => chunks.push(chunk));
52
+ res.on('end', () => {
53
+ const raw = Buffer.concat(chunks).toString();
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ } catch {
58
+ parsed = raw;
59
+ }
60
+
61
+ if (res.statusCode >= 200 && res.statusCode < 300) {
62
+ resolve(parsed);
63
+ } else {
64
+ reject(new ApiError(res.statusCode, parsed));
65
+ }
66
+ });
67
+ });
68
+
69
+ req.on('error', reject);
70
+ if (payload) req.write(payload);
71
+ req.end();
72
+ });
73
+ }
74
+
75
+ module.exports = {
76
+ ApiError,
77
+ get: (path, opts) => request('GET', path, opts),
78
+ post: (path, opts) => request('POST', path, opts),
79
+ put: (path, opts) => request('PUT', path, opts),
80
+ del: (path, opts) => request('DELETE', path, opts),
81
+ };
package/src/auth.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Shared browser-based authentication flow.
3
+ * Used by both `login` and `setup` commands.
4
+ */
5
+ const http = require('http');
6
+ const { URL } = require('url');
7
+ const config = require('./config');
8
+ const output = require('./output');
9
+
10
+ /**
11
+ * Opens the browser for authentication and waits for the callback.
12
+ * Starts a local HTTP server, opens the frontend auth page, and waits
13
+ * for the redirect with API key, email, and org.
14
+ *
15
+ * @param {string} frontendUrl - The frontend URL to open
16
+ * @returns {Promise<{email: string, orgName: string}>}
17
+ */
18
+ async function loginWithBrowser(frontendUrl) {
19
+ const server = http.createServer();
20
+ let callbackResolve;
21
+ let callbackReject;
22
+
23
+ const callbackPromise = new Promise((resolve, reject) => {
24
+ callbackResolve = resolve;
25
+ callbackReject = reject;
26
+ });
27
+
28
+ server.on('request', (req, res) => {
29
+ const url = new URL(req.url, `http://localhost`);
30
+
31
+ if (url.pathname !== '/callback') {
32
+ res.writeHead(404);
33
+ res.end('Not found');
34
+ return;
35
+ }
36
+
37
+ const apiKey = url.searchParams.get('api_key');
38
+ const email = url.searchParams.get('email');
39
+ const orgName = url.searchParams.get('org');
40
+
41
+ if (!apiKey) {
42
+ res.writeHead(400, { 'Content-Type': 'text/html' });
43
+ res.end('<html><body><h2>Authentication failed</h2><p>No API key received. Please try again.</p></body></html>');
44
+ callbackReject(new Error('No API key received in callback'));
45
+ server.close();
46
+ return;
47
+ }
48
+
49
+ config.setApiKey(apiKey);
50
+ const cfg = config.readConfig();
51
+ if (email) cfg.email = email;
52
+ if (orgName) cfg.org_name = orgName;
53
+ config.writeConfig(cfg);
54
+
55
+ res.writeHead(200, { 'Content-Type': 'text/html' });
56
+ res.end('<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>');
57
+
58
+ callbackResolve({ email, orgName });
59
+ server.close();
60
+ });
61
+
62
+ await new Promise((resolve, reject) => {
63
+ server.listen(0, '127.0.0.1', resolve);
64
+ server.on('error', reject);
65
+ });
66
+
67
+ const port = server.address().port;
68
+ const callbackUrl = `http://localhost:${port}/callback`;
69
+ const authUrl = `${frontendUrl}/cli/auth?callback=${encodeURIComponent(callbackUrl)}`;
70
+
71
+ const open = (await import('open')).default;
72
+
73
+ console.log('Opening browser for authentication...');
74
+ console.log(`If the browser does not open, visit:\n${authUrl}\n`);
75
+ console.log('Waiting for authentication...');
76
+
77
+ await open(authUrl);
78
+
79
+ const timeout = setTimeout(() => {
80
+ callbackReject(new Error('Authentication timed out after 120 seconds'));
81
+ server.close();
82
+ }, 120_000);
83
+
84
+ try {
85
+ const result = await callbackPromise;
86
+ clearTimeout(timeout);
87
+ return result;
88
+ } catch (err) {
89
+ clearTimeout(timeout);
90
+ throw err;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Ensures the user is logged in. If not, triggers browser-based login.
96
+ * Returns true if logged in (or just logged in), false on failure.
97
+ */
98
+ async function ensureLoggedIn() {
99
+ if (config.isLoggedIn()) {
100
+ return true;
101
+ }
102
+
103
+ output.warn('Not logged in. Opening browser to authenticate...\n');
104
+ const frontendUrl = config.getFrontendUrl();
105
+ const result = await loginWithBrowser(frontendUrl);
106
+
107
+ const parts = [];
108
+ if (result.email) parts.push(`as ${result.email}`);
109
+ if (result.orgName) parts.push(`to ${result.orgName}`);
110
+ output.success(`Logged in successfully${parts.length ? ' ' + parts.join(' ') : ''}.\n`);
111
+
112
+ return true;
113
+ }
114
+
115
+ module.exports = { loginWithBrowser, ensureLoggedIn };
@@ -0,0 +1,63 @@
1
+ const config = require('../config');
2
+ const output = require('../output');
3
+ const { loginWithBrowser } = require('../auth');
4
+
5
+ function register(program) {
6
+ program
7
+ .command('login')
8
+ .description('Authenticate with Unbound. Opens a browser for interactive login, or use --api-key for CI/CD environments.')
9
+ .option('--api-key <key>', 'Authenticate with an API key directly (non-interactive)')
10
+ .option('--base-url <url>', 'Set a custom API base URL before logging in')
11
+ .option('--frontend-url <url>', 'Set a custom frontend URL for browser login')
12
+ .addHelpText('after', `
13
+ Authentication methods:
14
+ Browser login (default):
15
+ Opens a browser to the Unbound frontend for interactive login.
16
+ A local HTTP server listens for the auth callback with the API key.
17
+ Times out after 120 seconds if no callback is received.
18
+
19
+ API key login (--api-key):
20
+ Non-interactive login for CI/CD or automation. Stores the provided
21
+ API key directly without browser interaction.
22
+
23
+ Options:
24
+ --base-url sets the backend API URL before authenticating (persisted).
25
+ --frontend-url sets the frontend URL for browser login (persisted).
26
+
27
+ Examples:
28
+ $ unbound login # Interactive browser-based login
29
+ $ unbound login --api-key sk-abc123 # Non-interactive login with API key
30
+ $ unbound login --base-url http://localhost:8000 --frontend-url http://localhost:3000
31
+ $ unbound login --base-url https://custom.api.example.com --api-key sk-abc123
32
+ `)
33
+ .action(async (opts) => {
34
+ try {
35
+ if (opts.baseUrl) {
36
+ config.setBaseUrl(opts.baseUrl);
37
+ }
38
+
39
+ if (opts.frontendUrl) {
40
+ config.setFrontendUrl(opts.frontendUrl);
41
+ }
42
+
43
+ if (opts.apiKey) {
44
+ config.setApiKey(opts.apiKey);
45
+ output.success('Logged in successfully with API key.');
46
+ return;
47
+ }
48
+
49
+ const frontendUrl = config.getFrontendUrl();
50
+ const result = await loginWithBrowser(frontendUrl);
51
+
52
+ const parts = [];
53
+ if (result.email) parts.push(`as ${result.email}`);
54
+ if (result.orgName) parts.push(`to ${result.orgName}`);
55
+ output.success(`Logged in successfully${parts.length ? ' ' + parts.join(' ') : ''}.`);
56
+ } catch (err) {
57
+ output.error(err.message);
58
+ process.exitCode = 1;
59
+ }
60
+ });
61
+ }
62
+
63
+ module.exports = { register };
@@ -0,0 +1,28 @@
1
+ const config = require('../config');
2
+ const output = require('../output');
3
+
4
+ function register(program) {
5
+ program
6
+ .command('logout')
7
+ .description('Log out of Unbound. Removes stored credentials and configuration from ~/.unbound/config.json.')
8
+ .addHelpText('after', `
9
+ What this does:
10
+ Clears all stored credentials (API key, email, organization) from
11
+ ~/.unbound/config.json. Custom URL settings (base_url, frontend_url)
12
+ are also removed.
13
+
14
+ Examples:
15
+ $ unbound logout
16
+ `)
17
+ .action(() => {
18
+ try {
19
+ config.clearConfig();
20
+ output.success('Logged out successfully.');
21
+ } catch (err) {
22
+ output.error(err.message);
23
+ process.exitCode = 1;
24
+ }
25
+ });
26
+ }
27
+
28
+ module.exports = { register };