troxy-cli 1.22.0 → 1.24.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/README.md CHANGED
@@ -50,8 +50,9 @@ npx troxy-cli <command>
50
50
  |---------|-------------|
51
51
  | `troxy init` | Connect an agent to Troxy — validates API key, sets agent name, patches MCP configs |
52
52
  | `troxy login` | Start a 12-hour CLI session (opens browser → copy code → paste into terminal) |
53
+ | `troxy tools` | Scan for installed AI coding tools and set your subscription plan for each |
53
54
  | `troxy mcps` | List connected MCP agents and their status |
54
- | `troxy policies` | List and manage policies |
55
+ | `troxy policies` | List every org/space policy that applies to you (creating one is admin-only) |
55
56
  | `troxy activity` | View recent transaction audit log |
56
57
  | `troxy insights` | Spending stats and decision breakdown |
57
58
  | `troxy status` | Show connection status and account overview |
package/bin/troxy.js CHANGED
@@ -9,7 +9,6 @@ import { runPolicies } from '../src/policies.js';
9
9
  import { runMcps } from '../src/mcps.js';
10
10
  import { runActivity } from '../src/activity.js';
11
11
  import { runSettings } from '../src/settings.js';
12
- import { runChatBudget } from '../src/chat-budget.js';
13
12
  import { runApprovals } from '../src/approvals.js';
14
13
  import { runAccount } from '../src/account.js';
15
14
  import { runSecrets } from '../src/secrets.js';
@@ -369,10 +368,6 @@ switch (command) {
369
368
  await runSettings(positional, flags);
370
369
  break;
371
370
 
372
- case 'chat-budget':
373
- await runChatBudget(positional, flags);
374
- break;
375
-
376
371
  case 'approvals':
377
372
  await runApprovals(positional, flags);
378
373
  break;
@@ -677,23 +672,25 @@ switch (command) {
677
672
  troxy rotate-key Rotate MCP key (revokes old, saves new locally)
678
673
 
679
674
  Policies (all six checkpoints: payment, comms, access, destructive, model, secrets)
675
+ Admin-only: every write below requires --org. Personal and per-MCP policies
676
+ no longer exist - a policy is always org-wide or space-wide, and org acts as
677
+ a ceiling a space policy can never be looser than. list/describe/test need
678
+ no --org and show every org + space policy that applies to you.
680
679
  troxy policies list / list --org
681
680
  troxy policies describe --name "Block Amazon"
682
681
  troxy policies templates (browse the built-in library)
683
682
  troxy policies fields --domain secrets (see what each checkpoint supports)
684
- troxy policies create --describe "block Amazon purchases over $200" (AI builder, payment/comms only)
685
- troxy policies create --name "X" --template "Block a secret going somewhere public"
686
- troxy policies create --name "X" --action BLOCK --domain payment --field amount --operator gte --value 500
687
- troxy policies create --name "X" --action BLOCK --domain access --field login_action --operator eq --value signup
688
- troxy policies create --name "X" --action BLOCK --domain comms --or "field=recipient_domain,operator=contains,value=gmail.com"
689
- troxy policies create --name "X" --action BLOCK --mcp "My Laptop,Server" (scoped to specific MCPs)
690
- troxy policies create --name "X" --action ESCALATE --chat (scoped to Troxy Chat only)
683
+ troxy policies create --describe "block Amazon purchases over $200" --org (AI builder, payment/comms only)
684
+ troxy policies create --name "X" --template "Block a secret going somewhere public" --org
685
+ troxy policies create --name "X" --action BLOCK --domain payment --field amount --operator gte --value 500 --org
686
+ troxy policies create --name "X" --action BLOCK --domain access --field login_action --operator eq --value signup --org
687
+ troxy policies create --name "X" --action BLOCK --domain comms --or "field=recipient_domain,operator=contains,value=gmail.com" --org
691
688
  troxy policies create --name "X" --action BLOCK --domain secrets --field has_api_key --operator eq --value true --org
692
689
  troxy policies test --domain payment --field amount --operator gt --value 100 --example '{"amount": 150}'
693
- troxy policies set-priority --name "X" --priority 10 [--org]
694
- troxy policies pause --name "X" [--org]
695
- troxy policies resume --name "X" [--org]
696
- troxy policies delete --name "X" [--org]
690
+ troxy policies set-priority --name "X" --priority 10 --org
691
+ troxy policies pause --name "X" --org
692
+ troxy policies resume --name "X" --org
693
+ troxy policies delete --name "X" --org
697
694
  (--org needs admin access; run 'troxy policies create --help' for the full condition syntax)
698
695
 
699
696
  Approvals (ESCALATE holds)
@@ -704,10 +701,8 @@ switch (command) {
704
701
  Settings
705
702
  troxy settings show
706
703
  troxy settings set --default-action BLOCK --approval-timeout 4
707
- troxy chat-budget show
708
- troxy chat-budget set --currency USD --limit 500
709
704
 
710
- LLM keys (Troxy Chat)
705
+ LLM provider keys
711
706
  troxy secrets list
712
707
  troxy secrets set --name "OpenAI"
713
708
  troxy secrets delete --name "OpenAI"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
4
  "description": "A secure control layer for AI agents: policies across payments, messages, logins, destructive actions, model usage, and secrets, all enforceable from the CLI",
5
5
  "homepage": "https://troxy.io",
6
6
  "bugs": {
package/src/api.js CHANGED
@@ -78,7 +78,6 @@ export const api = {
78
78
  // Account settings
79
79
  getSettings: (jwt) => request('GET', '/dashboard/settings', { jwt }),
80
80
  updateSettings: (jwt, b) => request('PATCH', '/dashboard/settings', { jwt, body: b }),
81
- updateChatBudget: (jwt, b) => request('PATCH', '/dashboard/chat-budget', { jwt, body: b }),
82
81
 
83
82
  // Approvals
84
83
  listApprovals: (jwt) => request('GET', '/dashboard/approvals', { jwt }),
package/src/init.js CHANGED
@@ -185,7 +185,7 @@ export function writeAgentsFile(dir, text) {
185
185
  return file;
186
186
  }
187
187
 
188
- function prompt(question) {
188
+ export function prompt(question) {
189
189
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
190
190
  return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
191
191
  }
@@ -564,7 +564,7 @@ export function patchClaudeCodeHooks(
564
564
  // single marker *file* whose mere existence means "Claude Code is
565
565
  // installed" - `~/.claude.json` and `~/.claude/settings.json` are both
566
566
  // created lazily and may not exist yet on a fresh install.
567
- function hasClaudeCode() {
567
+ export function hasClaudeCode() {
568
568
  try {
569
569
  execSync('claude --version', { stdio: 'ignore' });
570
570
  return true;
package/src/mcp-server.js CHANGED
@@ -81,7 +81,7 @@ export async function runMcp() {
81
81
  // Token Optimization is supposed to be a precise record, not a mood.
82
82
  // The trigger below is mechanical instead: did this turn use a tool,
83
83
  // yes or no. That is a fact you can check, not a judgment you make.
84
- 'You MUST also call the evaluate_model tool at the start of ANY turn where you will use one or more tools of any kind — reading or writing a file, running a command, browsing, calling an external API, anything beyond a plain text reply — and whenever you switch model or effort level partway through the turn. This is not a judgment call about how the turn feels: if you are about to use a tool, call evaluate_model first, every time, regardless of how small the turn seems, and WAIT for the decision before proceeding. Give the exact model id and how many tokens you expect to use. Do not use any tool without first receiving an ALLOW or NOTIFY decision; if BLOCK, pick a cheaper or smaller model and call evaluate_model again with the new id; if ESCALATE, call wait_for_approval and do not proceed until it resolves. Then, at the end of that same turn, you MUST call report_model_usage with the audit_id from that decision and the real token total from your model API. Both halves are required: without the second call Troxy has only your prediction, and the account owner is shown that this agent\'s costs are unverified. Report the true number even when it is far above your estimate. ' +
84
+ 'You MUST also call the evaluate_model tool at the start of ANY turn where you will use one or more tools of any kind — reading or writing a file, running a command, browsing, calling an external API, anything beyond a plain text reply — and whenever you switch model or effort level partway through the turn. This is not a judgment call about how the turn feels: if you are about to use a tool, call evaluate_model first, every time, regardless of how small the turn seems, and WAIT for the decision before proceeding. Give the exact model id, how many tokens you expect to use, and how many tool calls you expect to make this turn (estimated_tool_count). Do not use any tool without first receiving an ALLOW or NOTIFY decision; if BLOCK, pick a cheaper or smaller model and call evaluate_model again with the new id; if ESCALATE, call wait_for_approval and do not proceed until it resolves. Then, at the end of that same turn, you MUST call report_model_usage with the audit_id from that decision and the real token total from your model API. Both halves are required: without the second call Troxy has only your prediction, and the account owner is shown that this agent\'s costs are unverified. Report the true number even when it is far above your estimate. ' +
85
85
  (hookCaptureActive
86
86
  // Claude Code's own Stop hook already reports real, host-captured
87
87
  // usage for every turn in this session, chat-only included - see
@@ -330,6 +330,10 @@ export async function runMcp() {
330
330
  type: 'number',
331
331
  description: 'How many tokens you expect the task to use in total, input plus output. Troxy prices this itself, so an estimate in tokens is enough; do not convert it to dollars.',
332
332
  },
333
+ estimated_tool_count: {
334
+ type: 'number',
335
+ description: 'How many tool calls you expect to make this turn (reading/writing files, running commands, browsing, calling external APIs, etc). Report your honest expectation, not a running total across the session - Troxy uses this alongside estimated_tokens to judge how complex the task actually is.',
336
+ },
333
337
  effort: {
334
338
  type: 'string',
335
339
  enum: ['low', 'medium', 'high'],
@@ -574,17 +578,23 @@ export async function runMcp() {
574
578
  if (result.error) {
575
579
  return { content: [{ type: 'text', text: `Troxy error: ${result.error}` }], isError: true };
576
580
  }
577
- const { decision, reason, audit_id, approval_token } = result;
581
+ const { decision, reason, audit_id, approval_token, suggested_model, suggestion_reason } = result;
578
582
  const what = `${args.model}${args.effort ? ` at ${args.effort} effort` : ''}`;
579
583
  // Every non-blocked branch repeats the audit_id and the instruction to
580
584
  // report back. Half the value of this checkpoint is the second call, and
581
585
  // an agent that is only told "approved" has no reason to make it.
582
586
  const followUp = ` When this turn finishes, call report_model_usage(audit_id="${audit_id}", actual_tokens=<real total>).`;
587
+ // Advisory only, dark-shipped per org - most orgs never see this field.
588
+ // Same "call again with the new id" pattern a BLOCK already teaches,
589
+ // just optional here: the call is still approved either way.
590
+ const suggestionText = suggested_model
591
+ ? ` Suggestion: ${suggestion_reason || `${suggested_model} would likely work just as well here.`} If you'd like to use it, call evaluate_model again with model="${suggested_model}" — no obligation, this run is already approved as-is.`
592
+ : '';
583
593
  let modelText;
584
594
  switch (decision) {
585
595
  case 'ALLOW':
586
596
  case 'NOTIFY':
587
- modelText = `✓ Approved: ${what}.${reason ? ` ${reason}` : ''} You may run it.${followUp} (audit: ${audit_id})`;
597
+ modelText = `✓ Approved: ${what}.${reason ? ` ${reason}` : ''} You may run it.${followUp}${suggestionText} (audit: ${audit_id})`;
588
598
  break;
589
599
  case 'BLOCK':
590
600
  modelText = `✗ Blocked: ${what}.${reason ? ` ${reason}` : ''} Do not use this model for this task. Choose a cheaper or smaller model and call evaluate_model again with the new model id. (audit: ${audit_id})`;
package/src/mcps.js CHANGED
@@ -60,6 +60,7 @@ export async function runMcps([sub, ...args], flags) {
60
60
  m.is_me ? '← you' : '',
61
61
  ]),
62
62
  );
63
+ console.log(' Policies: org + space policies apply account-wide, so this count is the same for every agent.\n');
63
64
  break;
64
65
  }
65
66
 
package/src/policies.js CHANGED
@@ -30,21 +30,21 @@ function _promptYN(question) {
30
30
  }
31
31
 
32
32
  const HELP = {
33
- list: ` troxy policies list [--org]\n\n Lists policies. --org lists your organization's policies (any member can read them) instead of your personal ones.\n`,
34
- describe: ` troxy policies describe --name <policy-name> [--org]\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 --org Look up an org policy instead of a personal one\n`,
35
- create: ` troxy policies create --name <name> --action <action> --domain <domain> [options]\n troxy policies create --name <name> --action <action> --template "<template label>"\n troxy policies create --describe "<plain English>"\n\n Creates a new policy. Login required.\n\n Checkpoints (--domain), one per Troxy checkpoint - what the policy is about:\n payment paying, buying, sending money (/evaluate)\n comms messages on any channel (email, Slack...) (/evaluate/email)\n access logging in or signing up on a site (/evaluate/login)\n destructive deletes, exports, permission/access changes, other consequential actions (/evaluate/action)\n model the agent's own model, tokens, cost (/evaluate/model)\n secrets content scanned for secrets before it leaves (/evaluate/secrets)\n\n Run 'troxy policies fields --domain <domain>' to see the exact fields and\n operators each checkpoint supports - the same table the server checks\n against, so nothing here can drift from what actually fires.\n\n From a template (fastest, matches the dashboard's own template library):\n --template "<label>" Exact template label - run 'troxy policies templates' to list them\n --value / --value2 Override the template's default threshold (only when it has exactly one condition)\n --action Override the template's default action\n\n Manual, single condition:\n --domain Required whenever --field is given, unless --field is one of\n the original payment fields (amount, merchant_name, tx_per_day),\n which still default to --domain payment for old scripts.\n --field Field to match - see 'troxy policies fields --domain <domain>'\n --operator eq, neq, gt, gte, lt, lte, contains, starts_with, not_contains, between\n --value Comparison value\n --value2 Upper bound for 'between'\n\n Manual, multiple conditions:\n --and "field=X,operator=Y,value=Z" Repeatable. ANDs onto the single --field condition (if any).\n --or "field=X,operator=Y,value=Z" Repeatable. Each becomes its own alternative branch (any\n --and conditions apply to every branch); all branches share --action.\n\n Raw (full expressiveness, for scripting):\n --json '<JSON>' {\"conditions\": [...], \"or_conditions\": [...]} exactly as the API expects.\n Cannot be combined with --field/--and/--or/--template.\n\n AI builder (needs an org AI key; --domain payment or comms only - the other four\n checkpoints aren't covered by this endpoint yet, use --template or a manual condition):\n --describe "<text>" Describe the policy in plain English and Troxy AI drafts it for you to confirm.\n --domain payment (default) or comms - which allowlist the AI drafts against.\n\n Priority:\n --priority <n> Explicit priority number (default: auto, max+10). Lower = evaluated first.\n\n Scope (personal policies only; default: all agents, every MCP plus Troxy Chat):\n --mcp <name> Scope to one or more MCPs (comma-separated). Run 'troxy mcps list' for names.\n --chat Include Troxy Chat. Implied by default; combine with --mcp for MCPs *and* chat.\n --no-chat Exclude Troxy Chat.\n\n Org policies (applies to every member; requires admin):\n --org Create an org-wide policy instead of a personal one. No --mcp/--chat scoping - it's org-wide.\n\n Examples:\n troxy policies create --name "Block a secret going public" --template "Block a secret going somewhere public"\n troxy policies create --name "Cap over 100" --action BLOCK --domain payment --field amount --operator gt --value 100\n troxy policies create --name "Signups" --action ESCALATE --domain access --field login_action --operator eq --value signup\n troxy policies create --name "Big exports" --action BLOCK --domain destructive --field action_verb --operator eq --value export --and "field=resource,operator=contains,value=customer"\n troxy policies create --name "Personal domains" --action BLOCK --domain comms --or "field=recipient_domain,operator=contains,value=gmail.com" --or "field=recipient_domain,operator=contains,value=yahoo.com"\n troxy policies create --name "Lock down secrets" --action BLOCK --domain secrets --field has_api_key --operator eq --value true --org\n`,
33
+ list: ` troxy policies list [--org]\n\n Lists every org and space policy currently in force for you. --org narrows this to just your organization's policies (any member can read them, admin-authored).\n`,
34
+ describe: ` troxy policies describe --name <policy-name> [--org]\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 --org Look up an org policy specifically, instead of the combined org+space view\n`,
35
+ create: ` troxy policies create --name <name> --action <action> --domain <domain> [options]\n troxy policies create --name <name> --action <action> --template "<template label>"\n troxy policies create --describe "<plain English>"\n\n Creates a new policy. Login required. Policy creation is admin-only and\n always org-wide - pass --org (admin access required). Personal-scope and\n per-MCP policies no longer exist; space-scoped creation isn't available\n from the CLI yet, use the dashboard for that.\n\n Checkpoints (--domain), one per Troxy checkpoint - what the policy is about:\n payment paying, buying, sending money (/evaluate)\n comms messages on any channel (email, Slack...) (/evaluate/email)\n access logging in or signing up on a site (/evaluate/login)\n destructive deletes, exports, permission/access changes, other consequential actions (/evaluate/action)\n model the agent's own model, tokens, cost (/evaluate/model)\n secrets content scanned for secrets before it leaves (/evaluate/secrets)\n\n Run 'troxy policies fields --domain <domain>' to see the exact fields and\n operators each checkpoint supports - the same table the server checks\n against, so nothing here can drift from what actually fires.\n\n From a template (fastest, matches the dashboard's own template library):\n --template "<label>" Exact template label - run 'troxy policies templates' to list them\n --value / --value2 Override the template's default threshold (only when it has exactly one condition)\n --action Override the template's default action\n\n Manual, single condition:\n --domain Required whenever --field is given, unless --field is one of\n the original payment fields (amount, merchant_name, tx_per_day),\n which still default to --domain payment for old scripts.\n --field Field to match - see 'troxy policies fields --domain <domain>'\n --operator eq, neq, gt, gte, lt, lte, contains, starts_with, not_contains, between\n --value Comparison value\n --value2 Upper bound for 'between'\n\n Manual, multiple conditions:\n --and "field=X,operator=Y,value=Z" Repeatable. ANDs onto the single --field condition (if any).\n --or "field=X,operator=Y,value=Z" Repeatable. Each becomes its own alternative branch (any\n --and conditions apply to every branch); all branches share --action.\n\n Raw (full expressiveness, for scripting):\n --json '<JSON>' {\"conditions\": [...], \"or_conditions\": [...]} exactly as the API expects.\n Cannot be combined with --field/--and/--or/--template.\n\n AI builder (org AI key, admin required; --domain payment or comms only - the other four\n checkpoints aren't covered by this endpoint yet, use --template or a manual condition):\n --describe "<text>" Describe the policy in plain English and Troxy AI drafts it for you to confirm.\n --domain payment (default) or comms - which allowlist the AI drafts against.\n\n Priority:\n --priority <n> Explicit priority number (default: auto, max+10). Lower = evaluated first.\n\n Org (required; applies to every member):\n --org Admin access required. Every policy created from the CLI is org-wide.\n\n Examples:\n troxy policies create --name "Block a secret going public" --template "Block a secret going somewhere public" --org\n troxy policies create --name "Cap over 100" --action BLOCK --domain payment --field amount --operator gt --value 100 --org\n troxy policies create --name "Signups" --action ESCALATE --domain access --field login_action --operator eq --value signup --org\n troxy policies create --name "Big exports" --action BLOCK --domain destructive --field action_verb --operator eq --value export --and "field=resource,operator=contains,value=customer" --org\n troxy policies create --name "Personal domains" --action BLOCK --domain comms --or "field=recipient_domain,operator=contains,value=gmail.com" --or "field=recipient_domain,operator=contains,value=yahoo.com" --org\n troxy policies create --name "Lock down secrets" --action BLOCK --domain secrets --field has_api_key --operator eq --value true --org\n`,
36
36
  fields: ` troxy policies fields [--domain <domain>]\n\n Lists the fields, operators and allowed values each checkpoint actually supports -\n the same table the server validates against, so this can never offer something\n that saves and then never fires. Omit --domain to see all six.\n`,
37
37
  templates: ` troxy policies templates [--category "<name>"] [--search "<text>"]\n\n Lists the built-in policy templates (the same ones on the dashboard's\n Policies page). Use a template's exact label with 'policies create --template'.\n`,
38
38
  test: ` troxy policies test --domain <domain> --example '<JSON>' [condition flags]\n\n Dry-runs a condition against an example event - the same "would this rule\n fire?" check the dashboard's policy drawer offers, without saving anything.\n Nothing is written: no audit row, no notification, no pending approval.\n\n Takes the same condition flags as 'create' (--field/--operator/--value/--and/--or/--json/--template),\n plus:\n --example '<JSON>' The event to test against, shaped like what that checkpoint receives.\n e.g. --domain payment --example '{\"amount\": 150}'\n\n Examples:\n troxy policies test --domain payment --field amount --operator gt --value 100 --example '{"amount": 150}'\n troxy policies test --domain secrets --field has_api_key --operator eq --value true --example '{"has_api_key": true}'\n`,
39
- 'set-priority': ` troxy policies set-priority --name <policy-name> --priority <n> [--org]\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 --org Target an org policy (requires admin)\n\n Example:\n troxy policies set-priority --name "Block Wiki" --priority 5\n`,
40
- resume: ` troxy policies resume --name <policy-name> [--org]\n\n Resumes a paused policy.\n\n Options:\n --name Name of the policy to resume\n --org Target an org policy (requires admin)\n`,
41
- pause: ` troxy policies pause --name <policy-name> [--org]\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 --org Target an org policy (requires admin)\n`,
42
- delete: ` troxy policies delete --name <policy-name> [--org]\n\n Permanently deletes a policy.\n\n Options:\n --name Name of the policy to delete\n --org Target an org policy (requires admin)\n`,
39
+ 'set-priority': ` troxy policies set-priority --name <policy-name> --org --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 --org Required. Target the org policy (admin access required; personal-scope policies no longer exist)\n\n Example:\n troxy policies set-priority --name "Block Wiki" --priority 5 --org\n`,
40
+ resume: ` troxy policies resume --name <policy-name> --org\n\n Resumes a paused policy.\n\n Options:\n --name Name of the policy to resume\n --org Required. Target the org policy (admin access required; personal-scope policies no longer exist)\n`,
41
+ pause: ` troxy policies pause --name <policy-name> --org\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 --org Required. Target the org policy (admin access required; personal-scope policies no longer exist)\n`,
42
+ delete: ` troxy policies delete --name <policy-name> --org\n\n Permanently deletes a policy.\n\n Options:\n --name Name of the policy to delete\n --org Required. Target the org policy (admin access required; personal-scope policies no longer exist)\n`,
43
43
  };
44
44
 
45
45
  export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
46
46
  if (flags.help || flags.h) {
47
- console.log('\n' + (HELP[sub] || ` troxy policies <subcommand> [options]\n\n Subcommands:\n list List all policies\n describe Show details for a policy\n create Create a new policy\n templates List the built-in templates\n fields List the fields/operators each checkpoint supports\n test Dry-run a condition without saving it\n set-priority Change a policy's priority\n pause Pause a policy (it stops firing until you resume it)\n resume Resume a paused policy\n delete Delete a policy\n\n Any personal-policy subcommand also takes --org, to act on your organization's policies instead (admin required to write).\n\n Run 'troxy policies <subcommand> --help' for subcommand help.\n`));
47
+ console.log('\n' + (HELP[sub] || ` troxy policies <subcommand> [options]\n\n Subcommands:\n list List all policies\n describe Show details for a policy\n create Create a new policy\n templates List the built-in templates\n fields List the fields/operators each checkpoint supports\n test Dry-run a condition without saving it\n set-priority Change a policy's priority\n pause Pause a policy (it stops firing until you resume it)\n resume Resume a paused policy\n delete Delete a policy\n\n Policies are admin-created and always org- or space-scoped - personal policies\n no longer exist. create/set-priority/pause/resume/delete all require --org\n (admin access). list/describe/test need no --org and show every org + space\n policy that applies to you.\n\n Run 'troxy policies <subcommand> --help' for subcommand help.\n`));
48
48
  process.exit(0);
49
49
  }
50
50
 
@@ -113,7 +113,6 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
113
113
  const p = (data?.policies || []).find(x => x.name.toLowerCase() === name.toLowerCase());
114
114
  if (!p) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
115
115
 
116
- const mcpList = p.mcps.length ? p.mcps.map(m => m.name).join(', ') : (p.global ? 'all MCPs' : 'none');
117
116
  console.log(`
118
117
  Name: ${p.name}
119
118
  Action: ${_displayAction(p)}
@@ -121,8 +120,6 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
121
120
  Status: ${p.enabled ? 'active' : 'paused'}
122
121
  Scope: ${p.scope}
123
122
  Applies here: ${p.applies_to_me ? 'yes' : 'no'}
124
- MCPs: ${mcpList}
125
- Troxy Chat: ${p.applies_to_chat ? 'yes' : 'no'}
126
123
  Conditions: ${_condDetail(p)}
127
124
  Created: ${new Date(p.created_at).toLocaleDateString()}
128
125
  `);
@@ -162,8 +159,20 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
162
159
 
163
160
  switch (sub) {
164
161
  case 'create': {
162
+ // Personal-scope and per-MCP policy creation were eliminated (product
163
+ // decision, 2026-08-20): POST /dashboard/policies no longer exists on
164
+ // the server at all (falls through to a bare 404), so every policy the
165
+ // CLI creates now has to go through /orgs/:id/policies instead. --org
166
+ // is required up front, before touching --describe/--template/manual
167
+ // condition parsing, so a user who forgets it gets this clear message
168
+ // instead of the server's generic "not found".
169
+ if (!flags.org) {
170
+ console.error('\n Personal policies no longer exist - policy creation is admin-only and always org-wide.');
171
+ console.error(' Pass --org (admin access required). Space-scoped creation isn\'t available from the CLI yet - use the dashboard for that.\n');
172
+ process.exit(1);
173
+ }
174
+
165
175
  if (flags.describe) {
166
- if (flags.org) { console.error('\n --describe (AI builder) does not support --org yet. Build the policy manually or apply a template.\n'); process.exit(1); }
167
176
  // handle_ai_policy (the backend endpoint this hits) only knows two field
168
177
  // allowlists, gated on a single is_email boolean - unlike the six-domain
169
178
  // condition translator create's other paths use. Nothing here used to
@@ -175,31 +184,23 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
175
184
  console.error(`\n --describe only supports --domain payment or comms (the AI builder does not cover the other checkpoints yet - try --template or build the condition manually instead).\n`);
176
185
  process.exit(1);
177
186
  }
187
+ const org = await _resolveOrgContext(jwt, true);
178
188
  process.stdout.write('\n Asking Troxy AI to build this policy... ');
179
- const { tokens = [] } = await api.listTokens(jwt);
180
- const mcps = tokens.map(t => ({ id: t.id, name: t.name || t.agent_name || t.prefix }));
181
- const draft = await api.aiPolicy(jwt, { text: flags.describe, mcps, is_email: describeDomain === 'comms' });
189
+ const draft = await api.aiPolicy(jwt, { text: flags.describe, is_email: describeDomain === 'comms' });
182
190
  if (draft.error) { console.log('✗'); console.error(`\n ${draft.error}\n`); process.exit(1); }
183
191
  console.log('✓\n');
184
192
  console.log(` Name: ${draft.name}`);
185
193
  console.log(` Action: ${draft.action}`);
186
194
  if (draft.conditions?.length) console.log(` Conditions: ${_condDetail(draft)}`);
187
- if (draft.mcp_ids?.length) console.log(` Scope: ${draft.mcp_ids.length} MCP(s)`);
188
195
  if (draft.warnings?.length) console.log(` Warnings: ${draft.warnings.join('; ')}`);
189
196
  if (draft.note) console.log(` Note: ${draft.note}`);
190
197
  const ok = await _promptYN('\n Create this policy? (y/N): ');
191
198
  if (!ok) { console.log('\n Cancelled.\n'); process.exit(0); }
192
- const body = {
193
- name: draft.name, action: draft.action,
194
- conditions: draft.conditions || [], or_conditions: draft.or_conditions || [],
195
- enabled: true,
196
- global: !(draft.mcp_ids && draft.mcp_ids.length),
197
- mcp_ids: draft.mcp_ids || [],
198
- applies_to_chat: true,
199
- source: 'ai',
200
- };
201
- const policy = await api.createPolicy(jwt, body);
202
- console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority})\n`);
199
+ const name = draft.name, action = draft.action, domain = describeDomain,
200
+ conditions = draft.conditions || [], or_conditions = draft.or_conditions || [];
201
+ const body = { name, action, domain, conditions, or_conditions };
202
+ const policy = await api.createOrgPolicy(jwt, org.id, body);
203
+ console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, applies to every member of ${org.name})\n`);
203
204
  break;
204
205
  }
205
206
 
@@ -208,72 +209,13 @@ export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
208
209
 
209
210
  const { domain, action, conditions, or_conditions } = _buildConditionsFromFlags(flags, multiFlags, { requireAction: true });
210
211
 
211
- if (flags.org) {
212
- const org = await _resolveOrgContext(jwt, true);
213
- const priority = flags.priority != null ? parseInt(flags.priority, 10) : undefined;
214
- if (flags.priority != null && isNaN(priority)) { console.error(' --priority must be a number\n'); process.exit(1); }
215
- const body = { name, action, domain, conditions, or_conditions, enabled: true };
216
- if (priority != null) body.priority = priority;
217
- const policy = await api.createOrgPolicy(jwt, org.id, body);
218
- console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, applies to every member of ${org.name})\n`);
219
- break;
220
- }
221
-
222
- if (flags.chat && flags['no-chat']) {
223
- console.error(' --chat and --no-chat cannot both be set\n'); process.exit(1);
224
- }
225
-
226
- // --mcp "name" or "name1,name2": scope policy to specific MCPs instead of all.
227
- // --chat / --no-chat: include or exclude Troxy Chat (default: included).
228
- let isGlobal = true;
229
- let mcpIds = [];
230
- let mcpNames = [];
231
- if (flags.mcp) {
232
- const { tokens = [] } = await api.listTokens(jwt);
233
- const requested = String(flags.mcp).split(',').map(s => s.trim()).filter(Boolean);
234
- for (const raw of requested) {
235
- const needle = raw.toLowerCase();
236
- const match = tokens.find(t =>
237
- (t.name && t.name.toLowerCase() === needle) ||
238
- (t.agent_name && t.agent_name.toLowerCase() === needle) ||
239
- (t.prefix && t.prefix.toLowerCase().startsWith(needle))
240
- );
241
- if (!match) {
242
- console.error(`\n MCP "${raw}" not found. Run: troxy mcps list\n`);
243
- process.exit(1);
244
- }
245
- mcpIds.push(match.id);
246
- mcpNames.push(match.name || match.agent_name || match.prefix);
247
- }
248
- isGlobal = false;
249
- console.log(`\n Scoping to MCP${mcpNames.length > 1 ? 's' : ''}: ${mcpNames.join(', ')}`);
250
- } else if (flags.chat) {
251
- // --chat with no --mcp: scope to Troxy Chat only, no MCPs
252
- isGlobal = false;
253
- }
254
-
255
- const appliesToChat = flags['no-chat'] ? false : true;
256
-
257
- if (!isGlobal && mcpIds.length === 0 && !appliesToChat) {
258
- console.error(' Nothing to scope to, pass --mcp, --chat, or drop --no-chat\n'); process.exit(1);
259
- }
260
-
212
+ const org = await _resolveOrgContext(jwt, true);
261
213
  const priority = flags.priority != null ? parseInt(flags.priority, 10) : undefined;
262
- if (flags.priority != null && isNaN(priority)) {
263
- console.error(' --priority must be a number\n'); process.exit(1);
264
- }
265
- const body = { name, action, domain, conditions, or_conditions, enabled: true, global: isGlobal, mcp_ids: mcpIds, applies_to_chat: appliesToChat };
214
+ if (flags.priority != null && isNaN(priority)) { console.error(' --priority must be a number\n'); process.exit(1); }
215
+ const body = { name, action, domain, conditions, or_conditions, enabled: true };
266
216
  if (priority != null) body.priority = priority;
267
- const policy = await api.createPolicy(jwt, body);
268
- let scope;
269
- if (isGlobal) {
270
- scope = appliesToChat ? 'all agents' : 'all MCPs';
271
- } else {
272
- const parts = mcpNames.slice();
273
- if (appliesToChat) parts.push('Troxy Chat');
274
- scope = parts.length ? parts.join(', ') : 'no agents';
275
- }
276
- console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority}, checkpoint: ${domain}, scope: ${scope})\n`);
217
+ const policy = await api.createOrgPolicy(jwt, org.id, body);
218
+ console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, checkpoint: ${domain}, applies to every member of ${org.name})\n`);
277
219
  break;
278
220
  }
279
221
 
package/src/secrets.js CHANGED
@@ -4,7 +4,7 @@ import { requireJwt } from './auth.js';
4
4
  import { table } from './print.js';
5
5
 
6
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`,
7
+ list: ` troxy secrets list\n\n Lists your saved LLM provider keys. Values are\n never shown, only a masked preview.\n`,
8
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
9
  delete: ` troxy secrets delete --name <name>\n\n Deletes a saved key. Login required.\n`,
10
10
  };
package/src/settings.js CHANGED
@@ -30,9 +30,6 @@ export async function runSettings([sub, ...args], flags) {
30
30
  On notify: ${s.notify_prefs.email_on_notify ? 'yes' : 'no'}
31
31
  On allow: ${s.notify_prefs.email_on_allow ? 'yes' : 'no'}
32
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
33
  `);
37
34
  break;
38
35
  }
@@ -0,0 +1,389 @@
1
+ // tool_detect.js: the AI-tool inventory scan + subscription-plan self-report
2
+ // wired into `troxy init` (after reprovisionKeyConsumers) and standalone
3
+ // `troxy tools`. A single machine can have more than one of these six tools
4
+ // installed at once, and each is tracked independently - never assume "one
5
+ // AI tool per user" (that's the whole reason detectAiTools() returns an
6
+ // array, not a single match).
7
+ //
8
+ // Path-based detection (cursor/github_copilot/windsurf/continue) runs
9
+ // against a real temp HOME, the same technique config.test.js and
10
+ // hook-report.test.js already use - this suite has no fs/execSync mocking
11
+ // anywhere, and a real temp dir is simpler than reimplementing
12
+ // fs.existsSync's contract. claude_code/aider are execSync('<bin>
13
+ // --version') probes; asserting a specific installed/not-installed result
14
+ // for those would depend on whatever machine happens to run the suite (CI
15
+ // vs. a dev box with the CLI on PATH), so those are covered by testing the
16
+ // binExists() mechanism itself with synthetic commands, plus source
17
+ // inspection confirming the real TOOLS entries use it (and, for
18
+ // claude_code, reuse init.js's own hasClaudeCode() rather than
19
+ // reimplementing the probe).
20
+ import { describe, it, before, beforeEach, after, afterEach } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { readFileSync } from 'node:fs';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { dirname, join } from 'node:path';
28
+
29
+ // tool_detect.js reads os.homedir() once at module load time (`const home =
30
+ // os.homedir()`), so HOME must point at an isolated temp dir *before* the
31
+ // first import - exactly the trick config.test.js uses to keep tests off
32
+ // the real ~/.troxy (and, here, off the real ~/.cursor, ~/.continue, etc.).
33
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-tool-detect-test-'));
34
+ process.env.HOME = TMP;
35
+ process.env.APPDATA = path.join(TMP, 'AppData/Roaming');
36
+ process.env.LOCALAPPDATA = path.join(TMP, 'AppData/Local');
37
+
38
+ const {
39
+ detectAiTools,
40
+ promptForToolPlans,
41
+ silentToolReport,
42
+ reportToolPlans,
43
+ runToolDetection,
44
+ binExists,
45
+ TOOLS,
46
+ } = await import('../tool_detect.js');
47
+ const { api } = await import('../api.js');
48
+
49
+ const __dirname = dirname(fileURLToPath(import.meta.url));
50
+ const toolDetectSrc = readFileSync(join(__dirname, '..', 'tool_detect.js'), 'utf8');
51
+
52
+ after(() => fs.rmSync(TMP, { recursive: true, force: true }));
53
+
54
+ describe('TOOLS registry', () => {
55
+ it('has exactly the 6 documented tools, in slug form', () => {
56
+ assert.deepEqual(
57
+ TOOLS.map(t => t.slug),
58
+ ['claude_code', 'cursor', 'github_copilot', 'windsurf', 'continue', 'aider'],
59
+ );
60
+ });
61
+
62
+ // Keep in sync with the backend registry (tool_plans.py TOOL_PLANS) - the
63
+ // backend validates against its own copy of these slugs, so a slug typo
64
+ // here would silently fail every report for that tool.
65
+ const EXPECTED_PLAN_SLUGS = {
66
+ claude_code: ['free', 'pro', 'max_5x', 'max_20x', 'api', 'not_sure'],
67
+ cursor: ['hobby', 'pro', 'business', 'not_sure'],
68
+ github_copilot: ['individual', 'business', 'enterprise', 'not_sure'],
69
+ windsurf: ['free', 'pro', 'not_sure'],
70
+ continue: ['free', 'pro', 'not_sure'],
71
+ aider: ['free', 'pro', 'not_sure'],
72
+ };
73
+
74
+ for (const [slug, expected] of Object.entries(EXPECTED_PLAN_SLUGS)) {
75
+ it(`${slug} offers exactly the plan slugs the backend registry expects`, () => {
76
+ const tool = TOOLS.find(t => t.slug === slug);
77
+ assert.ok(tool, `${slug} missing from TOOLS`);
78
+ assert.deepEqual(tool.plans.map(p => p.slug), expected);
79
+ });
80
+ }
81
+ });
82
+
83
+ describe('binExists (the mechanism behind claude_code/aider detection)', () => {
84
+ it('returns true when the command succeeds', () => {
85
+ assert.equal(binExists('true'), true);
86
+ });
87
+
88
+ it('returns false when the command exits non-zero, without throwing', () => {
89
+ assert.equal(binExists('false'), false);
90
+ });
91
+
92
+ it('returns false for a binary that does not exist at all', () => {
93
+ assert.equal(binExists('this-command-almost-certainly-does-not-exist-xyz123 --version'), false);
94
+ });
95
+ });
96
+
97
+ describe('claude_code / aider detection wiring', () => {
98
+ it('claude_code reuses init.js\'s hasClaudeCode() rather than reimplementing the probe', () => {
99
+ assert.ok(
100
+ toolDetectSrc.includes("import { prompt, hasClaudeCode } from './init.js'"),
101
+ 'tool_detect.js must import hasClaudeCode from init.js, not redeclare its own execSync probe',
102
+ );
103
+ const claudeBlock = toolDetectSrc.slice(
104
+ toolDetectSrc.indexOf("slug: 'claude_code'"),
105
+ toolDetectSrc.indexOf("slug: 'cursor'"),
106
+ );
107
+ assert.match(claudeBlock, /detect:\s*\(\)\s*=>\s*hasClaudeCode\(\)/);
108
+ });
109
+
110
+ it('aider probes "aider --version" via binExists', () => {
111
+ const aiderBlock = toolDetectSrc.slice(toolDetectSrc.indexOf("slug: 'aider'"));
112
+ assert.match(aiderBlock, /detect:\s*\(\)\s*=>\s*binExists\('aider --version'\)/);
113
+ });
114
+ });
115
+
116
+ describe('detectAiTools - path-based markers (cursor / github_copilot / windsurf / continue)', () => {
117
+ function reset() {
118
+ fs.rmSync(TMP, { recursive: true, force: true });
119
+ fs.mkdirSync(TMP, { recursive: true });
120
+ }
121
+ beforeEach(reset);
122
+ after(reset);
123
+
124
+ it('reports none of the path-based tools installed against a clean home dir', () => {
125
+ const detected = detectAiTools();
126
+ for (const slug of ['cursor', 'github_copilot', 'windsurf', 'continue']) {
127
+ assert.equal(detected.find(d => d.slug === slug).installed, false, `${slug} should not be detected against an empty home dir`);
128
+ }
129
+ });
130
+
131
+ it("detects Cursor from its own app-support dir (~/Library/Application Support/Cursor)", () => {
132
+ fs.mkdirSync(path.join(TMP, 'Library/Application Support/Cursor'), { recursive: true });
133
+ assert.equal(detectAiTools().find(d => d.slug === 'cursor').installed, true);
134
+ });
135
+
136
+ it('does NOT treat a troxy-created .cursor/mcp.json as evidence Cursor is installed', () => {
137
+ fs.mkdirSync(path.join(TMP, '.cursor'), { recursive: true });
138
+ fs.writeFileSync(path.join(TMP, '.cursor/mcp.json'), '{}');
139
+ assert.equal(
140
+ detectAiTools().find(d => d.slug === 'cursor').installed,
141
+ false,
142
+ 'the MCP config troxy init itself creates must never look like Cursor being installed',
143
+ );
144
+ });
145
+
146
+ it('detects GitHub Copilot from ~/.config/github-copilot/hosts.json', () => {
147
+ fs.mkdirSync(path.join(TMP, '.config/github-copilot'), { recursive: true });
148
+ fs.writeFileSync(path.join(TMP, '.config/github-copilot/hosts.json'), '{}');
149
+ assert.equal(detectAiTools().find(d => d.slug === 'github_copilot').installed, true);
150
+ });
151
+
152
+ it('detects Windsurf from ~/.codeium/windsurf/, not the mcp_config.json troxy might create inside it', () => {
153
+ fs.mkdirSync(path.join(TMP, '.codeium/windsurf'), { recursive: true });
154
+ assert.equal(detectAiTools().find(d => d.slug === 'windsurf').installed, true);
155
+
156
+ // The MCP-specific file alone (same troxy-created-file risk as cursor)
157
+ // must not be required, but also must not be the thing being matched -
158
+ // the base dir is. Deleting it, base dir intact, should stay detected.
159
+ fs.rmSync(path.join(TMP, '.codeium/windsurf'), { recursive: true, force: true });
160
+ fs.mkdirSync(path.join(TMP, '.codeium/windsurf'), { recursive: true });
161
+ fs.writeFileSync(path.join(TMP, '.codeium/windsurf/mcp_config.json'), '{}');
162
+ assert.equal(detectAiTools().find(d => d.slug === 'windsurf').installed, true);
163
+ });
164
+
165
+ it('detects Continue from ~/.continue/', () => {
166
+ fs.mkdirSync(path.join(TMP, '.continue'), { recursive: true });
167
+ assert.equal(detectAiTools().find(d => d.slug === 'continue').installed, true);
168
+ });
169
+
170
+ it('a machine with several tools installed reports every one independently - never just the first match', () => {
171
+ fs.mkdirSync(path.join(TMP, 'Library/Application Support/Cursor'), { recursive: true });
172
+ fs.mkdirSync(path.join(TMP, '.continue'), { recursive: true });
173
+ const detected = detectAiTools();
174
+ assert.equal(detected.find(d => d.slug === 'cursor').installed, true);
175
+ assert.equal(detected.find(d => d.slug === 'continue').installed, true);
176
+ assert.equal(detected.find(d => d.slug === 'github_copilot').installed, false);
177
+ assert.equal(detected.find(d => d.slug === 'windsurf').installed, false);
178
+ });
179
+ });
180
+
181
+ describe('silentToolReport (non-interactive fallback shape)', () => {
182
+ it('reports every installed tool as not_sure with no cycle_day, and skips tools that are not installed', () => {
183
+ const detected = [
184
+ { slug: 'claude_code', name: 'Claude Code', installed: true },
185
+ { slug: 'cursor', name: 'Cursor', installed: false },
186
+ { slug: 'aider', name: 'Aider', installed: true },
187
+ ];
188
+ assert.deepEqual(silentToolReport(detected), [
189
+ { slug: 'claude_code', installed: true, plan: 'not_sure', cycle_day: null },
190
+ { slug: 'aider', installed: true, plan: 'not_sure', cycle_day: null },
191
+ ]);
192
+ });
193
+
194
+ it('returns an empty array when nothing is installed', () => {
195
+ assert.deepEqual(silentToolReport([{ slug: 'cursor', name: 'Cursor', installed: false }]), []);
196
+ });
197
+ });
198
+
199
+ describe('runToolDetection - non-interactive guard (a scripted/CI init must never hang on stdin)', () => {
200
+ let calls;
201
+ let originalReportToolPlans;
202
+ let originalIsTTY;
203
+
204
+ before(() => {
205
+ // A deterministic installed tool, independent of whether claude/aider
206
+ // binaries happen to be on PATH wherever this suite runs - the whole
207
+ // point of driving detection through the temp-HOME path markers rather
208
+ // than asserting on the real machine's execSync probes.
209
+ fs.mkdirSync(path.join(TMP, '.continue'), { recursive: true });
210
+ });
211
+ after(() => fs.rmSync(path.join(TMP, '.continue'), { recursive: true, force: true }));
212
+
213
+ beforeEach(() => {
214
+ calls = [];
215
+ originalReportToolPlans = api.reportToolPlans;
216
+ api.reportToolPlans = async (apiKey, tools) => { calls.push({ apiKey, tools }); return {}; };
217
+ originalIsTTY = process.stdin.isTTY;
218
+ });
219
+ afterEach(() => {
220
+ api.reportToolPlans = originalReportToolPlans;
221
+ process.stdin.isTTY = originalIsTTY;
222
+ });
223
+
224
+ it('skips the interactive prompt and reports every detected tool as not_sure when stdin is not a TTY', async () => {
225
+ process.stdin.isTTY = false;
226
+ await runToolDetection('txy-test-key', { interactive: true });
227
+
228
+ assert.equal(calls.length, 1, 'reportToolPlans must be called exactly once when at least one tool is installed');
229
+ assert.equal(calls[0].apiKey, 'txy-test-key');
230
+ const tools = calls[0].tools;
231
+ const cont = tools.find(t => t.slug === 'continue');
232
+ assert.ok(cont, 'the tool detected via the temp-HOME marker must be in the reported set');
233
+ for (const t of tools) {
234
+ assert.equal(t.installed, true);
235
+ assert.equal(t.plan, 'not_sure');
236
+ assert.equal(t.cycle_day, null);
237
+ }
238
+ });
239
+
240
+ it('returns promptly rather than blocking on stdin when stdin is not a TTY', async () => {
241
+ process.stdin.isTTY = false;
242
+ const start = Date.now();
243
+ await runToolDetection('txy-test-key', { interactive: true });
244
+ assert.ok(Date.now() - start < 2000, 'runToolDetection must not block waiting on a readline question nothing will ever answer');
245
+ });
246
+
247
+ it('runToolDetection only takes the interactive path when both interactive AND stdin.isTTY are true', () => {
248
+ assert.ok(
249
+ toolDetectSrc.includes('(interactive && process.stdin.isTTY)'),
250
+ 'the non-interactive guard must check both interactive and process.stdin.isTTY, not just one',
251
+ );
252
+ });
253
+ });
254
+
255
+ describe("promptForToolPlans - returns immediately when nothing is installed (no prompt call, no stdin needed)", () => {
256
+ it('resolves to [] without touching stdin', async () => {
257
+ const result = await promptForToolPlans([{ slug: 'cursor', name: 'Cursor', installed: false }]);
258
+ assert.deepEqual(result, []);
259
+ });
260
+ });
261
+
262
+ describe('promptForToolPlans numbered-choice parsing (mirrors the real logic in tool_detect.js)', () => {
263
+ // Mirrors the parsing inside promptForToolPlans's loop: a numbered-menu
264
+ // answer maps 1-based to the plan array; anything out of range - blank,
265
+ // 0, a too-large number, non-numeric junk - degrades to 'not_sure' rather
266
+ // than re-prompting, so a stray keystroke can never hang setup. The last
267
+ // test in each block below source-checks that the real implementation
268
+ // still contains this exact logic, so the two can't silently drift apart.
269
+ function resolvePlanChoice(ans, plans) {
270
+ const idx = parseInt(ans, 10) - 1;
271
+ return (idx >= 0 && idx < plans.length) ? plans[idx].slug : 'not_sure';
272
+ }
273
+
274
+ const cursorPlans = TOOLS.find(t => t.slug === 'cursor').plans; // hobby, pro, business, not_sure
275
+
276
+ it('maps a valid 1-based numbered choice to the matching plan slug', () => {
277
+ assert.equal(resolvePlanChoice('1', cursorPlans), 'hobby');
278
+ assert.equal(resolvePlanChoice('2', cursorPlans), 'pro');
279
+ assert.equal(resolvePlanChoice('3', cursorPlans), 'business');
280
+ assert.equal(resolvePlanChoice('4', cursorPlans), 'not_sure');
281
+ });
282
+
283
+ it('defaults to not_sure on a blank answer (Enter to skip)', () => {
284
+ assert.equal(resolvePlanChoice('', cursorPlans), 'not_sure');
285
+ });
286
+
287
+ it('defaults to not_sure on an out-of-range or non-numeric answer, rather than throwing', () => {
288
+ assert.equal(resolvePlanChoice('99', cursorPlans), 'not_sure');
289
+ assert.equal(resolvePlanChoice('0', cursorPlans), 'not_sure');
290
+ assert.equal(resolvePlanChoice('-1', cursorPlans), 'not_sure');
291
+ assert.equal(resolvePlanChoice('banana', cursorPlans), 'not_sure');
292
+ });
293
+
294
+ it('the real implementation uses this exact idx = parseInt(ans, 10) - 1 mapping', () => {
295
+ assert.ok(toolDetectSrc.includes('const idx = parseInt(ans, 10) - 1;'));
296
+ assert.ok(toolDetectSrc.includes("(idx >= 0 && idx < tool.plans.length) ? tool.plans[idx].slug : 'not_sure'"));
297
+ });
298
+ });
299
+
300
+ describe('promptForToolPlans cycle_day parsing (mirrors the real logic)', () => {
301
+ function resolveCycleDay(ans) {
302
+ const day = parseInt(ans, 10);
303
+ return (day >= 1 && day <= 31) ? day : null;
304
+ }
305
+
306
+ it('accepts a valid day 1-31', () => {
307
+ assert.equal(resolveCycleDay('1'), 1);
308
+ assert.equal(resolveCycleDay('15'), 15);
309
+ assert.equal(resolveCycleDay('31'), 31);
310
+ });
311
+
312
+ it('rejects out-of-range or non-numeric input as null (Enter to skip), never throws', () => {
313
+ assert.equal(resolveCycleDay(''), null);
314
+ assert.equal(resolveCycleDay('0'), null);
315
+ assert.equal(resolveCycleDay('32'), null);
316
+ assert.equal(resolveCycleDay('abc'), null);
317
+ });
318
+
319
+ it('the real implementation uses this exact 1-31 range check', () => {
320
+ assert.ok(toolDetectSrc.includes('if (day >= 1 && day <= 31) cycle_day = day;'));
321
+ });
322
+
323
+ it('only asks for a renewal day on a real paid plan - never free/hobby/not_sure/api', () => {
324
+ assert.ok(toolDetectSrc.includes("!['free', 'hobby', 'not_sure', 'api'].includes(plan)"));
325
+ });
326
+ });
327
+
328
+ describe('reportToolPlans', () => {
329
+ let originalReportToolPlans;
330
+ beforeEach(() => { originalReportToolPlans = api.reportToolPlans; });
331
+ afterEach(() => { api.reportToolPlans = originalReportToolPlans; });
332
+
333
+ it('POSTs the tools array via api.reportToolPlans, unchanged', async () => {
334
+ let captured;
335
+ api.reportToolPlans = async (apiKey, tools) => { captured = { apiKey, tools }; return {}; };
336
+ const results = [{ slug: 'cursor', installed: true, plan: 'pro', cycle_day: 5 }];
337
+ await reportToolPlans('txy-key', results);
338
+ assert.deepEqual(captured, { apiKey: 'txy-key', tools: results });
339
+ });
340
+
341
+ it('makes no API call for an empty result set', async () => {
342
+ let called = false;
343
+ api.reportToolPlans = async () => { called = true; };
344
+ await reportToolPlans('txy-key', []);
345
+ assert.equal(called, false);
346
+ });
347
+
348
+ it('swallows a failed report rather than throwing - must never break `troxy init`', async () => {
349
+ api.reportToolPlans = async () => { throw new Error('network down'); };
350
+ await assert.doesNotReject(
351
+ reportToolPlans('txy-key', [{ slug: 'cursor', installed: true, plan: 'pro', cycle_day: 5 }]),
352
+ );
353
+ });
354
+ });
355
+
356
+ describe('wiring: api.js / bin/troxy.js / init.js', () => {
357
+ const apiSrc = readFileSync(join(__dirname, '..', 'api.js'), 'utf8');
358
+ const binSrc = readFileSync(join(__dirname, '..', '..', 'bin', 'troxy.js'), 'utf8');
359
+ const initSrc = readFileSync(join(__dirname, '..', 'init.js'), 'utf8');
360
+
361
+ it("api.js POSTs to /agents/tool-plans with {tools} as the body", () => {
362
+ assert.ok(apiSrc.includes("'/agents/tool-plans'"), '/agents/tool-plans route missing from api.js');
363
+ assert.match(
364
+ apiSrc,
365
+ /reportToolPlans:\s*\(apiKey,\s*tools\)\s*=>\s*request\('POST',\s*'\/agents\/tool-plans',\s*\{\s*apiKey,\s*body:\s*\{\s*tools\s*\}\s*\}\)/,
366
+ );
367
+ });
368
+
369
+ it("bin/troxy.js registers a 'tools' subcommand that reuses tool_detect.js's runToolDetection", () => {
370
+ assert.ok(binSrc.includes("case 'tools'"), 'troxy tools subcommand missing');
371
+ assert.ok(binSrc.includes("'../src/tool_detect.js'"), 'troxy tools must import from tool_detect.js, not reimplement detection');
372
+ assert.ok(binSrc.includes('runToolDetection'));
373
+ });
374
+
375
+ it('runInit calls tool detection after reprovisionKeyConsumers, wrapped so a scan failure never blocks init', () => {
376
+ const reprovisionIdx = initSrc.indexOf('reprovisionKeyConsumers(key, agentName)');
377
+ const toolIdx = initSrc.indexOf('runToolDetection');
378
+ assert.ok(reprovisionIdx !== -1, 'reprovisionKeyConsumers call not found in init.js');
379
+ assert.ok(toolIdx !== -1, 'runToolDetection not wired into runInit');
380
+ assert.ok(toolIdx > reprovisionIdx, 'tool detection must run after reprovisionKeyConsumers, per spec');
381
+
382
+ const surrounding = initSrc.slice(Math.max(0, toolIdx - 200), toolIdx + 300);
383
+ assert.match(
384
+ surrounding,
385
+ /try\s*\{[\s\S]*runToolDetection[\s\S]*\}\s*catch/,
386
+ 'the tool-detection step must be wrapped in try/catch so a scan/report failure never breaks a successful init',
387
+ );
388
+ });
389
+ });
@@ -1,9 +1,9 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import readline from 'readline';
5
4
  import { execSync } from 'child_process';
6
5
  import { api } from './api.js';
6
+ import { prompt, hasClaudeCode } from './init.js';
7
7
 
8
8
  // AI coding tools we detect + the subscription plans a user can confirm for
9
9
  // each. Mirrors the backend registry (troxy-tf-live tool_plans.py TOOL_PLANS)
@@ -19,11 +19,14 @@ import { api } from './api.js';
19
19
 
20
20
  const home = os.homedir();
21
21
 
22
- function binExists(cmd) {
22
+ // Exported for direct unit testing of the detection mechanism itself
23
+ // (with a synthetic command / synthetic paths), independent of whether the
24
+ // real tools happen to be installed on whatever machine runs the tests.
25
+ export function binExists(cmd) {
23
26
  try { execSync(cmd, { stdio: 'ignore' }); return true; } catch { return false; }
24
27
  }
25
28
 
26
- function anyPathExists(paths) {
29
+ export function anyPathExists(paths) {
27
30
  return paths.some(p => { try { return fs.existsSync(p); } catch { return false; } });
28
31
  }
29
32
 
@@ -31,7 +34,10 @@ export const TOOLS = [
31
34
  {
32
35
  slug: 'claude_code',
33
36
  name: 'Claude Code',
34
- detect: () => binExists('claude --version'),
37
+ // Reuses init.js's own hasClaudeCode() as-is (execSync('claude
38
+ // --version')) rather than reimplementing it - one source of truth for
39
+ // "is Claude Code on PATH".
40
+ detect: () => hasClaudeCode(),
35
41
  plans: [
36
42
  { slug: 'free', label: 'Free' },
37
43
  { slug: 'pro', label: 'Pro ($20/mo)' },
@@ -97,6 +103,7 @@ export const TOOLS = [
97
103
  detect: () => binExists('aider --version'),
98
104
  plans: [
99
105
  { slug: 'free', label: 'Free (bring your own API key)' },
106
+ { slug: 'pro', label: 'Pro ($15/mo)' },
100
107
  { slug: 'not_sure', label: 'Not sure' },
101
108
  ],
102
109
  },
@@ -106,11 +113,6 @@ export function detectAiTools() {
106
113
  return TOOLS.map(t => ({ slug: t.slug, name: t.name, installed: !!t.detect() }));
107
114
  }
108
115
 
109
- function _prompt(question) {
110
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
111
- return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
112
- }
113
-
114
116
  // For each detected tool: show a numbered plan menu, read a choice, then ask
115
117
  // the renewal day. Returns [{slug, installed:true, plan, cycle_day}]. A tool
116
118
  // detected but skipped (blank / invalid choice) is reported as 'not_sure' so
@@ -128,7 +130,7 @@ export async function promptForToolPlans(detected) {
128
130
  const tool = TOOLS.find(t => t.slug === d.slug);
129
131
  console.log(` ${tool.name}:`);
130
132
  tool.plans.forEach((p, i) => console.log(` ${i + 1}) ${p.label}`));
131
- const ans = await _prompt(` Which plan? [1-${tool.plans.length}, Enter to skip]: `);
133
+ const ans = await prompt(` Which plan? [1-${tool.plans.length}, Enter to skip]: `);
132
134
  const idx = parseInt(ans, 10) - 1;
133
135
  const plan = (idx >= 0 && idx < tool.plans.length) ? tool.plans[idx].slug : 'not_sure';
134
136
 
@@ -138,7 +140,7 @@ export async function promptForToolPlans(detected) {
138
140
  // have no monthly renewal to track a usage window against.
139
141
  const isPaid = chosen && !['free', 'hobby', 'not_sure', 'api'].includes(plan);
140
142
  if (isPaid) {
141
- const dayAns = await _prompt(' What day of the month does it renew? [1-31, Enter to skip]: ');
143
+ const dayAns = await prompt(' What day of the month does it renew? [1-31, Enter to skip]: ');
142
144
  const day = parseInt(dayAns, 10);
143
145
  if (day >= 1 && day <= 31) cycle_day = day;
144
146
  }
@@ -1,73 +0,0 @@
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
- }