troxy-cli 1.8.3 → 1.8.4

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/bin/troxy.js CHANGED
@@ -8,7 +8,6 @@ import { loadConfig } from '../src/config.js';
8
8
  import { runPolicies } from '../src/policies.js';
9
9
  import { runMcps } from '../src/mcps.js';
10
10
  import { runActivity } from '../src/activity.js';
11
- import { runCards } from '../src/cards.js';
12
11
  import { runSettings } from '../src/settings.js';
13
12
  import { runChatBudget } from '../src/chat-budget.js';
14
13
  import { runApprovals } from '../src/approvals.js';
@@ -107,13 +106,15 @@ switch (command) {
107
106
  troxy rotate-key
108
107
  troxy rotate-key --name "My Agent Key"
109
108
 
110
- After rotating: run troxy restart to apply the new key.
109
+ Automatically re-points MCP clients and the background service at the new key.
111
110
  `);
112
111
  process.exit(0);
113
112
  }
114
113
  const jwt = requireJwt();
115
114
  const { loadConfig, saveConfig } = await import('../src/config.js');
116
- const oldKey = loadConfig()?.apiKey;
115
+ const { reprovisionKeyConsumers } = await import('../src/init.js');
116
+ const existing = loadConfig() || {};
117
+ const oldKey = existing.apiKey;
117
118
  const oldPrefix = oldKey ? oldKey.substring(0, 11) : null;
118
119
  const name = flags.name || null; // keep existing name if not specified
119
120
 
@@ -123,11 +124,18 @@ switch (command) {
123
124
  const newPrefix = result.prefix;
124
125
  console.log('✓');
125
126
 
126
- saveConfig({ apiKey: newKey });
127
+ // Merge, don't overwrite — saveConfig({apiKey}) alone used to drop agentName/notify.
128
+ saveConfig({ ...existing, apiKey: newKey });
127
129
  console.log(' Saved to ~/.troxy/config.json ✓');
128
130
 
131
+ // Re-point every real consumer (MCP client configs, the background service) at the new
132
+ // key BEFORE revoking the old one — otherwise every consumer, which holds the key via
133
+ // ENV rather than config.json, keeps presenting the now-revoked key and payment control
134
+ // goes down until the user thinks to re-run init. (H6)
135
+ await reprovisionKeyConsumers(newKey, existing.agentName || 'my-agent');
136
+
129
137
  if (oldPrefix) {
130
- process.stdout.write(` Revoking old key ${oldPrefix}... `);
138
+ process.stdout.write(`\n Revoking old key ${oldPrefix}... `);
131
139
  const { tokens = [] } = await api.listTokens(jwt);
132
140
  const old = tokens.find(t => t.prefix === oldPrefix);
133
141
  if (old) { await api.revokeToken(jwt, old.id); console.log('✓'); }
@@ -142,8 +150,7 @@ switch (command) {
142
150
  New key (shown once, save it now):
143
151
  ${newKey}
144
152
 
145
- ~/.troxy/config.json updated ✓
146
- Run troxy restart to apply the new key.
153
+ ~/.troxy/config.json and all connected MCP clients / the background service updated ✓
147
154
  `);
148
155
  break;
149
156
  }
@@ -187,11 +194,10 @@ switch (command) {
187
194
  --amount <n> Payment amount in USD
188
195
 
189
196
  Optional:
190
- --card <alias> Card alias (default: "Work")
191
197
  --category <cat> Merchant category (e.g. travel, software, food)
192
198
  --no-confirm Don't auto-confirm, leaves the row PENDING
193
199
  --fail Confirm as a failed charge instead of success
194
- (simulates a declined card)
200
+ (simulates a declined charge)
195
201
 
196
202
  Examples:
197
203
  troxy pay --merchant "Amazon" --amount 50
@@ -205,14 +211,13 @@ switch (command) {
205
211
  if (!apiKey) { console.error(' No API key. Run: troxy init --key txy-...\n'); process.exit(1); }
206
212
  const merchant = flags.merchant;
207
213
  const amount = parseFloat(flags.amount);
208
- const card = flags.card || 'Work';
209
214
  const category = flags.category;
210
215
  const noConfirm = !!flags['no-confirm'];
211
216
  const failCharge = !!flags.fail;
212
217
  if (!merchant) { console.error(' --merchant is required\n'); process.exit(1); }
213
218
  if (isNaN(amount)){ console.error(' --amount is required\n'); process.exit(1); }
214
219
  const agentName = loadConfig()?.agentName || 'troxy-cli';
215
- const body = { card_alias: card, merchant_name: merchant, amount, agent: agentName };
220
+ const body = { merchant_name: merchant, amount, agent: agentName, source: 'troxy-cli' };
216
221
  if (category) body.merchant_category = category;
217
222
  const result = await api.evaluate(body, apiKey);
218
223
  const ICON = { ALLOW: '✓', BLOCK: '✗', ESCALATE: '⏳', NOTIFY: '~' };
@@ -232,7 +237,7 @@ switch (command) {
232
237
  await api.confirmPayment(result.audit_id, {
233
238
  status,
234
239
  provider: 'troxy-cli',
235
- ...(failCharge ? { reason: 'simulated card decline (--fail)' } : {}),
240
+ ...(failCharge ? { reason: 'simulated declined charge (--fail)' } : {}),
236
241
  }, apiKey);
237
242
  console.log(` confirmed: charge ${status}`);
238
243
  } catch (e) {
@@ -256,10 +261,6 @@ switch (command) {
256
261
  await runActivity(flags);
257
262
  break;
258
263
 
259
- case 'cards':
260
- await runCards(positional, flags);
261
- break;
262
-
263
264
  case 'settings':
264
265
  await runSettings(positional, flags);
265
266
  break;
@@ -328,10 +329,9 @@ switch (command) {
328
329
  if (!sub || sub === 'policies') { await runPolicies(['list'], flags); break; }
329
330
  if (sub === 'mcps') { await runMcps(['list'], flags); break; }
330
331
  if (sub === 'activity') { await runActivity(flags); break; }
331
- if (sub === 'cards') { await runCards(['list'], flags); break; }
332
332
  if (sub === 'approvals') { await runApprovals(['list'], flags); break; }
333
333
  if (sub === 'secrets') { await runSecrets(['list'], flags); break; }
334
- console.error(` Unknown resource: ${sub}. Try: policies, mcps, activity, cards, approvals, secrets\n`);
334
+ console.error(` Unknown resource: ${sub}. Try: policies, mcps, activity, approvals, secrets\n`);
335
335
  process.exit(1);
336
336
 
337
337
  // ── Status ────────────────────────────────────────────────────
@@ -543,12 +543,6 @@ switch (command) {
543
543
  troxy policies resume --name "X"
544
544
  troxy policies delete --name "X"
545
545
 
546
- Cards
547
- troxy cards list
548
- troxy cards create --name "Work" --last4 1234 --budget 2000
549
- troxy cards update --name "Work" --budget 3000
550
- troxy cards delete --name "Work"
551
-
552
546
  Approvals (ESCALATE holds)
553
547
  troxy approvals list
554
548
  troxy approvals approve --id <id>
@@ -574,8 +568,8 @@ switch (command) {
574
568
  troxy insights [--period 7]
575
569
 
576
570
  Simulate
577
- troxy pay --merchant "Amazon" --amount 50 --card "Work"
578
- troxy pay --merchant "Google" --amount 300 --card "Work" --category software
571
+ troxy pay --merchant "Amazon" --amount 50
572
+ troxy pay --merchant "Google" --amount 300 --category software
579
573
  `);
580
574
  process.exit(command ? 1 : 0);
581
575
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.8.3",
3
+ "version": "1.8.4",
4
4
  "description": "AI payment control — protect your agent's payments with policies",
5
5
  "type": "module",
6
6
  "bin": {
package/src/account.js CHANGED
@@ -8,7 +8,7 @@ function prompt(question) {
8
8
  }
9
9
 
10
10
  const HELP = {
11
- delete: ` troxy account delete\n\n PERMANENTLY deletes your Troxy account: all activity, policies, cards,\n API keys, and the account itself. This cannot be undone. You'll be asked\n to type your account email to confirm.\n`,
11
+ delete: ` troxy account delete\n\n PERMANENTLY deletes your Troxy account: all activity, policies,\n API keys, and the account itself. This cannot be undone. You'll be asked\n to type your account email to confirm.\n`,
12
12
  'clear-data': ` troxy account clear-data\n\n Deletes all activity, policies, and pending approvals. Keeps your account\n and API keys intact, so your MCP connections keep working.\n`,
13
13
  };
14
14
 
@@ -24,7 +24,7 @@ export async function runAccount([sub, ...args], flags) {
24
24
  case 'delete': {
25
25
  const s = await api.getSettings(jwt);
26
26
  console.log(`\n This PERMANENTLY deletes your Troxy account (${s.email}):`);
27
- console.log(' all activity, policies, cards, and API keys. This cannot be undone.\n');
27
+ console.log(' all activity, policies, and API keys. This cannot be undone.\n');
28
28
  const typed = await prompt(` Type your account email to confirm: `);
29
29
  if (typed.trim().toLowerCase() !== s.email.toLowerCase()) {
30
30
  console.log('\n Email did not match. Cancelled.\n');
package/src/api.js CHANGED
@@ -31,12 +31,6 @@ export const api = {
31
31
  cliAuthorize: (jwt, session_id) => request('POST', '/auth/cli/authorize', { jwt, body: { session_id } }),
32
32
  cliExchange: (session_id, code) => request('POST', '/auth/cli/exchange', { body: { session_id, code } }),
33
33
 
34
- // Cards
35
- listCards: (jwt) => request('GET', '/cards', { jwt }),
36
- createCard: (jwt, b) => request('POST', '/cards', { jwt, body: b }),
37
- updateCard: (jwt, id, b) => request('PUT', `/cards/${id}`, { jwt, body: b }),
38
- deleteCard: (jwt, id) => request('DELETE', `/cards/${id}`, { jwt }),
39
-
40
34
  // Policies
41
35
  listPolicies: (jwt) => request('GET', '/dashboard/policies', { jwt }),
42
36
  createPolicy: (jwt, b) => request('POST', '/dashboard/policies', { jwt, body: b }),
@@ -97,7 +91,6 @@ export const api = {
97
91
  agentStatus: (jwt, tokenPrefix) => request('GET', `/agent/status${tokenPrefix ? `?token_prefix=${encodeURIComponent(tokenPrefix)}` : ''}`, { jwt }),
98
92
  agentPolicies: (jwt, tokenPrefix) => request('GET', `/agent/policies${tokenPrefix ? `?token_prefix=${encodeURIComponent(tokenPrefix)}` : ''}`, { jwt }),
99
93
  agentMcps: (jwt, tokenPrefix) => request('GET', `/agent/mcps${tokenPrefix ? `?token_prefix=${encodeURIComponent(tokenPrefix)}` : ''}`, { jwt }),
100
- agentCards: (jwt) => request('GET', '/agent/cards', { jwt }),
101
94
  agentActivity: (jwt, limit, mine, tokenPrefix) => request('GET', `/agent/activity?limit=${limit || 20}${mine ? `&mine=true&token_prefix=${encodeURIComponent(tokenPrefix || '')}` : ''}`, { jwt }),
102
95
  agentInsights: (jwt, period) => request('GET', `/agent/insights?period=${period || 30}`, { jwt }),
103
96
  };
package/src/approvals.js CHANGED
@@ -31,13 +31,12 @@ export async function runApprovals([sub, ...args], flags) {
31
31
  if (!approvals.length) { console.log('\n No pending approvals.\n'); return; }
32
32
  console.log();
33
33
  table(
34
- ['ID', 'Merchant', 'Amount', 'Agent', 'Card', 'Expires'],
34
+ ['ID', 'Merchant', 'Amount', 'Agent', 'Expires'],
35
35
  approvals.map(a => [
36
36
  a.id.slice(0, 8),
37
37
  a.merchant_name,
38
38
  `$${Number(a.amount).toFixed(2)} ${a.currency}`,
39
39
  a.agent_name,
40
- a.card_name,
41
40
  new Date(a.expires_at).toLocaleString(),
42
41
  ]),
43
42
  );
package/src/auth.js CHANGED
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
4
  import readline from 'readline';
5
- import { exec } from 'child_process';
5
+ import { execFile } from 'child_process';
6
6
  import { api } from './api.js';
7
7
 
8
8
  const SESSION_FILE = path.join(os.homedir(), '.troxy', 'session.json');
@@ -16,8 +16,13 @@ export function loadSession() {
16
16
  }
17
17
 
18
18
  export function saveSession(data) {
19
- fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true });
20
- fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2));
19
+ // The session file holds a 12h full-authority JWT — lock it down like the API-key
20
+ // config (0600), not the default ~0644. writeFileSync's mode is ignored when the file
21
+ // already exists, so chmod explicitly after writing. (H5)
22
+ const dir = path.dirname(SESSION_FILE);
23
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
24
+ fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
25
+ try { fs.chmodSync(SESSION_FILE, 0o600); } catch {}
21
26
  }
22
27
 
23
28
  export function clearSession() {
@@ -67,15 +72,27 @@ function loadConfig() {
67
72
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
68
73
  }
69
74
 
75
+ // The login URL comes from the (unauthenticated) api.cliStart response, so treat it as
76
+ // untrusted. Only https URLs are openable; even so we pass the URL as an argv element via
77
+ // execFile (no shell) below, so metacharacters can never reach a shell. Exported for tests.
78
+ export function isOpenableUrl(url) {
79
+ return typeof url === 'string' && /^https:\/\//i.test(url);
80
+ }
81
+
70
82
  function _openBrowser(url) {
71
83
  const isHeadless = process.platform === 'linux'
72
84
  && !process.env.DISPLAY
73
85
  && !process.env.WAYLAND_DISPLAY;
74
86
  if (isHeadless) return;
75
- const cmd = process.platform === 'darwin' ? `open "${url}"`
76
- : process.platform === 'win32' ? `start "" "${url}"`
77
- : `xdg-open "${url}"`;
78
- exec(cmd, () => {});
87
+ if (!isOpenableUrl(url)) return; // (H4)
88
+ if (process.platform === 'darwin') {
89
+ execFile('open', [url], () => {});
90
+ } else if (process.platform === 'win32') {
91
+ // rundll32 opens a URL with no shell parsing of the argument.
92
+ execFile('rundll32', ['url.dll,FileProtocolHandler', url], () => {});
93
+ } else {
94
+ execFile('xdg-open', [url], () => {});
95
+ }
79
96
  }
80
97
 
81
98
  /** Device-code login flow — opens browser, user copies code back to CLI. */
package/src/init.js CHANGED
@@ -6,6 +6,67 @@ import { execSync, execFileSync } from 'child_process';
6
6
  import { saveConfig } from './config.js';
7
7
  import { evaluatePayment, api } from './api.js';
8
8
 
9
+ // Re-point every consumer of the API key (MCP client configs, the background service /
10
+ // systemd env file / launchd plist, OpenClaw) at `key`. Shared by runInit and rotate-key
11
+ // (H6): rotate-key used to write the new key only to config.json and then revoke the old
12
+ // one immediately, so every real consumer — which holds the key via ENV, not config.json —
13
+ // kept presenting the now-revoked key until the user manually re-ran init. Non-interactive
14
+ // (no prompts), so it's safe to call from rotate-key with the existing agent name.
15
+ export async function reprovisionKeyConsumers(key, agentName) {
16
+ const platform = process.platform;
17
+ const detected = MCP_CLIENTS.filter(c => {
18
+ const p = c.path[platform] ?? c.path.linux;
19
+ return fs.existsSync(p);
20
+ });
21
+
22
+ let hasOpenClaw = false;
23
+ try {
24
+ execSync('openclaw --version', { stdio: 'ignore' });
25
+ hasOpenClaw = true;
26
+ } catch {}
27
+
28
+ if (detected.length === 0 && !hasOpenClaw) {
29
+ console.log('\n No MCP clients detected (Claude Desktop, Cursor, Windsurf, OpenClaw).');
30
+ console.log(' Troxy MCP server config:\n');
31
+ console.log(JSON.stringify(mcpEntry(key), null, 4));
32
+ console.log('\n Add the above to your MCP client\'s config under "mcpServers".\n');
33
+ } else {
34
+ console.log('\n MCP clients found:');
35
+ for (const client of detected) {
36
+ const configPath = client.path[platform] ?? client.path.linux;
37
+ try {
38
+ if (client.type === 'zed') patchZedConfig(configPath, key);
39
+ else if (client.type === 'continue') patchContinueConfig(configPath, key);
40
+ else patchMcpConfig(configPath, key);
41
+ console.log(` • ${client.name} ✓`);
42
+ } catch (err) {
43
+ console.log(` • ${client.name} ✗ (${err.message})`);
44
+ }
45
+ }
46
+ if (hasOpenClaw) {
47
+ try {
48
+ const entry = JSON.stringify({ command: 'npx', args: ['troxy-cli', 'mcp'], env: { TROXY_API_KEY: key } });
49
+ // execFileSync passes args directly to the process (no shell), so the
50
+ // key can't break out of a quoted string and inject shell commands.
51
+ execFileSync('openclaw', ['mcp', 'set', 'troxy', entry], { stdio: 'ignore' });
52
+ console.log(` • OpenClaw ✓`);
53
+ } catch (err) {
54
+ console.log(` • OpenClaw ✗ (${err.message})`);
55
+ }
56
+ }
57
+ console.log('\n Restart your MCP client to activate Troxy.');
58
+ }
59
+
60
+ console.log('\n Setting up background service...');
61
+ try {
62
+ installService(key, agentName);
63
+ console.log(' Background service installed ✓');
64
+ } catch (err) {
65
+ console.log(` Background service ✗ (${err.message})`);
66
+ console.log(' You can start it manually with: troxy daemon &');
67
+ }
68
+ }
69
+
9
70
  function prompt(question) {
10
71
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
72
  return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
@@ -69,11 +130,11 @@ export async function runInit({ key } = {}) {
69
130
 
70
131
  console.log('\n Troxy: AI payment control\n');
71
132
 
72
- // Validate the key by hitting /evaluate (404 card = key is valid, TypeError = network failure)
133
+ // Validate the key by hitting /evaluate (404 = key is valid, TypeError = network failure)
73
134
  process.stdout.write(' Validating API key... ');
74
135
  try {
75
136
  await evaluatePayment(
76
- { agent: 'troxy-init', card_alias_name: '__ping__', amount: 0 },
137
+ { agent: 'troxy-init', amount: 0 },
77
138
  key,
78
139
  );
79
140
  console.log('✓');
@@ -90,7 +151,7 @@ export async function runInit({ key } = {}) {
90
151
  console.error('\n Error: Invalid or revoked API key.\n');
91
152
  process.exit(1);
92
153
  }
93
- // 404 (card not found) or any other API error = key is valid, API is up
154
+ // 404 or any other API error = key is valid, API is up
94
155
  console.log('✓');
95
156
  }
96
157
 
@@ -112,61 +173,7 @@ export async function runInit({ key } = {}) {
112
173
  // Non-fatal: the daemon's own heartbeat will retry this later.
113
174
  }
114
175
 
115
- // Detect and patch MCP clients
116
- const platform = process.platform;
117
- const detected = MCP_CLIENTS.filter(c => {
118
- const p = c.path[platform] ?? c.path.linux;
119
- return fs.existsSync(p);
120
- });
121
-
122
- // Detect OpenClaw via CLI
123
- let hasOpenClaw = false;
124
- try {
125
- execSync('openclaw --version', { stdio: 'ignore' });
126
- hasOpenClaw = true;
127
- } catch {}
128
-
129
- if (detected.length === 0 && !hasOpenClaw) {
130
- console.log('\n No MCP clients detected (Claude Desktop, Cursor, Windsurf, OpenClaw).');
131
- console.log(' Troxy MCP server config:\n');
132
- console.log(JSON.stringify(mcpEntry(key), null, 4));
133
- console.log('\n Add the above to your MCP client\'s config under "mcpServers".\n');
134
- } else {
135
- console.log('\n MCP clients found:');
136
- for (const client of detected) {
137
- const configPath = client.path[platform] ?? client.path.linux;
138
- try {
139
- if (client.type === 'zed') patchZedConfig(configPath, key);
140
- else if (client.type === 'continue') patchContinueConfig(configPath, key);
141
- else patchMcpConfig(configPath, key);
142
- console.log(` • ${client.name} ✓`);
143
- } catch (err) {
144
- console.log(` • ${client.name} ✗ (${err.message})`);
145
- }
146
- }
147
- if (hasOpenClaw) {
148
- try {
149
- const entry = JSON.stringify({ command: 'npx', args: ['troxy-cli', 'mcp'], env: { TROXY_API_KEY: key } });
150
- // execFileSync passes args directly to the process (no shell), so the
151
- // key can't break out of a quoted string and inject shell commands.
152
- execFileSync('openclaw', ['mcp', 'set', 'troxy', entry], { stdio: 'ignore' });
153
- console.log(` • OpenClaw ✓`);
154
- } catch (err) {
155
- console.log(` • OpenClaw ✗ (${err.message})`);
156
- }
157
- }
158
- console.log('\n Restart your MCP client to activate Troxy.');
159
- }
160
-
161
- // Install background service so MCP server survives reboots
162
- console.log('\n Setting up background service...');
163
- try {
164
- installService(key, agentName);
165
- console.log(' Background service installed ✓');
166
- } catch (err) {
167
- console.log(` Background service ✗ (${err.message})`);
168
- console.log(' You can start it manually with: troxy daemon &');
169
- }
176
+ await reprovisionKeyConsumers(key, agentName);
170
177
 
171
178
  console.log('\n Your payments are now protected.');
172
179
  console.log(' Dashboard → https://dash.troxy.io');
@@ -0,0 +1,27 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import { isOpenableUrl } from '../auth.js';
5
+
6
+ test('isOpenableUrl accepts plain https and rejects everything else (H4)', () => {
7
+ assert.equal(isOpenableUrl('https://dash.troxy.io/cli?code=abc'), true);
8
+
9
+ // Non-https schemes and non-strings are rejected outright, so a server-controlled
10
+ // login URL can never be handed to a shell. (Even an https URL is only ever passed as a
11
+ // single argv element to execFile, never through a shell.)
12
+ for (const bad of [
13
+ 'http://x',
14
+ 'file:///etc/passwd',
15
+ 'javascript:alert(1)',
16
+ '$(curl evil.example)',
17
+ '`id`',
18
+ '; touch /tmp/pwned',
19
+ 'ftp://x',
20
+ '',
21
+ null,
22
+ undefined,
23
+ 42,
24
+ ]) {
25
+ assert.equal(isOpenableUrl(bad), false, `should reject: ${String(bad)}`);
26
+ }
27
+ });
package/src/cards.js DELETED
@@ -1,107 +0,0 @@
1
- import { api } from './api.js';
2
- import { requireJwt } from './auth.js';
3
- import { table } from './print.js';
4
-
5
- const HELP = {
6
- list: ` troxy cards list\n\n Lists all cards (budget/spend aliases) on your account.\n`,
7
- create: ` troxy cards create --name <name> --last4 <4 digits> [options]\n\n Creates a new card alias. Login required.\n\n Required:\n --name Card alias name (e.g. "Work")\n --last4 Last 4 digits of the card\n\n Optional:\n --type Card type label (default: virtual)\n --budget <n> Monthly budget amount\n --reset-day <n> Day of month the budget resets (default: 1)\n --default-action ALLOW, BLOCK, ESCALATE, or NOTIFY (default: ALLOW)\n --notes <text> Free text notes\n\n Example:\n troxy cards create --name "Work" --last4 1234 --budget 2000\n`,
8
- update: ` troxy cards update --name <name> [options]\n\n Updates an existing card. Login required.\n\n Options:\n --new-name <name> Rename the card\n --budget <n> Set the monthly budget\n --clear-budget Remove the monthly budget\n --reset-day <n> Day of month the budget resets\n --status <status> active or paused\n --default-action ALLOW, BLOCK, ESCALATE, or NOTIFY\n --notes <text> Free text notes\n\n Example:\n troxy cards update --name "Work" --budget 3000\n`,
9
- delete: ` troxy cards delete --name <name>\n\n Permanently deletes a card. Login required.\n`,
10
- };
11
-
12
- export async function runCards([sub, ...args], flags) {
13
- if (flags.help || flags.h) {
14
- console.log('\n' + (HELP[sub] || ` troxy cards <subcommand> [options]\n\n Subcommands:\n list List all cards\n create Create a new card\n update Update an existing card\n delete Delete a card\n\n Run 'troxy cards <subcommand> --help' for subcommand help.\n`));
15
- process.exit(0);
16
- }
17
-
18
- const jwt = requireJwt();
19
-
20
- switch (sub || 'list') {
21
- case 'list': {
22
- const { cards = [] } = await api.listCards(jwt);
23
- if (!cards.length) { console.log('\n No cards yet.\n'); return; }
24
- console.log();
25
- table(
26
- ['Name', 'Last 4', 'Type', 'Budget', 'Used', 'Reset day', 'Status', 'Default action'],
27
- cards.map(c => [
28
- c.name,
29
- c.last4,
30
- c.type,
31
- c.budget != null ? `$${Number(c.budget).toFixed(2)}` : '—',
32
- c.budget != null ? `$${Number(c.budget_used).toFixed(2)}` : '—',
33
- c.reset_day,
34
- c.status,
35
- c.default_action,
36
- ]),
37
- );
38
- break;
39
- }
40
-
41
- case 'create': {
42
- const name = flags.name;
43
- const last4 = String(flags.last4 ?? '');
44
- if (!name) { console.error(' --name is required\n'); process.exit(1); }
45
- if (!/^\d{4}$/.test(last4)) { console.error(' --last4 must be exactly 4 digits\n'); process.exit(1); }
46
- const action = flags['default-action'] ? flags['default-action'].toUpperCase() : undefined;
47
- if (action && !['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY'].includes(action)) {
48
- console.error(' --default-action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n'); process.exit(1);
49
- }
50
- const body = {
51
- name, last4,
52
- type: flags.type || undefined,
53
- budget: flags.budget != null ? parseFloat(flags.budget) : undefined,
54
- reset_day: flags['reset-day'] != null ? parseInt(flags['reset-day'], 10) : undefined,
55
- default_action: action,
56
- notes: flags.notes || undefined,
57
- };
58
- const card = await api.createCard(jwt, body);
59
- console.log(`\n Card "${card.name}" created ✓ (•••• ${card.last4})\n`);
60
- break;
61
- }
62
-
63
- case 'update': {
64
- const name = flags.name;
65
- if (!name) { console.error(' --name is required\n'); process.exit(1); }
66
- const { cards = [] } = await api.listCards(jwt);
67
- const card = cards.find(c => c.name.toLowerCase() === name.toLowerCase());
68
- if (!card) { console.error(` Card "${name}" not found\n`); process.exit(1); }
69
-
70
- const body = {};
71
- if (flags['new-name']) body.name = flags['new-name'];
72
- if (flags['clear-budget']) body.budget = null;
73
- else if (flags.budget != null) body.budget = parseFloat(flags.budget);
74
- if (flags['reset-day'] != null) body.reset_day = parseInt(flags['reset-day'], 10);
75
- if (flags.status) body.status = flags.status;
76
- if (flags['default-action']) {
77
- const action = flags['default-action'].toUpperCase();
78
- if (!['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY'].includes(action)) {
79
- console.error(' --default-action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n'); process.exit(1);
80
- }
81
- body.default_action = action;
82
- }
83
- if (flags.notes != null) body.notes = flags.notes;
84
- if (Object.keys(body).length === 0) { console.error(' Nothing to update, pass at least one option\n'); process.exit(1); }
85
-
86
- await api.updateCard(jwt, card.id, body);
87
- console.log(`\n Card "${name}" updated ✓\n`);
88
- break;
89
- }
90
-
91
- case 'delete': {
92
- const name = flags.name;
93
- if (!name) { console.error(' --name is required\n'); process.exit(1); }
94
- const { cards = [] } = await api.listCards(jwt);
95
- const card = cards.find(c => c.name.toLowerCase() === name.toLowerCase());
96
- if (!card) { console.error(` Card "${name}" not found\n`); process.exit(1); }
97
- await api.deleteCard(jwt, card.id);
98
- console.log(`\n Card "${name}" deleted ✓\n`);
99
- break;
100
- }
101
-
102
- default:
103
- console.error(` Unknown subcommand: ${sub}`);
104
- console.error(' Usage: troxy cards [list|create|update|delete]\n');
105
- process.exit(1);
106
- }
107
- }