unbound-cli 0.1.3 → 0.1.5

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,10 +1,11 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
- "unbound": "src/index.js"
7
+ "unbound": "src/index.js",
8
+ "unbound-cli": "src/index.js"
8
9
  },
9
10
  "scripts": {
10
11
  "start": "node src/index.js",
package/src/api.js CHANGED
@@ -5,9 +5,22 @@ const config = require('./config');
5
5
 
6
6
  const USER_AGENT = 'UnboundCLI/0.1.0';
7
7
 
8
+ const STATUS_MESSAGES = {
9
+ 400: 'Bad request. Check your input and try again.',
10
+ 401: 'Authentication failed. Your API key may be invalid or expired. Run `unbound login` to re-authenticate.',
11
+ 403: 'Permission denied. You may not have the required role for this action.',
12
+ 404: 'Not found. Check the ID and try again.',
13
+ 409: 'Conflict. The resource may already exist.',
14
+ 422: 'Invalid input. Check your parameters and try again.',
15
+ 429: 'Too many requests. Please wait and try again.',
16
+ 500: 'Something went wrong on the server. Please try again later.',
17
+ 502: 'The server is temporarily unavailable. Please try again later.',
18
+ 503: 'The server is temporarily unavailable. Please try again later.',
19
+ };
20
+
8
21
  class ApiError extends Error {
9
22
  constructor(statusCode, body) {
10
- const message = body?.error || body?.message || `HTTP ${statusCode}`;
23
+ const message = body?.error || body?.message || STATUS_MESSAGES[statusCode] || `Unexpected error (HTTP ${statusCode})`;
11
24
  super(message);
12
25
  this.name = 'ApiError';
13
26
  this.statusCode = statusCode;
@@ -66,7 +79,17 @@ function request(method, path, { body, query, apiKey } = {}) {
66
79
  });
67
80
  });
68
81
 
69
- req.on('error', reject);
82
+ req.on('error', (err) => {
83
+ if (err.code === 'ECONNREFUSED') {
84
+ reject(new Error(`Could not connect to ${url.host}. Check that the server is running.`));
85
+ } else if (err.code === 'ENOTFOUND') {
86
+ reject(new Error(`Could not resolve ${url.host}. Check your internet connection and the API URL.`));
87
+ } else if (err.code === 'ETIMEDOUT' || err.code === 'ESOCKETTIMEDOUT') {
88
+ reject(new Error(`Connection to ${url.host} timed out. Please try again.`));
89
+ } else {
90
+ reject(new Error(`Network error: ${err.message}`));
91
+ }
92
+ });
70
93
  if (payload) req.write(payload);
71
94
  req.end();
72
95
  });
package/src/auth.js CHANGED
@@ -70,9 +70,9 @@ async function loginWithBrowser(frontendUrl) {
70
70
 
71
71
  const open = (await import('open')).default;
72
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...');
73
+ output.info('Opening browser for authentication...');
74
+ output.info(`If the browser does not open, visit:\n${authUrl}`);
75
+ output.info('Waiting for authentication...');
76
76
 
77
77
  await open(authUrl);
78
78
 
@@ -100,14 +100,14 @@ async function ensureLoggedIn() {
100
100
  return true;
101
101
  }
102
102
 
103
- output.warn('Not logged in. Opening browser to authenticate...\n');
103
+ output.warn('Not logged in. Opening browser to authenticate...');
104
104
  const frontendUrl = config.getFrontendUrl();
105
105
  const result = await loginWithBrowser(frontendUrl);
106
106
 
107
107
  const parts = [];
108
108
  if (result.email) parts.push(`as ${result.email}`);
109
109
  if (result.orgName) parts.push(`to ${result.orgName}`);
110
- output.success(`Logged in successfully${parts.length ? ' ' + parts.join(' ') : ''}.\n`);
110
+ output.success(`Logged in successfully${parts.length ? ' ' + parts.join(' ') : ''}.`);
111
111
 
112
112
  return true;
113
113
  }
@@ -1,5 +1,6 @@
1
1
  const config = require('../config');
2
2
  const output = require('../output');
3
+ const api = require('../api');
3
4
  const { loginWithBrowser } = require('../auth');
4
5
 
5
6
  function register(program) {
@@ -8,7 +9,7 @@ function register(program) {
8
9
  .description('Authenticate with Unbound. Opens a browser for interactive login, or use --api-key for CI/CD environments.')
9
10
  .option('--api-key <key>', 'Authenticate with an API key directly (non-interactive)')
10
11
  .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
+ .option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
12
13
  .addHelpText('after', `
13
14
  Authentication methods:
14
15
  Browser login (default):
@@ -21,14 +22,14 @@ Authentication methods:
21
22
  API key directly without browser interaction.
22
23
 
23
24
  Options:
25
+ --domain sets a custom domain for organizations with self-hosted frontends.
24
26
  --base-url sets the backend API URL before authenticating (persisted).
25
- --frontend-url sets the frontend URL for browser login (persisted).
26
27
 
27
28
  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
29
+ $ unbound login # Login via default gateway
30
+ $ unbound login --domain custom.example.com # Login via custom domain
31
+ $ unbound login --api-key sk-abc123 # Non-interactive login
32
+ $ unbound login --base-url http://localhost:8000 # Use local backend
32
33
  `)
33
34
  .action(async (opts) => {
34
35
  try {
@@ -36,22 +37,31 @@ Examples:
36
37
  config.setBaseUrl(opts.baseUrl);
37
38
  }
38
39
 
39
- if (opts.frontendUrl) {
40
- config.setFrontendUrl(opts.frontendUrl);
41
- }
40
+ let apiKey;
42
41
 
43
42
  if (opts.apiKey) {
44
- config.setApiKey(opts.apiKey);
45
- output.success('Logged in successfully with API key.');
46
- return;
43
+ apiKey = opts.apiKey;
44
+ } else {
45
+ // Determine frontend URL: --domain flag or default
46
+ let frontendUrl;
47
+ if (opts.domain) {
48
+ frontendUrl = opts.domain.startsWith('http') ? opts.domain : `https://${opts.domain}`;
49
+ } else {
50
+ frontendUrl = config.getFrontendUrl();
51
+ }
52
+
53
+ await loginWithBrowser(frontendUrl);
54
+ apiKey = config.getApiKey();
47
55
  }
48
56
 
49
- const frontendUrl = config.getFrontendUrl();
50
- const result = await loginWithBrowser(frontendUrl);
57
+ // Store the API key and validate through the backend
58
+ config.setApiKey(apiKey);
59
+ await api.get('/api/v1/users/privileges/');
51
60
 
61
+ const cfg = config.readConfig();
52
62
  const parts = [];
53
- if (result.email) parts.push(`as ${result.email}`);
54
- if (result.orgName) parts.push(`to ${result.orgName}`);
63
+ if (cfg.email) parts.push(`as ${cfg.email}`);
64
+ if (cfg.org_name) parts.push(`to ${cfg.org_name}`);
55
65
  output.success(`Logged in successfully${parts.length ? ' ' + parts.join(' ') : ''}.`);
56
66
  } catch (err) {
57
67
  output.error(err.message);
@@ -1,12 +1,7 @@
1
- const readline = require('readline');
2
1
  const config = require('../config');
3
2
  const api = require('../api');
4
3
  const output = require('../output');
5
-
6
- function formatDate(dateStr) {
7
- if (!dateStr) return '-';
8
- return new Date(dateStr).toLocaleDateString();
9
- }
4
+ const { formatDate, confirm, parseCommaSeparated } = require('../utils');
10
5
 
11
6
  function formatScope(groups) {
12
7
  if (!groups || groups.length === 0) return '-';
@@ -52,21 +47,6 @@ function displayEffectivePolicies(data, opts) {
52
47
  ]);
53
48
  }
54
49
 
55
- function confirm(message) {
56
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
57
- return new Promise((resolve) => {
58
- rl.question(`${message} (y/N) `, (answer) => {
59
- rl.close();
60
- resolve(answer.toLowerCase() === 'y');
61
- });
62
- });
63
- }
64
-
65
- function parseCommaSeparated(value) {
66
- if (!value) return undefined;
67
- return value.split(',').map((s) => s.trim()).filter(Boolean);
68
- }
69
-
70
50
  function register(program) {
71
51
  const policy = program
72
52
  .command('policy')
@@ -335,22 +315,25 @@ Examples:
335
315
 
336
316
  // Display each section of the form data
337
317
  if (data.user_groups) {
338
- console.log('\nUser Groups:');
318
+ console.log('');
319
+ output.info('User Groups');
339
320
  output.table(data.user_groups, [
340
321
  { key: 'id', header: 'ID' },
341
322
  { key: 'name', header: 'Name' },
342
323
  ]);
343
324
  }
344
325
 
345
- if (data.tool_types) {
346
- console.log('\nTool Types:');
326
+ if (data.tool_types && data.tool_types.length > 0) {
327
+ console.log('');
328
+ output.info('Tool Types');
347
329
  for (const type of data.tool_types) {
348
330
  console.log(` ${type}`);
349
331
  }
350
332
  }
351
333
 
352
334
  if (data.guardrails) {
353
- console.log('\nGuardrails:');
335
+ console.log('');
336
+ output.info('Guardrails');
354
337
  output.table(data.guardrails, [
355
338
  { key: 'id', header: 'ID' },
356
339
  { key: 'name', header: 'Name' },
@@ -359,7 +342,8 @@ Examples:
359
342
  }
360
343
 
361
344
  if (data.models) {
362
- console.log('\nModels:');
345
+ console.log('');
346
+ output.info('Models');
363
347
  output.table(data.models, [
364
348
  { key: 'id', header: 'ID' },
365
349
  { key: 'name', header: 'Name' },
@@ -9,9 +9,10 @@ const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/ref
9
9
  /**
10
10
  * Runs a Python setup script from the setup repo, passing the stored API key.
11
11
  */
12
- function runSetupScript(scriptPath, apiKey) {
12
+ function runSetupScript(scriptPath, apiKey, { clear = false } = {}) {
13
13
  const url = `${SETUP_BASE_URL}/${scriptPath}`;
14
- const cmd = `curl -fsSL "${url}" | python3 - --api-key "${apiKey}"`;
14
+ const extraArgs = clear ? ' --clear' : '';
15
+ const cmd = `curl -fsSL "${url}" | python3 - --api-key "${apiKey}"${extraArgs}`;
15
16
 
16
17
  console.log('');
17
18
  execSync(cmd, { stdio: 'inherit' });
@@ -21,13 +22,13 @@ function runSetupScript(scriptPath, apiKey) {
21
22
  * Ensures login, gets the stored API key, and runs the setup script(s).
22
23
  */
23
24
  function makeAction(...scriptPaths) {
24
- return async () => {
25
+ return async (opts) => {
25
26
  try {
26
27
  await ensureLoggedIn();
27
28
  const apiKey = config.getApiKey();
28
29
 
29
30
  for (const scriptPath of scriptPaths) {
30
- runSetupScript(scriptPath, apiKey);
31
+ runSetupScript(scriptPath, apiKey, { clear: opts.clear });
31
32
  }
32
33
  } catch (err) {
33
34
  output.error(err.message);
@@ -66,6 +67,7 @@ open automatically to authenticate before proceeding.
66
67
  'Set up Cursor to use Unbound. Downloads hook scripts, sets the ' +
67
68
  'UNBOUND_CURSOR_API_KEY environment variable, and restarts Cursor.'
68
69
  )
70
+ .option('--clear', 'Remove Unbound configuration for Cursor')
69
71
  .addHelpText('after', `
70
72
  What this does:
71
73
  1. Downloads Cursor hook scripts from the Unbound setup repository
@@ -79,6 +81,7 @@ Prerequisites:
79
81
 
80
82
  Examples:
81
83
  $ unbound setup cursor
84
+ $ unbound setup cursor --clear # Remove Unbound configuration
82
85
  `)
83
86
  .action(makeAction('cursor/setup.py'));
84
87
 
@@ -90,6 +93,7 @@ Examples:
90
93
  )
91
94
  .option('--subscription', 'Use your existing Claude subscription (hooks only)')
92
95
  .option('--gateway', 'Use Unbound as the AI provider (gateway mode)')
96
+ .option('--clear', 'Remove Unbound configuration for Claude Code')
93
97
  .addHelpText('after', `
94
98
  Modes:
95
99
  Subscription (hooks only):
@@ -114,14 +118,16 @@ Examples:
114
118
  $ unbound setup claude-code # Interactive mode selection
115
119
  $ unbound setup claude-code --subscription # Hooks only (keep your subscription)
116
120
  $ unbound setup claude-code --gateway # Use Unbound as AI provider
121
+ $ unbound setup claude-code --clear # Remove Unbound configuration
117
122
  `)
118
123
  .action(async (opts) => {
119
124
  try {
120
125
  await ensureLoggedIn();
121
126
  const apiKey = config.getApiKey();
127
+ const scriptOpts = { clear: opts.clear };
122
128
 
123
129
  let useSubscription = opts.subscription;
124
- if (!opts.subscription && !opts.gateway) {
130
+ if (!opts.clear && !opts.subscription && !opts.gateway) {
125
131
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
126
132
  const answer = await new Promise((resolve) => {
127
133
  console.log('\nHow do you want to use Claude Code with Unbound?\n');
@@ -133,10 +139,13 @@ Examples:
133
139
  useSubscription = answer.trim() === '1';
134
140
  }
135
141
 
136
- if (useSubscription) {
137
- runSetupScript('claude-code/hooks/setup.py', apiKey);
142
+ if (opts.clear) {
143
+ runSetupScript('claude-code/hooks/setup.py', apiKey, scriptOpts);
144
+ runSetupScript('claude-code/gateway/setup.py', apiKey, scriptOpts);
145
+ } else if (useSubscription) {
146
+ runSetupScript('claude-code/hooks/setup.py', apiKey, scriptOpts);
138
147
  } else {
139
- runSetupScript('claude-code/gateway/setup.py', apiKey);
148
+ runSetupScript('claude-code/gateway/setup.py', apiKey, scriptOpts);
140
149
  }
141
150
  } catch (err) {
142
151
  output.error(err.message);
@@ -150,6 +159,7 @@ Examples:
150
159
  'Set up Gemini CLI to use Unbound. Sets GEMINI_API_KEY and ' +
151
160
  'GOOGLE_GEMINI_BASE_URL environment variables.'
152
161
  )
162
+ .option('--clear', 'Remove Unbound configuration for Gemini CLI')
153
163
  .addHelpText('after', `
154
164
  What this does:
155
165
  1. Sets GEMINI_API_KEY to your Unbound API key in your shell profile
@@ -163,6 +173,7 @@ Prerequisites:
163
173
 
164
174
  Examples:
165
175
  $ unbound setup gemini-cli
176
+ $ unbound setup gemini-cli --clear # Remove Unbound configuration
166
177
  `)
167
178
  .action(makeAction('gemini-cli/gateway/setup.py'));
168
179
 
@@ -172,6 +183,7 @@ Examples:
172
183
  'Set up Codex to use Unbound. Sets OPENAI_API_KEY and ' +
173
184
  'OPENAI_BASE_URL environment variables.'
174
185
  )
186
+ .option('--clear', 'Remove Unbound configuration for Codex')
175
187
  .addHelpText('after', `
176
188
  What this does:
177
189
  1. Sets OPENAI_API_KEY to your Unbound API key in your shell profile
@@ -185,6 +197,7 @@ Prerequisites:
185
197
 
186
198
  Examples:
187
199
  $ unbound setup codex
200
+ $ unbound setup codex --clear # Remove Unbound configuration
188
201
  `)
189
202
  .action(makeAction('codex/gateway/setup.py'));
190
203
 
@@ -18,8 +18,10 @@ Output fields:
18
18
 
19
19
  Examples:
20
20
  $ unbound status
21
+ $ unbound status --json
21
22
  `)
22
- .action(async () => {
23
+ .option('--json', 'Output raw JSON')
24
+ .action(async (opts) => {
23
25
  try {
24
26
  const loggedIn = config.isLoggedIn();
25
27
  const cfg = config.readConfig();
@@ -48,6 +50,15 @@ Examples:
48
50
  }
49
51
  pairs.push(['API status', connectivity]);
50
52
 
53
+ 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);
59
+ return;
60
+ }
61
+
51
62
  output.keyValue(pairs);
52
63
  } catch (err) {
53
64
  output.error(err.message);
@@ -1,11 +1,7 @@
1
1
  const config = require('../config');
2
2
  const api = require('../api');
3
3
  const output = require('../output');
4
-
5
- function formatDate(dateStr) {
6
- if (!dateStr) return '-';
7
- return new Date(dateStr).toLocaleDateString();
8
- }
4
+ const { formatDate } = require('../utils');
9
5
 
10
6
  const SUPPORTED_TOOL_TYPES = [
11
7
  'CLAUDE_CODE',
@@ -92,8 +88,10 @@ Examples:
92
88
  $ unbound tools connect CLAUDE_CODE
93
89
  $ unbound tools connect CURSOR
94
90
  $ unbound tools connect GEMINI_CLI
91
+ $ unbound tools connect CLAUDE_CODE --json
95
92
  `)
96
- .action(async (toolType) => {
93
+ .option('--json', 'Output raw JSON')
94
+ .action(async (toolType, opts) => {
97
95
  try {
98
96
  if (!config.isLoggedIn()) {
99
97
  output.error('Not logged in. Run `unbound login` first.');
@@ -110,6 +108,11 @@ Examples:
110
108
 
111
109
  const data = await api.post('/api/v1/agents/connect/', { body: { tool_type: normalized } });
112
110
 
111
+ if (opts.json) {
112
+ output.json(data);
113
+ return;
114
+ }
115
+
113
116
  output.success(`Connected ${normalized}.`);
114
117
  output.keyValue([
115
118
  ['Tool Type', data.tool_type],
@@ -153,18 +156,21 @@ Examples:
153
156
  return;
154
157
  }
155
158
 
156
- if (data.restriction_enabled !== undefined) {
157
- console.log(`Restriction Enabled: ${data.restriction_enabled}`);
158
- console.log('');
159
- }
159
+ output.keyValue([
160
+ ['Restriction Enabled', data.restriction_enabled !== undefined ? String(data.restriction_enabled) : '-'],
161
+ ]);
160
162
 
161
163
  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
- }
164
+ console.log('');
165
+ output.table(data.approved_tools.map((tool) => {
166
+ const name = typeof tool === 'string' ? tool : tool.tool_type || tool.name || JSON.stringify(tool);
167
+ return { name };
168
+ }), [
169
+ { key: 'name', header: 'Tool Type' },
170
+ ]);
166
171
  } else {
167
- console.log('No approved tools configured.');
172
+ console.log('');
173
+ output.info('No approved tools configured.');
168
174
  }
169
175
  } catch (err) {
170
176
  output.error(err.message);
@@ -1,12 +1,7 @@
1
- const readline = require('readline');
2
1
  const config = require('../config');
3
2
  const api = require('../api');
4
3
  const output = require('../output');
5
-
6
- function formatDate(dateStr) {
7
- if (!dateStr) return '-';
8
- return new Date(dateStr).toLocaleDateString();
9
- }
4
+ const { formatDate, confirm, parseCommaSeparated } = require('../utils');
10
5
 
11
6
  function displayGroup(group) {
12
7
  output.keyValue([
@@ -19,7 +14,7 @@ function displayGroup(group) {
19
14
  ]);
20
15
 
21
16
  if (!group.all_org_users && group.members && group.members.length > 0) {
22
- console.log('\nMembers:');
17
+ console.log('');
23
18
  output.table(group.members, [
24
19
  { key: 'id', header: 'ID' },
25
20
  { key: 'email', header: 'Email' },
@@ -29,21 +24,6 @@ function displayGroup(group) {
29
24
  }
30
25
  }
31
26
 
32
- function confirm(message) {
33
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
34
- return new Promise((resolve) => {
35
- rl.question(`${message} (y/N) `, (answer) => {
36
- rl.close();
37
- resolve(answer.toLowerCase() === 'y');
38
- });
39
- });
40
- }
41
-
42
- function parseCommaSeparated(value) {
43
- if (!value) return undefined;
44
- return value.split(',').map((s) => s.trim()).filter(Boolean);
45
- }
46
-
47
27
  function register(program) {
48
28
  const userGroups = program
49
29
  .command('user-groups')
@@ -21,8 +21,10 @@ Output fields:
21
21
 
22
22
  Examples:
23
23
  $ unbound whoami
24
+ $ unbound whoami --json
24
25
  `)
25
- .action(async () => {
26
+ .option('--json', 'Output raw JSON')
27
+ .action(async (opts) => {
26
28
  try {
27
29
  if (!config.isLoggedIn()) {
28
30
  output.error('Not logged in. Run `unbound login` first.');
@@ -34,6 +36,11 @@ Examples:
34
36
  const privileges = await api.get('/api/v1/users/privileges/');
35
37
  const role = roleFromPrivileges(privileges);
36
38
 
39
+ if (opts.json) {
40
+ output.json({ email: cfg.email || null, organization: cfg.org_name || null, role });
41
+ return;
42
+ }
43
+
37
44
  output.keyValue([
38
45
  ['Email', cfg.email || '-'],
39
46
  ['Organization', cfg.org_name || '-'],
package/src/index.js CHANGED
@@ -3,13 +3,14 @@
3
3
  const { Command } = require('commander');
4
4
  const config = require('./config');
5
5
  const output = require('./output');
6
+ const { version } = require('../package.json');
6
7
 
7
8
  const program = new Command();
8
9
 
9
10
  program
10
11
  .name('unbound')
11
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
- .version('0.1.0')
13
+ .version(version)
13
14
  .addHelpText('after', `
14
15
  Examples:
15
16
  $ unbound login Sign in via browser
@@ -114,8 +115,22 @@ configCmd
114
115
  configCmd
115
116
  .command('show')
116
117
  .description('Show all current configuration values.')
117
- .action(() => {
118
+ .option('--json', 'Output raw JSON')
119
+ .action((opts) => {
118
120
  const cfg = config.readConfig();
121
+
122
+ if (opts.json) {
123
+ output.json({
124
+ config_file: config.CONFIG_FILE,
125
+ api_base_url: config.getBaseUrl(),
126
+ frontend_url: config.getFrontendUrl(),
127
+ logged_in: config.isLoggedIn(),
128
+ email: cfg.email || null,
129
+ organization: cfg.org_name || null,
130
+ });
131
+ return;
132
+ }
133
+
119
134
  output.keyValue([
120
135
  ['Config file', config.CONFIG_FILE],
121
136
  ['API base URL', config.getBaseUrl()],
package/src/output.js CHANGED
@@ -3,9 +3,19 @@
3
3
  * Provides table, JSON, and key-value output for terminal display.
4
4
  */
5
5
 
6
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
7
+ const c = {
8
+ green: (s) => useColor ? `\x1b[32m${s}\x1b[0m` : s,
9
+ red: (s) => useColor ? `\x1b[31m${s}\x1b[0m` : s,
10
+ yellow: (s) => useColor ? `\x1b[33m${s}\x1b[0m` : s,
11
+ cyan: (s) => useColor ? `\x1b[36m${s}\x1b[0m` : s,
12
+ dim: (s) => useColor ? `\x1b[2m${s}\x1b[0m` : s,
13
+ bold: (s) => useColor ? `\x1b[1m${s}\x1b[0m` : s,
14
+ };
15
+
6
16
  function table(rows, columns) {
7
17
  if (!rows || rows.length === 0) {
8
- console.log('No results found.');
18
+ console.log(c.dim('No results found.'));
9
19
  return;
10
20
  }
11
21
 
@@ -22,8 +32,8 @@ function table(rows, columns) {
22
32
  }
23
33
 
24
34
  // Header
25
- const header = columns.map((col) => col.header.padEnd(widths[col.key])).join(' ');
26
- const separator = columns.map((col) => '-'.repeat(widths[col.key])).join(' ');
35
+ const header = columns.map((col) => c.bold(c.dim(col.header.padEnd(widths[col.key])))).join(' ');
36
+ const separator = columns.map((col) => '\u2500'.repeat(widths[col.key])).join(' ');
27
37
  console.log(header);
28
38
  console.log(separator);
29
39
 
@@ -46,20 +56,25 @@ function json(data) {
46
56
  function keyValue(pairs) {
47
57
  const maxKey = Math.max(...pairs.map(([k]) => k.length));
48
58
  for (const [key, value] of pairs) {
49
- console.log(`${key.padEnd(maxKey)} ${value ?? '-'}`);
59
+ console.log(`${c.dim(key.padEnd(maxKey))} ${value ?? '-'}`);
50
60
  }
51
61
  }
52
62
 
53
63
  function success(msg) {
54
- console.log(`\u2713 ${msg}`);
64
+ const check = process.stdout.isTTY ? '\u2714' : '\u221A';
65
+ console.log(c.green(`${check} ${msg}`));
55
66
  }
56
67
 
57
68
  function error(msg) {
58
- console.error(`Error: ${msg}`);
69
+ console.error(c.red(c.bold(`Error: ${msg}`)));
59
70
  }
60
71
 
61
72
  function warn(msg) {
62
- console.error(`Warning: ${msg}`);
73
+ console.error(c.yellow(`Warning: ${msg}`));
74
+ }
75
+
76
+ function info(msg) {
77
+ console.error(c.cyan(`\u2139 ${msg}`));
63
78
  }
64
79
 
65
- module.exports = { table, json, keyValue, success, error, warn };
80
+ module.exports = { table, json, keyValue, success, error, warn, info };
package/src/utils.js ADDED
@@ -0,0 +1,23 @@
1
+ const readline = require('readline');
2
+
3
+ function formatDate(dateStr) {
4
+ if (!dateStr) return '-';
5
+ return new Date(dateStr).toLocaleDateString();
6
+ }
7
+
8
+ function confirm(message) {
9
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
10
+ return new Promise((resolve) => {
11
+ rl.question(`${message} (y/N) `, (answer) => {
12
+ rl.close();
13
+ resolve(answer.toLowerCase() === 'y');
14
+ });
15
+ });
16
+ }
17
+
18
+ function parseCommaSeparated(value) {
19
+ if (!value) return undefined;
20
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
21
+ }
22
+
23
+ module.exports = { formatDate, confirm, parseCommaSeparated };