troxy-cli 1.7.3 → 1.8.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.
package/bin/troxy.js CHANGED
@@ -8,6 +8,12 @@ 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
+ import { runSettings } from '../src/settings.js';
13
+ import { runChatBudget } from '../src/chat-budget.js';
14
+ import { runApprovals } from '../src/approvals.js';
15
+ import { runAccount } from '../src/account.js';
16
+ import { runSecrets } from '../src/secrets.js';
11
17
  import { api } from '../src/api.js';
12
18
  import { table } from '../src/print.js';
13
19
 
@@ -250,6 +256,30 @@ switch (command) {
250
256
  await runActivity(flags);
251
257
  break;
252
258
 
259
+ case 'cards':
260
+ await runCards(positional, flags);
261
+ break;
262
+
263
+ case 'settings':
264
+ await runSettings(positional, flags);
265
+ break;
266
+
267
+ case 'chat-budget':
268
+ await runChatBudget(positional, flags);
269
+ break;
270
+
271
+ case 'approvals':
272
+ await runApprovals(positional, flags);
273
+ break;
274
+
275
+ case 'account':
276
+ await runAccount(positional, flags);
277
+ break;
278
+
279
+ case 'secrets':
280
+ await runSecrets(positional, flags);
281
+ break;
282
+
253
283
  case 'insights': {
254
284
  if (flags.help || flags.h) {
255
285
  console.log(`
@@ -298,7 +328,10 @@ switch (command) {
298
328
  if (!sub || sub === 'policies') { await runPolicies(['list'], flags); break; }
299
329
  if (sub === 'mcps') { await runMcps(['list'], flags); break; }
300
330
  if (sub === 'activity') { await runActivity(flags); break; }
301
- console.error(` Unknown resource: ${sub}. Try: policies, mcps, activity\n`);
331
+ if (sub === 'cards') { await runCards(['list'], flags); break; }
332
+ if (sub === 'approvals') { await runApprovals(['list'], flags); break; }
333
+ if (sub === 'secrets') { await runSecrets(['list'], flags); break; }
334
+ console.error(` Unknown resource: ${sub}. Try: policies, mcps, activity, cards, approvals, secrets\n`);
302
335
  process.exit(1);
303
336
 
304
337
  // ── Status ────────────────────────────────────────────────────
@@ -485,10 +518,15 @@ switch (command) {
485
518
  Everything else requires: troxy login
486
519
 
487
520
  MCP
488
- troxy pause Pause this MCP (blocks all payments)
489
- troxy resume Resume this MCP
490
- troxy mcps list All MCPs in your account
491
- troxy mcps rename --name "x" Rename this machine's MCP
521
+ troxy pause Pause this MCP (blocks all payments)
522
+ troxy resume Resume this MCP
523
+ troxy mcps list All MCPs in your account
524
+ troxy mcps rename --name "x" Rename this machine's MCP
525
+ troxy mcps create --name "x" Create a key for another agent/machine
526
+ troxy mcps revoke --mcp "x" Revoke an MCP's key
527
+ troxy mcps pause/resume --mcp "x" Pause/resume any MCP (not just this one)
528
+ troxy mcps budget --mcp "x" --currency USD --limit 500
529
+ troxy mcps set-default-action --mcp "x" --action BLOCK
492
530
 
493
531
  Keys
494
532
  troxy rotate-key Rotate MCP key (revokes old, saves new locally)
@@ -496,12 +534,40 @@ switch (command) {
496
534
  Policies
497
535
  troxy policies list
498
536
  troxy policies describe --name "Block Amazon"
537
+ troxy policies create --describe "block Amazon purchases over $200" (AI builder)
499
538
  troxy policies create --name "X" --action BLOCK --field amount --operator gte --value 500
500
- troxy policies create --name "X" --action BLOCK --mcp "My Laptop" (scoped to one MCP)
539
+ troxy policies create --name "X" --action BLOCK --mcp "My Laptop,Server" (scoped to specific MCPs)
540
+ troxy policies create --name "X" --action ESCALATE --chat (scoped to Troxy Chat only)
501
541
  troxy policies set-priority --name "X" --priority 10
502
- troxy policies enable --name "X"
503
- troxy policies disable --name "X"
504
- troxy policies delete --name "X"
542
+ troxy policies pause --name "X"
543
+ troxy policies resume --name "X"
544
+ troxy policies delete --name "X"
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
+ Approvals (ESCALATE holds)
553
+ troxy approvals list
554
+ troxy approvals approve --id <id>
555
+ troxy approvals decline --id <id>
556
+
557
+ Settings
558
+ troxy settings show
559
+ troxy settings set --default-action BLOCK --approval-timeout 4
560
+ troxy chat-budget show
561
+ troxy chat-budget set --currency USD --limit 500
562
+
563
+ LLM keys (Troxy Chat)
564
+ troxy secrets list
565
+ troxy secrets set --name "OpenAI"
566
+ troxy secrets delete --name "OpenAI"
567
+
568
+ Account
569
+ troxy account clear-data Wipe activity/policies, keep account + keys
570
+ troxy account delete Permanently delete your account (irreversible)
505
571
 
506
572
  Activity & insights
507
573
  troxy activity [--limit 50] [--mine]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "AI payment control — protect your agent's payments with policies",
5
5
  "type": "module",
6
6
  "bin": {
package/src/account.js ADDED
@@ -0,0 +1,52 @@
1
+ import readline from 'readline';
2
+ import { api } from './api.js';
3
+ import { requireJwt, clearSession } from './auth.js';
4
+
5
+ function prompt(question) {
6
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7
+ return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
8
+ }
9
+
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`,
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
+ };
14
+
15
+ export async function runAccount([sub, ...args], flags) {
16
+ if (flags.help || flags.h) {
17
+ console.log('\n' + (HELP[sub] || ` troxy account <subcommand>\n\n Subcommands:\n delete Permanently delete your account and all data\n clear-data Wipe activity/policies but keep the account and API keys\n\n Run 'troxy account <subcommand> --help' for subcommand help.\n`));
18
+ process.exit(0);
19
+ }
20
+
21
+ const jwt = requireJwt();
22
+
23
+ switch (sub) {
24
+ case 'delete': {
25
+ const s = await api.getSettings(jwt);
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');
28
+ const typed = await prompt(` Type your account email to confirm: `);
29
+ if (typed.trim().toLowerCase() !== s.email.toLowerCase()) {
30
+ console.log('\n Email did not match. Cancelled.\n');
31
+ process.exit(1);
32
+ }
33
+ await api.deleteAccount(jwt);
34
+ clearSession();
35
+ console.log('\n Account deleted. Run troxy uninstall to remove Troxy from this machine.\n');
36
+ break;
37
+ }
38
+
39
+ case 'clear-data': {
40
+ const answer = await prompt(' This deletes all activity, policies, and pending approvals (keeps your account and API keys). Continue? (y/N): ');
41
+ if (answer.toLowerCase() !== 'y') { console.log('\n Cancelled.\n'); process.exit(0); }
42
+ await api.clearData(jwt);
43
+ console.log('\n Activity, policies, and pending approvals cleared ✓\n');
44
+ break;
45
+ }
46
+
47
+ default:
48
+ console.error(` Unknown subcommand: ${sub || '(none)'}`);
49
+ console.error(' Usage: troxy account [delete|clear-data]\n');
50
+ process.exit(1);
51
+ }
52
+ }
package/src/api.js CHANGED
@@ -51,6 +51,29 @@ export const api = {
51
51
  listTokens: (jwt) => request('GET', '/tokens', { jwt }),
52
52
  createToken: (jwt, b) => request('POST', '/tokens', { jwt, body: b }),
53
53
  revokeToken: (jwt, id) => request('DELETE', `/tokens/${id}`, { jwt }),
54
+ updateTokenBudget: (jwt, id, b) => request('PATCH', `/tokens/${id}/budget`, { jwt, body: b }),
55
+ updateTokenDefaultAction: (jwt, id, action) => request('PATCH', `/tokens/${id}`, { jwt, body: { default_action: action } }),
56
+
57
+ // Account settings
58
+ getSettings: (jwt) => request('GET', '/dashboard/settings', { jwt }),
59
+ updateSettings: (jwt, b) => request('PATCH', '/dashboard/settings', { jwt, body: b }),
60
+ updateChatBudget: (jwt, b) => request('PATCH', '/dashboard/chat-budget', { jwt, body: b }),
61
+
62
+ // Approvals
63
+ listApprovals: (jwt) => request('GET', '/dashboard/approvals', { jwt }),
64
+ resolveApproval: (token, resolved) => request('POST', `/approvals/${encodeURIComponent(token)}/${resolved ? 'approve' : 'reject'}`, {}),
65
+
66
+ // Account
67
+ deleteAccount: (jwt) => request('DELETE', '/account', { jwt }),
68
+ clearData: (jwt) => request('DELETE', '/account/data', { jwt }),
69
+
70
+ // Secrets (LLM provider keys)
71
+ listSecrets: (jwt) => request('GET', '/secrets', { jwt }),
72
+ saveSecret: (jwt, b) => request('POST', '/secrets', { jwt, body: b }),
73
+ deleteSecret: (jwt, id) => request('DELETE', `/secrets/${id}`, { jwt }),
74
+
75
+ // AI policy builder
76
+ aiPolicy: (jwt, b) => request('POST', '/dashboard/ai-policy', { jwt, body: b }),
54
77
 
55
78
  // Evaluate + confirm (agent API key)
56
79
  evaluate: (body, apiKey) => request('POST', '/evaluate', { apiKey, body }),
@@ -58,7 +81,7 @@ export const api = {
58
81
  waitApprovalStatus: (token) => request('GET', `/approvals/${encodeURIComponent(token)}/wait`),
59
82
 
60
83
  // MCP heartbeat (agent API key)
61
- mcpHeartbeat: (apiKey, agentName) => request('POST', '/mcp/heartbeat', { apiKey, body: agentName ? { agent_name: agentName } : undefined }),
84
+ mcpHeartbeat: (apiKey, agentName, force) => request('POST', '/mcp/heartbeat', { apiKey, body: agentName ? { agent_name: agentName, force_name: !!force } : undefined }),
62
85
 
63
86
  // MCP status (agent API key — no login needed)
64
87
  mcpStatus: (apiKey) => request('GET', '/mcp/status', { apiKey }),
@@ -69,9 +92,11 @@ export const api = {
69
92
  renameToken: (jwt, id, name) => request('PATCH', `/tokens/${id}/name`, { jwt, body: { name } }),
70
93
 
71
94
  // Agent read-only API (JWT session auth — run: troxy login)
72
- agentStatus: (jwt) => request('GET', '/agent/status', { jwt }),
73
- agentPolicies: (jwt) => request('GET', '/agent/policies', { jwt }),
74
- agentMcps: (jwt) => request('GET', '/agent/mcps', { jwt }),
95
+ // tokenPrefix (this machine's saved key, first 11 chars) lets the backend
96
+ // resolve which MCP is "me" for the applies_to_me / is_me fields.
97
+ agentStatus: (jwt, tokenPrefix) => request('GET', `/agent/status${tokenPrefix ? `?token_prefix=${encodeURIComponent(tokenPrefix)}` : ''}`, { jwt }),
98
+ agentPolicies: (jwt, tokenPrefix) => request('GET', `/agent/policies${tokenPrefix ? `?token_prefix=${encodeURIComponent(tokenPrefix)}` : ''}`, { jwt }),
99
+ agentMcps: (jwt, tokenPrefix) => request('GET', `/agent/mcps${tokenPrefix ? `?token_prefix=${encodeURIComponent(tokenPrefix)}` : ''}`, { jwt }),
75
100
  agentCards: (jwt) => request('GET', '/agent/cards', { jwt }),
76
101
  agentActivity: (jwt, limit, mine, tokenPrefix) => request('GET', `/agent/activity?limit=${limit || 20}${mine ? `&mine=true&token_prefix=${encodeURIComponent(tokenPrefix || '')}` : ''}`, { jwt }),
77
102
  agentInsights: (jwt, period) => request('GET', `/agent/insights?period=${period || 30}`, { jwt }),
@@ -0,0 +1,60 @@
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 approvals list\n\n Lists pending ESCALATE holds waiting for your approval.\n`,
7
+ approve: ` troxy approvals approve --id <id>\n\n Approves a pending transaction. Use the ID shown by 'troxy approvals list'\n (an unambiguous prefix is enough).\n`,
8
+ decline: ` troxy approvals decline --id <id>\n\n Declines a pending transaction. Use the ID shown by 'troxy approvals list'\n (an unambiguous prefix is enough).\n`,
9
+ };
10
+
11
+ async function _find(jwt, idPrefix) {
12
+ if (!idPrefix) { console.error(' --id is required\n'); process.exit(1); }
13
+ const { items: approvals = [] } = await api.listApprovals(jwt);
14
+ const matches = approvals.filter(a => a.id.startsWith(idPrefix));
15
+ if (matches.length === 0) { console.error(` No pending approval starting with "${idPrefix}". Run: troxy approvals list\n`); process.exit(1); }
16
+ if (matches.length > 1) { console.error(` "${idPrefix}" matches ${matches.length} pending approvals — use a longer prefix\n`); process.exit(1); }
17
+ return matches[0];
18
+ }
19
+
20
+ export async function runApprovals([sub, ...args], flags) {
21
+ if (flags.help || flags.h) {
22
+ console.log('\n' + (HELP[sub] || ` troxy approvals <subcommand> [options]\n\n Subcommands:\n list List pending approvals\n approve Approve a pending transaction\n decline Decline a pending transaction\n\n Run 'troxy approvals <subcommand> --help' for subcommand help.\n`));
23
+ process.exit(0);
24
+ }
25
+
26
+ const jwt = requireJwt();
27
+
28
+ switch (sub || 'list') {
29
+ case 'list': {
30
+ const { items: approvals = [] } = await api.listApprovals(jwt);
31
+ if (!approvals.length) { console.log('\n No pending approvals.\n'); return; }
32
+ console.log();
33
+ table(
34
+ ['ID', 'Merchant', 'Amount', 'Agent', 'Card', 'Expires'],
35
+ approvals.map(a => [
36
+ a.id.slice(0, 8),
37
+ a.merchant_name,
38
+ `$${Number(a.amount).toFixed(2)} ${a.currency}`,
39
+ a.agent_name,
40
+ a.card_name,
41
+ new Date(a.expires_at).toLocaleString(),
42
+ ]),
43
+ );
44
+ break;
45
+ }
46
+
47
+ case 'approve':
48
+ case 'decline': {
49
+ const approval = await _find(jwt, flags.id);
50
+ await api.resolveApproval(approval.approval_token, sub === 'approve');
51
+ console.log(`\n ${approval.merchant_name} ($${Number(approval.amount).toFixed(2)}) ${sub === 'approve' ? 'approved' : 'declined'} ✓\n`);
52
+ break;
53
+ }
54
+
55
+ default:
56
+ console.error(` Unknown subcommand: ${sub}`);
57
+ console.error(' Usage: troxy approvals [list|approve|decline]\n');
58
+ process.exit(1);
59
+ }
60
+ }
package/src/cards.js ADDED
@@ -0,0 +1,107 @@
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
+ }
@@ -0,0 +1,73 @@
1
+ import { api } from './api.js';
2
+ import { requireJwt } from './auth.js';
3
+ import { table } from './print.js';
4
+
5
+ const VALID_CURRENCIES = ['USD', 'ILS', 'EUR'];
6
+ const VALID_ACTIONS = ['block', 'allow', 'notify', 'escalate'];
7
+
8
+ const HELP = {
9
+ show: ` troxy chat-budget show\n\n Shows Troxy Chat's monthly budget limits and what happens when they're hit.\n`,
10
+ set: ` troxy chat-budget set [options]\n\n Updates Troxy Chat's monthly budget. Login required.\n\n --currency <cur> USD, ILS, or EUR (default: USD)\n --limit <n> Monthly limit for that currency\n --clear Remove the limit for that currency\n --action <action> block, allow, notify, or escalate — what happens once the limit is hit\n\n Examples:\n troxy chat-budget set --currency USD --limit 500\n troxy chat-budget set --action notify\n troxy chat-budget set --currency EUR --clear\n`,
11
+ };
12
+
13
+ export async function runChatBudget([sub, ...args], flags) {
14
+ if (flags.help || flags.h) {
15
+ console.log('\n' + (HELP[sub] || ` troxy chat-budget <subcommand> [options]\n\n Subcommands:\n show Show Troxy Chat's budget\n set Update Troxy Chat's budget\n\n Run 'troxy chat-budget <subcommand> --help' for subcommand help.\n`));
16
+ process.exit(0);
17
+ }
18
+
19
+ const jwt = requireJwt();
20
+
21
+ switch (sub || 'show') {
22
+ case 'show': {
23
+ const s = await api.getSettings(jwt);
24
+ console.log(`\n Action when budget is hit: ${s.chat_budget_action}\n`);
25
+ if (!s.chat_budget_limits.length) {
26
+ console.log(' No monthly limits set.\n');
27
+ return;
28
+ }
29
+ table(
30
+ ['Currency', 'Limit', 'Used', 'Remaining'],
31
+ s.chat_budget_limits.map(b => [b.currency, b.limit, b.used, (b.limit - b.used).toFixed(2)]),
32
+ );
33
+ break;
34
+ }
35
+
36
+ case 'set': {
37
+ const body = {};
38
+
39
+ if (flags.action) {
40
+ const action = flags.action.toLowerCase();
41
+ if (!VALID_ACTIONS.includes(action)) {
42
+ console.error(` --action must be one of: ${VALID_ACTIONS.join(', ')}\n`); process.exit(1);
43
+ }
44
+ body.budget_action = action;
45
+ }
46
+
47
+ if (flags.limit != null || flags.clear || flags.currency) {
48
+ const currency = (flags.currency || 'USD').toUpperCase();
49
+ if (!VALID_CURRENCIES.includes(currency)) {
50
+ console.error(` --currency must be one of: ${VALID_CURRENCIES.join(', ')}\n`); process.exit(1);
51
+ }
52
+ body.currency = currency;
53
+ if (flags.clear) body.limit = null;
54
+ else if (flags.limit != null) body.limit = parseFloat(flags.limit);
55
+ else { console.error(' --limit is required (or pass --clear to remove the limit)\n'); process.exit(1); }
56
+ }
57
+
58
+ if (Object.keys(body).length === 0) {
59
+ console.error(' Nothing to update — pass at least one option. Run troxy chat-budget set --help\n');
60
+ process.exit(1);
61
+ }
62
+
63
+ await api.updateChatBudget(jwt, body);
64
+ console.log('\n Chat budget updated ✓\n');
65
+ break;
66
+ }
67
+
68
+ default:
69
+ console.error(` Unknown subcommand: ${sub}`);
70
+ console.error(' Usage: troxy chat-budget [show|set]\n');
71
+ process.exit(1);
72
+ }
73
+ }
package/src/config.js CHANGED
@@ -13,6 +13,11 @@ export function loadConfig() {
13
13
  }
14
14
  }
15
15
 
16
+ /** First 11 chars of this machine's saved API key — used to identify "me" to the API. */
17
+ export function localTokenPrefix() {
18
+ return (loadConfig()?.apiKey || '').substring(0, 11) || null;
19
+ }
20
+
16
21
  export function saveConfig(data) {
17
22
  // Owner-only permissions on dir + file — matches what AWS CLI does with
18
23
  // ~/.aws/credentials. Stops other users on the same machine from reading
package/src/daemon.js CHANGED
@@ -19,14 +19,17 @@ export async function runDaemon() {
19
19
 
20
20
  const { apiKey, agentName } = cfg;
21
21
 
22
- const beat = () =>
23
- api.mcpHeartbeat(apiKey, agentName)
22
+ // `force` pushes the config name as the authoritative display name. We only
23
+ // do this on the FIRST beat after (re)start, so a `troxy restart` overrides
24
+ // the dashboard, while regular beats never clobber a dashboard rename.
25
+ const beat = (force) =>
26
+ api.mcpHeartbeat(apiKey, agentName, force)
24
27
  .then(() => process.stderr.write(`[troxy-daemon] heartbeat ok\n`))
25
28
  .catch(err => process.stderr.write(`[troxy-daemon] heartbeat failed: ${err.message}\n`));
26
29
 
27
30
  process.stderr.write(`[troxy-daemon] starting (agent: ${agentName || 'unnamed'})\n`);
28
- await beat();
29
- setInterval(beat, INTERVAL_MS);
31
+ await beat(true); // startup beat: config name wins
32
+ setInterval(() => beat(false), INTERVAL_MS); // regular beats: don't touch name
30
33
 
31
34
  // Keep process alive indefinitely
32
35
  process.stdin.resume();
package/src/init.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 { execSync } from 'child_process';
5
+ import { execSync, execFileSync } from 'child_process';
6
6
  import { saveConfig } from './config.js';
7
7
  import { evaluatePayment } from './api.js';
8
8
 
@@ -138,7 +138,9 @@ export async function runInit({ key } = {}) {
138
138
  if (hasOpenClaw) {
139
139
  try {
140
140
  const entry = JSON.stringify({ command: 'npx', args: ['troxy-cli', 'mcp'], env: { TROXY_API_KEY: key } });
141
- execSync(`openclaw mcp set troxy '${entry}'`, { stdio: 'ignore' });
141
+ // execFileSync passes args directly to the process (no shell), so the
142
+ // key can't break out of a quoted string and inject shell commands.
143
+ execFileSync('openclaw', ['mcp', 'set', 'troxy', entry], { stdio: 'ignore' });
142
144
  console.log(` • OpenClaw ✓`);
143
145
  } catch (err) {
144
146
  console.log(` • OpenClaw ✗ (${err.message})`);
@@ -174,6 +176,17 @@ function installService(apiKey, agentName) {
174
176
  }
175
177
 
176
178
  if (platform === 'linux') {
179
+ // The API key lives in a separate root-owned, 600-permission file loaded via
180
+ // EnvironmentFile= — unit files under /etc/systemd/system are world-readable
181
+ // (mode 644), so putting the key directly in `Environment=` there would leak
182
+ // it to every local user on the machine.
183
+ const envFilePath = '/etc/troxy-mcp.env';
184
+ const envFile = `TROXY_API_KEY=${apiKey}\nTROXY_AGENT_NAME="${agentName}"\n`;
185
+ fs.writeFileSync('/tmp/troxy-mcp.env', envFile, { mode: 0o600 });
186
+ execSync('sudo mv /tmp/troxy-mcp.env ' + envFilePath);
187
+ execSync(`sudo chown root:root ${envFilePath}`);
188
+ execSync(`sudo chmod 600 ${envFilePath}`);
189
+
177
190
  const unit = `[Unit]
178
191
  Description=Troxy MCP Server
179
192
  After=network.target
@@ -183,8 +196,7 @@ ExecStart=${troxy} daemon
183
196
  Restart=always
184
197
  RestartSec=10
185
198
  User=${os.userInfo().username}
186
- Environment=TROXY_API_KEY=${apiKey}
187
- Environment=TROXY_AGENT_NAME="${agentName}"
199
+ EnvironmentFile=${envFilePath}
188
200
 
189
201
  [Install]
190
202
  WantedBy=multi-user.target
@@ -225,7 +237,11 @@ WantedBy=multi-user.target
225
237
  `;
226
238
  const plistPath = path.join(os.homedir(), 'Library/LaunchAgents/ai.troxy.mcp.plist');
227
239
  fs.mkdirSync(path.dirname(plistPath), { recursive: true });
228
- fs.writeFileSync(plistPath, plist);
240
+ // The plist embeds the API key in plaintext (launchd has no EnvironmentFile
241
+ // equivalent) — default file perms are world-readable, so lock it to the
242
+ // owner only. launchd runs as this same user and can still read it.
243
+ fs.writeFileSync(plistPath, plist, { mode: 0o600 });
244
+ fs.chmodSync(plistPath, 0o600);
229
245
  try { execSync(`launchctl unload ${plistPath} 2>/dev/null`); } catch {}
230
246
  execSync(`launchctl load ${plistPath}`);
231
247
 
package/src/mcps.js CHANGED
@@ -1,23 +1,50 @@
1
- import { api } from './api.js';
2
- import { loadConfig, saveConfig } from './config.js';
3
- import { requireJwt } from './auth.js';
4
- import { table } from './print.js';
1
+ import { api } from './api.js';
2
+ import { loadConfig, saveConfig, localTokenPrefix } from './config.js';
3
+ import { requireJwt } from './auth.js';
4
+ import { table } from './print.js';
5
5
 
6
6
  const HELP = {
7
- list: ` troxy mcps list\n\n Lists all MCP connections on your account — name, prefix, status, last seen,\n policies assigned, default action, and which one is this machine.\n`,
8
- rename: ` troxy mcps rename --name <new-name>\n\n Renames this machine's MCP. The new name appears in the dashboard immediately.\n\n Options:\n --name New name for this machine's MCP\n\n Example:\n troxy mcps rename --name "My Laptop"\n`,
7
+ list: ` troxy mcps list\n\n Lists all MCP connections on your account — name, prefix, status, last seen,\n policies assigned, default action, budgets, and which one is this machine.\n`,
8
+ rename: ` troxy mcps rename --name <new-name> [--mcp <target>]\n\n Renames an MCP. Without --mcp, renames this machine's MCP.\n\n Options:\n --name New name\n --mcp Name/prefix of the MCP to rename (default: this machine)\n\n Example:\n troxy mcps rename --name "My Laptop"\n troxy mcps rename --mcp "old-name" --name "Staging Server"\n`,
9
+ create: ` troxy mcps create --name <name>\n\n Creates a new API key for another agent/machine. The key is shown once —\n save it, then run 'troxy init --key <key>' on that machine.\n\n Options:\n --name Label for the new key\n`,
10
+ revoke: ` troxy mcps revoke --mcp <name>\n\n Permanently revokes an MCP's API key. That machine will stop being able\n to evaluate payments until reconnected with a new key.\n`,
11
+ pause: ` troxy pause / troxy mcps pause [--mcp <name>]\n\n Pauses an MCP (blocks all its payment evaluations). Without --mcp, pauses\n this machine's MCP — same as running 'troxy pause'.\n`,
12
+ resume: ` troxy resume / troxy mcps resume [--mcp <name>]\n\n Resumes a paused MCP. Without --mcp, resumes this machine's MCP.\n`,
13
+ budget: ` troxy mcps budget --mcp <name> [options]\n\n Sets or clears a monthly spend limit for a specific MCP.\n\n Options:\n --mcp <name> MCP to configure (required)\n --currency <cur> USD, ILS, or EUR (default: USD)\n --limit <n> Monthly limit\n --clear Remove the limit for that currency\n --action <action> block, escalate, notify, or allow — what happens once hit\n --reset-usage Reset this cycle's usage back to 0\n\n Example:\n troxy mcps budget --mcp "My Laptop" --currency USD --limit 500\n`,
14
+ 'set-default-action': ` troxy mcps set-default-action --mcp <name> --action <action>\n\n Sets the fallback decision for an MCP when no policy matches.\n\n Options:\n --mcp <name> MCP to configure (required)\n --action <action> ALLOW, BLOCK, ESCALATE, or NOTIFY\n`,
9
15
  };
10
16
 
11
- export async function runMcps([sub], flags) {
17
+ async function _findToken(jwt, needle) {
18
+ const { tokens = [] } = await api.listTokens(jwt);
19
+ const n = needle.toLowerCase();
20
+ const match = tokens.find(t =>
21
+ (t.name && t.name.toLowerCase() === n) ||
22
+ (t.agent_name && t.agent_name.toLowerCase() === n) ||
23
+ (t.prefix && t.prefix.toLowerCase().startsWith(n))
24
+ );
25
+ if (!match) { console.error(`\n MCP "${needle}" not found. Run: troxy mcps list\n`); process.exit(1); }
26
+ return match;
27
+ }
28
+
29
+ async function _localToken(jwt) {
30
+ const prefix = localTokenPrefix();
31
+ if (!prefix) { console.error('\n No API key found. Run: troxy init --key txy-...\n'); process.exit(1); }
32
+ const { tokens = [] } = await api.listTokens(jwt);
33
+ const tok = tokens.find(t => t.prefix === prefix);
34
+ if (!tok) { console.error('\n Could not find this machine\'s MCP. Run: troxy init --key txy-...\n'); process.exit(1); }
35
+ return tok;
36
+ }
37
+
38
+ export async function runMcps([sub, ...args], flags) {
12
39
  if (flags.help || flags.h) {
13
- console.log('\n' + (HELP[sub] || ` troxy mcps <subcommand> [options]\n\n Subcommands:\n list List all MCP connections\n rename Rename this machine's MCP\n\n Run 'troxy mcps <subcommand> --help' for subcommand help.\n`));
40
+ console.log('\n' + (HELP[sub] || ` troxy mcps <subcommand> [options]\n\n Subcommands:\n list List all MCP connections\n rename Rename an MCP\n create Create a new API key for another agent\n revoke Revoke an MCP's key\n pause / resume Pause or resume an MCP\n budget Set/clear a per-MCP monthly budget\n set-default-action Set the fallback decision for an MCP\n\n Run 'troxy mcps <subcommand> --help' for subcommand help.\n`));
14
41
  process.exit(0);
15
42
  }
16
43
 
17
44
  switch (sub || 'list') {
18
45
  case 'list': {
19
46
  const jwt = requireJwt();
20
- const data = await api.agentMcps(jwt);
47
+ const data = await api.agentMcps(jwt, localTokenPrefix());
21
48
  const mcps = data?.mcps || [];
22
49
  if (!mcps.length) { console.log('\n No MCP connections yet.\n'); return; }
23
50
  console.log();
@@ -38,25 +65,91 @@ export async function runMcps([sub], flags) {
38
65
 
39
66
  case 'rename': {
40
67
  const name = flags.name;
41
- if (!name) { console.error('\n Usage: troxy mcps rename --name "new-name"\n'); process.exit(1); }
68
+ if (!name) { console.error('\n Usage: troxy mcps rename --name "new-name" [--mcp <target>]\n'); process.exit(1); }
42
69
  const jwt = requireJwt();
43
- const config = loadConfig();
44
- const prefix = (config?.apiKey || '').substring(0, 11);
45
- if (!prefix) { console.error('\n No API key found. Run: troxy init --key txy-...\n'); process.exit(1); }
46
70
  process.stdout.write(`\n Renaming MCP to "${name}"... `);
47
- const { tokens = [] } = await api.listTokens(jwt);
48
- const tok = tokens.find(t => t.prefix === prefix);
49
- if (!tok) { console.error('\nCould not find this machine\'s MCP. Run: troxy init --key txy-...\n'); process.exit(1); }
71
+ const tok = flags.mcp ? await _findToken(jwt, flags.mcp) : await _localToken(jwt);
50
72
  await api.renameToken(jwt, tok.id, name);
51
- saveConfig({ ...config, agentName: name });
73
+ if (!flags.mcp) {
74
+ const config = loadConfig();
75
+ saveConfig({ ...config, agentName: name });
76
+ }
52
77
  console.log('✓');
53
78
  console.log(' Dashboard and future heartbeats will use the new name.\n');
54
79
  break;
55
80
  }
56
81
 
82
+ case 'create': {
83
+ const name = flags.name;
84
+ if (!name) { console.error('\n --name is required\n'); process.exit(1); }
85
+ const jwt = requireJwt();
86
+ const result = await api.createToken(jwt, { name });
87
+ console.log(`\n API key created for "${name}":\n`);
88
+ console.log(` ${result.key}\n`);
89
+ console.log(' Shown once — save it now. Run "troxy init --key <key>" on that machine to connect it.\n');
90
+ break;
91
+ }
92
+
93
+ case 'revoke': {
94
+ if (!flags.mcp) { console.error('\n --mcp is required\n'); process.exit(1); }
95
+ const jwt = requireJwt();
96
+ const tok = await _findToken(jwt, flags.mcp);
97
+ await api.revokeToken(jwt, tok.id);
98
+ console.log(`\n MCP "${tok.name || tok.prefix}" revoked ✓\n`);
99
+ break;
100
+ }
101
+
102
+ case 'pause':
103
+ case 'resume': {
104
+ const jwt = requireJwt();
105
+ const tok = flags.mcp ? await _findToken(jwt, flags.mcp) : await _localToken(jwt);
106
+ if (sub === 'pause') await api.pauseToken(jwt, tok.id);
107
+ else await api.resumeToken(jwt, tok.id);
108
+ console.log(`\n MCP "${tok.name || tok.prefix}" ${sub}d ✓\n`);
109
+ break;
110
+ }
111
+
112
+ case 'budget': {
113
+ if (!flags.mcp) { console.error('\n --mcp is required\n'); process.exit(1); }
114
+ const jwt = requireJwt();
115
+ const tok = await _findToken(jwt, flags.mcp);
116
+ const body = {};
117
+ if (flags.action) {
118
+ const action = flags.action.toLowerCase();
119
+ if (!['block', 'escalate', 'notify', 'allow'].includes(action)) {
120
+ console.error(' --action must be block, escalate, notify, or allow\n'); process.exit(1);
121
+ }
122
+ body.budget_action = action;
123
+ }
124
+ if (flags.limit != null || flags.clear || flags.currency) {
125
+ body.currency = (flags.currency || 'USD').toUpperCase();
126
+ if (flags.clear) body.limit = null;
127
+ else if (flags.limit != null) body.limit = parseFloat(flags.limit);
128
+ else { console.error(' --limit is required (or pass --clear)\n'); process.exit(1); }
129
+ }
130
+ if (flags['reset-usage']) body.reset_usage = true;
131
+ if (Object.keys(body).length === 0) { console.error(' Nothing to update — pass at least one option\n'); process.exit(1); }
132
+ await api.updateTokenBudget(jwt, tok.id, body);
133
+ console.log(`\n Budget updated for "${tok.name || tok.prefix}" ✓\n`);
134
+ break;
135
+ }
136
+
137
+ case 'set-default-action': {
138
+ if (!flags.mcp) { console.error('\n --mcp is required\n'); process.exit(1); }
139
+ const action = (flags.action || '').toUpperCase();
140
+ if (!['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY'].includes(action)) {
141
+ console.error(' --action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n'); process.exit(1);
142
+ }
143
+ const jwt = requireJwt();
144
+ const tok = await _findToken(jwt, flags.mcp);
145
+ await api.updateTokenDefaultAction(jwt, tok.id, action);
146
+ console.log(`\n Default action for "${tok.name || tok.prefix}" set to ${action} ✓\n`);
147
+ break;
148
+ }
149
+
57
150
  default:
58
151
  console.error(` Unknown subcommand: ${sub}`);
59
- console.error(' Usage: troxy mcps [list|rename]\n');
152
+ console.error(' Usage: troxy mcps [list|rename|create|revoke|pause|resume|budget|set-default-action]\n');
60
153
  process.exit(1);
61
154
  }
62
155
  }
package/src/policies.js CHANGED
@@ -1,19 +1,20 @@
1
+ import readline from 'readline';
1
2
  import { api } from './api.js';
2
3
  import { requireJwt } from './auth.js';
3
4
  import { table } from './print.js';
5
+ import { localTokenPrefix } from './config.js';
4
6
 
5
7
  const DECISION_ICON = { ALLOW: '✓', BLOCK: '✗', ESCALATE: '⏳', NOTIFY: '~', TIERED: '⊕' };
6
8
 
7
- function _scope(p) {
8
- if (p.global !== false) return 'all MCPs';
9
- if (p.mcps && p.mcps.length > 0) return p.mcps.map(m => m.name || m.token_prefix || 'MCP').join(', ');
10
- return 'no MCPs applied';
9
+ function _promptYN(question) {
10
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
+ return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim().toLowerCase() === 'y'); }));
11
12
  }
12
13
 
13
14
  const HELP = {
14
15
  list: ` troxy policies list\n\n Lists all policies in your account with their action, scope, status, and conditions.\n`,
15
16
  describe: ` troxy policies describe --name <policy-name>\n\n Shows full details for a single policy.\n\n Options:\n --name Name of the policy (use single quotes for names with special chars)\n`,
16
- create: ` troxy policies create --name <name> --action <action> [options]\n\n Creates a new policy. Login required.\n\n Required:\n --name Policy name\n --action ALLOW, BLOCK, NOTIFY, or ESCALATE\n\n Optional conditions:\n --field Field to match: amount, merchant_name, tx_per_day\n --operator eq, neq, gt, gte, lt, lte, contains, starts_with, not_contains, between\n --value Comparison value (e.g. 500, amazon)\n --value2 Upper bound for 'between' operator\n\n Priority:\n --priority <n> Explicit priority number (default: auto, max+10)\n Lower number = higher priority (evaluated first).\n\n Scope:\n --mcp <name> Scope policy to a specific MCP only (default: all MCPs)\n Run 'troxy mcps list' to see MCP names.\n\n Examples:\n troxy policies create --name "Block large" --action BLOCK --field amount --operator gte --value 500\n troxy policies create --name "Block Amazon" --action BLOCK --field merchant_name --operator contains --value amazon\n troxy policies create --name "Cap volume" --action BLOCK --field tx_per_day --operator gt --value 20\n troxy policies create --name "Allow Wiki" --action ALLOW --priority 5 --field merchant_name --operator contains --value Wiki\n`,
17
+ create: ` troxy policies create --name <name> --action <action> [options]\n troxy policies create --describe "<plain English>"\n\n Creates a new policy. Login required.\n\n AI builder:\n --describe "<text>" Describe the policy in plain English (e.g. "block\n Amazon purchases over $200") and Troxy AI drafts\n it for you to confirm. Rate-limited per day.\n\n Manual — required:\n --name Policy name\n --action ALLOW, BLOCK, NOTIFY, or ESCALATE\n\n Manual — optional conditions:\n --field Field to match: amount, merchant_name, tx_per_day\n --operator eq, neq, gt, gte, lt, lte, contains, starts_with, not_contains, between\n --value Comparison value (e.g. 500, amazon)\n --value2 Upper bound for 'between' operator\n\n Priority:\n --priority <n> Explicit priority number (default: auto, max+10)\n Lower number = higher priority (evaluated first).\n\n Scope (default: all agents — every MCP plus Troxy Chat):\n --mcp <name> Scope to one or more MCPs (comma-separated).\n Run 'troxy mcps list' to see MCP names.\n --chat Include Troxy Chat. Implied by default; combine with\n --mcp to scope to specific MCPs *and* chat, or use\n alone to scope to Troxy Chat only (no MCPs).\n --no-chat Exclude Troxy Chat from this policy's scope.\n\n Examples:\n troxy policies create --describe "block Amazon purchases over $200"\n troxy policies create --name "Block large" --action BLOCK --field amount --operator gte --value 500\n troxy policies create --name "Block Amazon" --action BLOCK --field merchant_name --operator contains --value amazon\n troxy policies create --name "Cap volume" --action BLOCK --field tx_per_day --operator gt --value 20\n troxy policies create --name "Allow Wiki" --action ALLOW --priority 5 --field merchant_name --operator contains --value Wiki\n troxy policies create --name "Laptop only" --action BLOCK --mcp "My Laptop" --field amount --operator gte --value 100\n troxy policies create --name "Multi-agent" --action BLOCK --mcp "My Laptop,Server" --field amount --operator gte --value 100\n troxy policies create --name "Chat only" --action ESCALATE --chat --field amount --operator gte --value 200\n troxy policies create --name "MCPs, no chat" --action BLOCK --no-chat --field merchant_name --operator contains --value casino\n`,
17
18
  'set-priority': ` troxy policies set-priority --name <policy-name> --priority <n>\n\n Changes the priority of a policy. Lower number = higher priority (evaluated first).\n\n Options:\n --name Name of the policy\n --priority New priority number (e.g. 10, 20, 50)\n\n Example:\n troxy policies set-priority --name "Block Wiki" --priority 5\n`,
18
19
  resume: ` troxy policies resume --name <policy-name>\n\n Resumes a paused policy.\n\n Options:\n --name Name of the policy to resume\n`,
19
20
  pause: ` troxy policies pause --name <policy-name>\n\n Pauses a policy without deleting it. The policy stops firing until you resume it.\n\n Options:\n --name Name of the policy to pause\n`,
@@ -33,7 +34,7 @@ export async function runPolicies([sub, ...args], flags) {
33
34
  const jwt = requireJwt();
34
35
  switch (sub || 'list') {
35
36
  case 'list': {
36
- const data = await api.agentPolicies(jwt);
37
+ const data = await api.agentPolicies(jwt, localTokenPrefix());
37
38
  const policies = data?.policies || [];
38
39
  if (!policies.length) { console.log('\n No policies yet.\n'); return; }
39
40
  console.log();
@@ -43,7 +44,7 @@ export async function runPolicies([sub, ...args], flags) {
43
44
  p.priority,
44
45
  p.name,
45
46
  p.action,
46
- _scope(p),
47
+ p.scope,
47
48
  p.enabled ? 'active' : 'paused',
48
49
  _condSummary(p),
49
50
  p.applies_to_me ? '✓' : '—',
@@ -55,10 +56,11 @@ export async function runPolicies([sub, ...args], flags) {
55
56
  case 'describe': {
56
57
  const name = flags.name;
57
58
  if (!name) { console.error(' --name is required (tip: use single quotes for names with special chars)\n'); process.exit(1); }
58
- const data = await api.agentPolicies(jwt);
59
+ const data = await api.agentPolicies(jwt, localTokenPrefix());
59
60
  const p = (data?.policies || []).find(x => x.name.toLowerCase() === name.toLowerCase());
60
61
  if (!p) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
61
62
 
63
+ const mcpList = p.mcps.length ? p.mcps.map(m => m.name).join(', ') : (p.global ? 'all MCPs' : 'none');
62
64
  console.log(`
63
65
  Name: ${p.name}
64
66
  Action: ${p.action}
@@ -66,7 +68,8 @@ export async function runPolicies([sub, ...args], flags) {
66
68
  Status: ${p.enabled ? 'active' : 'paused'}
67
69
  Scope: ${p.scope}
68
70
  Applies here: ${p.applies_to_me ? 'yes' : 'no'}
69
- MCPs: ${p.mcps.length ? p.mcps.map(m => m.name).join(', ') : p.global ? 'all MCPs' : 'none'}
71
+ MCPs: ${mcpList}
72
+ Troxy Chat: ${p.applies_to_chat ? 'yes' : 'no'}
70
73
  Conditions: ${_condDetail(p)}
71
74
  Created: ${new Date(p.created_at).toLocaleDateString()}
72
75
  `);
@@ -81,6 +84,35 @@ export async function runPolicies([sub, ...args], flags) {
81
84
 
82
85
  switch (sub) {
83
86
  case 'create': {
87
+ if (flags.describe) {
88
+ process.stdout.write('\n Asking Troxy AI to build this policy... ');
89
+ const { tokens = [] } = await api.listTokens(jwt);
90
+ const mcps = tokens.map(t => ({ id: t.id, name: t.name || t.agent_name || t.prefix }));
91
+ const draft = await api.aiPolicy(jwt, { text: flags.describe, mcps });
92
+ if (draft.error) { console.log('✗'); console.error(`\n ${draft.error}\n`); process.exit(1); }
93
+ console.log('✓\n');
94
+ console.log(` Name: ${draft.name}`);
95
+ console.log(` Action: ${draft.action}`);
96
+ if (draft.conditions?.length) console.log(` Conditions: ${_condDetail(draft)}`);
97
+ if (draft.mcp_ids?.length) console.log(` Scope: ${draft.mcp_ids.length} MCP(s)`);
98
+ if (draft.warnings?.length) console.log(` Warnings: ${draft.warnings.join('; ')}`);
99
+ if (draft.note) console.log(` Note: ${draft.note}`);
100
+ const ok = await _promptYN('\n Create this policy? (y/N): ');
101
+ if (!ok) { console.log('\n Cancelled.\n'); process.exit(0); }
102
+ const body = {
103
+ name: draft.name, action: draft.action,
104
+ conditions: draft.conditions || [], or_conditions: draft.or_conditions || [],
105
+ enabled: true,
106
+ global: !(draft.mcp_ids && draft.mcp_ids.length),
107
+ mcp_ids: draft.mcp_ids || [],
108
+ applies_to_chat: true,
109
+ source: 'ai',
110
+ };
111
+ const policy = await api.createPolicy(jwt, body);
112
+ console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority})\n`);
113
+ break;
114
+ }
115
+
84
116
  const name = flags.name;
85
117
  const action = (flags.action || '').toUpperCase();
86
118
  if (!name) { console.error(' --name is required\n'); process.exit(1); }
@@ -114,34 +146,60 @@ export async function runPolicies([sub, ...args], flags) {
114
146
  conditions.push(cond);
115
147
  }
116
148
 
117
- // --mcp <name>: scope policy to a specific MCP instead of all
149
+ if (flags.chat && flags['no-chat']) {
150
+ console.error(' --chat and --no-chat cannot both be set\n'); process.exit(1);
151
+ }
152
+
153
+ // --mcp "name" or "name1,name2": scope policy to specific MCPs instead of all.
154
+ // --chat / --no-chat: include or exclude Troxy Chat (default: included).
118
155
  let isGlobal = true;
119
156
  let mcpIds = [];
157
+ let mcpNames = [];
120
158
  if (flags.mcp) {
121
159
  const { tokens = [] } = await api.listTokens(jwt);
122
- const needle = flags.mcp.toLowerCase();
123
- const match = tokens.find(t =>
124
- (t.name && t.name.toLowerCase() === needle) ||
125
- (t.agent_name && t.agent_name.toLowerCase() === needle) ||
126
- (t.prefix && t.prefix.toLowerCase().startsWith(needle))
127
- );
128
- if (!match) {
129
- console.error(`\n MCP "${flags.mcp}" not found. Run: troxy mcps list\n`);
130
- process.exit(1);
160
+ const requested = String(flags.mcp).split(',').map(s => s.trim()).filter(Boolean);
161
+ for (const raw of requested) {
162
+ const needle = raw.toLowerCase();
163
+ const match = tokens.find(t =>
164
+ (t.name && t.name.toLowerCase() === needle) ||
165
+ (t.agent_name && t.agent_name.toLowerCase() === needle) ||
166
+ (t.prefix && t.prefix.toLowerCase().startsWith(needle))
167
+ );
168
+ if (!match) {
169
+ console.error(`\n MCP "${raw}" not found. Run: troxy mcps list\n`);
170
+ process.exit(1);
171
+ }
172
+ mcpIds.push(match.id);
173
+ mcpNames.push(match.name || match.agent_name || match.prefix);
131
174
  }
132
175
  isGlobal = false;
133
- mcpIds = [match.id];
134
- console.log(`\n Scoping to MCP: ${match.name || match.agent_name || match.prefix}`);
176
+ console.log(`\n Scoping to MCP${mcpNames.length > 1 ? 's' : ''}: ${mcpNames.join(', ')}`);
177
+ } else if (flags.chat) {
178
+ // --chat with no --mcp: scope to Troxy Chat only, no MCPs
179
+ isGlobal = false;
180
+ }
181
+
182
+ const appliesToChat = flags['no-chat'] ? false : true;
183
+
184
+ if (!isGlobal && mcpIds.length === 0 && !appliesToChat) {
185
+ console.error(' Nothing to scope to — pass --mcp, --chat, or drop --no-chat\n'); process.exit(1);
135
186
  }
136
187
 
137
188
  const priority = flags.priority != null ? parseInt(flags.priority, 10) : undefined;
138
189
  if (flags.priority != null && isNaN(priority)) {
139
190
  console.error(' --priority must be a number\n'); process.exit(1);
140
191
  }
141
- const body = { name, action, conditions, enabled: true, global: isGlobal, mcp_ids: mcpIds };
192
+ const body = { name, action, conditions, enabled: true, global: isGlobal, mcp_ids: mcpIds, applies_to_chat: appliesToChat };
142
193
  if (priority != null) body.priority = priority;
143
194
  const policy = await api.createPolicy(jwt, body);
144
- const scope = isGlobal ? 'all MCPs' : (policy.mcps?.[0]?.name || flags.mcp);
195
+ let scope;
196
+ if (isGlobal) {
197
+ scope = appliesToChat ? 'all agents' : 'all MCPs';
198
+ } else {
199
+ const parts = mcpNames.slice();
200
+ if (appliesToChat) parts.push('Troxy Chat');
201
+ scope = parts.length ? parts.join(', ') : 'no agents';
202
+ }
145
203
  console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority}, scope: ${scope})\n`);
146
204
  break;
147
205
  }
package/src/secrets.js ADDED
@@ -0,0 +1,101 @@
1
+ import readline from 'readline';
2
+ import { api } from './api.js';
3
+ import { requireJwt } from './auth.js';
4
+ import { table } from './print.js';
5
+
6
+ const HELP = {
7
+ list: ` troxy secrets list\n\n Lists your saved LLM provider keys (used by Troxy Chat). Values are\n never shown, only a masked preview.\n`,
8
+ set: ` troxy secrets set --name <name> [--type api_key|text]\n\n Saves or updates an LLM provider key. You'll be prompted for the value\n so it never appears in your shell history. Login required.\n\n Options:\n --name Label for this key (e.g. "OpenAI", "Anthropic")\n --type api_key (default) or text\n\n Card secrets are dashboard-only — troxy secrets does not handle them.\n\n Example:\n troxy secrets set --name "OpenAI"\n`,
9
+ delete: ` troxy secrets delete --name <name>\n\n Deletes a saved key. Login required.\n`,
10
+ };
11
+
12
+ const CTRL_C = '';
13
+ const BACKSPACE = '';
14
+
15
+ function promptHidden(question) {
16
+ return new Promise(resolve => {
17
+ const rl = readline.createInterface({ input: process.stdin, output: null });
18
+ process.stdout.write(question);
19
+ let buf = '';
20
+ process.stdin.setRawMode(true);
21
+ process.stdin.resume();
22
+ process.stdin.setEncoding('utf8');
23
+ const onData = chunk => {
24
+ for (const ch of chunk) {
25
+ if (ch === '\r' || ch === '\n') {
26
+ process.stdin.setRawMode(false);
27
+ process.stdin.pause();
28
+ process.stdin.removeListener('data', onData);
29
+ rl.close();
30
+ process.stdout.write('\n');
31
+ resolve(buf.trim());
32
+ return;
33
+ } else if (ch === CTRL_C) {
34
+ process.stdout.write('\n');
35
+ process.exit(0);
36
+ } else if (ch === BACKSPACE || ch === '\b') {
37
+ if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
38
+ } else if (ch >= ' ') {
39
+ buf += ch;
40
+ process.stdout.write('•');
41
+ }
42
+ }
43
+ };
44
+ process.stdin.on('data', onData);
45
+ });
46
+ }
47
+
48
+ export async function runSecrets([sub, ...args], flags) {
49
+ if (flags.help || flags.h) {
50
+ console.log('\n' + (HELP[sub] || ` troxy secrets <subcommand> [options]\n\n Subcommands:\n list List saved LLM provider keys\n set Save or update a key\n delete Delete a key\n\n Run 'troxy secrets <subcommand> --help' for subcommand help.\n`));
51
+ process.exit(0);
52
+ }
53
+
54
+ const jwt = requireJwt();
55
+
56
+ switch (sub || 'list') {
57
+ case 'list': {
58
+ const { secrets = [] } = await api.listSecrets(jwt);
59
+ if (!secrets.length) { console.log('\n No secrets saved yet.\n'); return; }
60
+ console.log();
61
+ table(
62
+ ['Name', 'Type', 'Preview', 'Updated'],
63
+ secrets.map(s => [s.name, s.secret_type, s.masked, new Date(s.updated_at).toLocaleDateString()]),
64
+ );
65
+ if (secrets.some(s => s.secret_type === 'credit_card')) {
66
+ console.log(' Card secrets can only be managed from the dashboard.\n');
67
+ }
68
+ break;
69
+ }
70
+
71
+ case 'set': {
72
+ const name = flags.name;
73
+ if (!name) { console.error(' --name is required\n'); process.exit(1); }
74
+ const type = (flags.type || 'api_key').toLowerCase();
75
+ if (!['api_key', 'text'].includes(type)) {
76
+ console.error(' --type must be api_key or text. Card secrets are dashboard-only.\n'); process.exit(1);
77
+ }
78
+ const value = await promptHidden(` Value for "${name}": `);
79
+ if (!value) { console.error(' No value entered. Cancelled.\n'); process.exit(1); }
80
+ await api.saveSecret(jwt, { name, secret_type: type, value });
81
+ console.log(`\n Secret "${name}" saved ✓\n`);
82
+ break;
83
+ }
84
+
85
+ case 'delete': {
86
+ const name = flags.name;
87
+ if (!name) { console.error(' --name is required\n'); process.exit(1); }
88
+ const { secrets = [] } = await api.listSecrets(jwt);
89
+ const secret = secrets.find(s => s.name.toLowerCase() === name.toLowerCase());
90
+ if (!secret) { console.error(` Secret "${name}" not found\n`); process.exit(1); }
91
+ await api.deleteSecret(jwt, secret.kind);
92
+ console.log(`\n Secret "${name}" deleted ✓\n`);
93
+ break;
94
+ }
95
+
96
+ default:
97
+ console.error(` Unknown subcommand: ${sub}`);
98
+ console.error(' Usage: troxy secrets [list|set|delete]\n');
99
+ process.exit(1);
100
+ }
101
+ }
@@ -0,0 +1,86 @@
1
+ import { api } from './api.js';
2
+ import { requireJwt } from './auth.js';
3
+
4
+ const VALID_TIMEOUTS = [0, 0.083, 1, 4, 8, 24, 72];
5
+
6
+ const HELP = {
7
+ show: ` troxy settings show\n\n Shows your account settings: default action, approval timeout, and\n notification preferences.\n`,
8
+ set: ` troxy settings set [options]\n\n Updates account settings. Login required. Pass any combination of:\n\n --default-action <action> ALLOW, BLOCK, ESCALATE, or NOTIFY\n (used when no policy matches a transaction)\n --approval-timeout <hours> ${VALID_TIMEOUTS.join(', ')}\n (how long an ESCALATE hold waits before auto-resolving)\n --notify-email <email> Where to send decision notification emails\n --notify-on-block Email me when a transaction is blocked\n --no-notify-on-block\n --notify-on-escalate Email me when a transaction is escalated\n --no-notify-on-escalate\n --notify-on-notify Email me on NOTIFY decisions\n --no-notify-on-notify\n --notify-on-allow Email me when a transaction is allowed\n --no-notify-on-allow\n\n Example:\n troxy settings set --default-action BLOCK --approval-timeout 4\n`,
9
+ };
10
+
11
+ export async function runSettings([sub, ...args], flags) {
12
+ if (flags.help || flags.h) {
13
+ console.log('\n' + (HELP[sub] || ` troxy settings <subcommand> [options]\n\n Subcommands:\n show Show current settings\n set Update settings\n\n Run 'troxy settings <subcommand> --help' for subcommand help.\n`));
14
+ process.exit(0);
15
+ }
16
+
17
+ const jwt = requireJwt();
18
+
19
+ switch (sub || 'show') {
20
+ case 'show': {
21
+ const s = await api.getSettings(jwt);
22
+ console.log(`
23
+ Email: ${s.email}
24
+ Default action: ${s.default_action}
25
+ Approval timeout: ${s.approval_timeout_hours} hour${s.approval_timeout_hours === 1 ? '' : 's'}
26
+
27
+ Notifications
28
+ On block: ${s.notify_prefs.email_on_block ? 'yes' : 'no'}
29
+ On escalate: ${s.notify_prefs.email_on_escalate ? 'yes' : 'no'}
30
+ On notify: ${s.notify_prefs.email_on_notify ? 'yes' : 'no'}
31
+ On allow: ${s.notify_prefs.email_on_allow ? 'yes' : 'no'}
32
+ Notify email: ${s.notify_prefs.notify_email || '(account email)'}
33
+
34
+ Chat budget action: ${s.chat_budget_action}
35
+ Chat budget limits: ${s.chat_budget_limits.length ? s.chat_budget_limits.map(b => `${b.currency} ${b.used}/${b.limit}`).join(', ') : 'none set — run troxy chat-budget'}
36
+ `);
37
+ break;
38
+ }
39
+
40
+ case 'set': {
41
+ const body = {};
42
+
43
+ if (flags['default-action']) {
44
+ const action = flags['default-action'].toUpperCase();
45
+ if (!['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY'].includes(action)) {
46
+ console.error(' --default-action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n'); process.exit(1);
47
+ }
48
+ body.default_action = action;
49
+ }
50
+
51
+ if (flags['approval-timeout'] != null) {
52
+ const timeout = Number(flags['approval-timeout']);
53
+ if (!VALID_TIMEOUTS.includes(timeout)) {
54
+ console.error(` --approval-timeout must be one of: ${VALID_TIMEOUTS.join(', ')}\n`); process.exit(1);
55
+ }
56
+ body.approval_timeout_hours = timeout;
57
+ }
58
+
59
+ const notify_prefs = {};
60
+ if (flags['notify-email'] != null) notify_prefs.notify_email = flags['notify-email'];
61
+ if (flags['notify-on-block']) notify_prefs.email_on_block = true;
62
+ if (flags['no-notify-on-block']) notify_prefs.email_on_block = false;
63
+ if (flags['notify-on-escalate']) notify_prefs.email_on_escalate = true;
64
+ if (flags['no-notify-on-escalate']) notify_prefs.email_on_escalate = false;
65
+ if (flags['notify-on-notify']) notify_prefs.email_on_notify = true;
66
+ if (flags['no-notify-on-notify']) notify_prefs.email_on_notify = false;
67
+ if (flags['notify-on-allow']) notify_prefs.email_on_allow = true;
68
+ if (flags['no-notify-on-allow']) notify_prefs.email_on_allow = false;
69
+ if (Object.keys(notify_prefs).length) body.notify_prefs = notify_prefs;
70
+
71
+ if (Object.keys(body).length === 0) {
72
+ console.error(' Nothing to update — pass at least one option. Run troxy settings set --help\n');
73
+ process.exit(1);
74
+ }
75
+
76
+ await api.updateSettings(jwt, body);
77
+ console.log('\n Settings updated ✓\n');
78
+ break;
79
+ }
80
+
81
+ default:
82
+ console.error(` Unknown subcommand: ${sub}`);
83
+ console.error(' Usage: troxy settings [show|set]\n');
84
+ process.exit(1);
85
+ }
86
+ }
package/src/uninstall.js CHANGED
@@ -44,6 +44,7 @@ function removeService() {
44
44
  try { execSync('sudo systemctl stop troxy-mcp 2>/dev/null'); } catch {}
45
45
  try { execSync('sudo systemctl disable troxy-mcp 2>/dev/null'); } catch {}
46
46
  try { execSync('sudo rm -f /etc/systemd/system/troxy-mcp.service'); } catch {}
47
+ try { execSync('sudo rm -f /etc/troxy-mcp.env'); } catch {}
47
48
  try { execSync('sudo systemctl daemon-reload'); } catch {}
48
49
  return true;
49
50