troxy-cli 1.25.0 → 1.26.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,7 +50,7 @@ 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
+ | `troxy tools` | Scan for installed AI coding tools and report them to the dashboard |
54
54
  | `troxy mcps` | List connected MCP agents and their status |
55
55
  | `troxy policies` | List every org/space policy that applies to you (creating one is admin-only) |
56
56
  | `troxy activity` | View recent transaction audit log |
package/bin/troxy.js CHANGED
@@ -91,9 +91,8 @@ switch (command) {
91
91
  troxy tools
92
92
 
93
93
  Re-scans this machine for AI coding tools (Claude Code, Cursor, GitHub
94
- Copilot, Windsurf, Continue, Aider) and lets you set the subscription plan
95
- for each, so the dashboard shows what you actually pay instead of raw
96
- token estimates. Run this any time your plan changes.
94
+ Copilot, Windsurf, Continue, Aider) and reports which ones it found to the
95
+ dashboard's "My AI tools" view. Run this any time you install or remove one.
97
96
  `);
98
97
  process.exit(0);
99
98
  }
@@ -107,7 +106,7 @@ switch (command) {
107
106
  console.log('\n No supported AI coding tools detected on this machine.\n');
108
107
  break;
109
108
  }
110
- await runToolDetection(apiKey, { interactive: true });
109
+ await runToolDetection(apiKey);
111
110
  console.log(' Saved ✓ See it at https://dash.troxy.io\n');
112
111
  break;
113
112
  }
@@ -649,7 +648,7 @@ switch (command) {
649
648
  troxy init --key <api-key> Connect this machine as an MCP + save key
650
649
  troxy init --key <api-key> --name "My Agent" Same, no interactive prompt
651
650
  (for cloud/scripted agents that can't answer stdin)
652
- troxy tools Scan for AI tools + set your subscription plans
651
+ troxy tools Scan for AI tools + report them to the dashboard
653
652
  troxy restart Restart the MCP background service
654
653
  troxy uninstall Remove Troxy from this machine
655
654
  troxy status API health + MCP status (no login needed)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.25.0",
3
+ "version": "1.26.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
@@ -132,8 +132,9 @@ export const api = {
132
132
  // MCP status (agent API key — no login needed)
133
133
  mcpStatus: (apiKey) => request('GET', '/mcp/status', { apiKey }),
134
134
 
135
- // AI tool inventory + subscription plans, self-reported by `troxy init` /
136
- // `troxy tools`. Agent API key auth, same as the evaluate/heartbeat paths.
135
+ // AI tool inventory (presence only - no plan/price), self-reported by
136
+ // `troxy init` / `troxy tools`. Agent API key auth, same as the
137
+ // evaluate/heartbeat paths.
137
138
  reportToolPlans: (apiKey, tools) => request('POST', '/agents/tool-plans', { apiKey, body: { tools } }),
138
139
 
139
140
  // Setup instructions for an agent with no MCP client to configure. Fetched
package/src/init.js CHANGED
@@ -306,13 +306,11 @@ export async function runInit({ key, name, proxy } = {}) {
306
306
 
307
307
  await reprovisionKeyConsumers(key, agentName, proxyOptIn);
308
308
 
309
- // Scan for local AI coding tools and (interactively) confirm the plan for
310
- // each, so the dashboard shows real subscription cost, not token math.
311
- // Non-fatal + TTY-gated inside runToolDetection - a scripted init never
312
- // hangs and a scan failure never breaks setup.
309
+ // Scan for local AI coding tools so the dashboard's "My AI tools" view has
310
+ // something to show. Non-fatal - a scan failure never breaks setup.
313
311
  try {
314
312
  const { runToolDetection } = await import('./tool_detect.js');
315
- await runToolDetection(key, { interactive: true });
313
+ await runToolDetection(key);
316
314
  } catch {
317
315
  // Tool detection is a bonus, never a blocker for a successful init.
318
316
  }
@@ -611,7 +609,9 @@ async function maybeEnableClaudeCodeProxy(proxyOptIn) {
611
609
  console.log(' opportunity, not just suggest one.');
612
610
  console.log(" Trade-off: while this is on, Claude Code's Remote Control feature is");
613
611
  console.log(' disabled (Anthropic disables it whenever the API base URL points');
614
- console.log(' anywhere other than api.anthropic.com).');
612
+ console.log(' anywhere other than api.anthropic.com). Also, model policies (e.g. a');
613
+ console.log(' BLOCK rule on a specific model) are not enforced on this path yet -');
614
+ console.log(' only the cost-saving model swap is live.');
615
615
  const answer = await prompt(' Enable? (y/N): ');
616
616
  enable = /^y(es)?$/i.test(answer);
617
617
  }
@@ -1,9 +1,15 @@
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).
1
+ // tool_detect.js: the AI-tool presence scan wired into `troxy init` (after
2
+ // reprovisionKeyConsumers) and standalone `troxy tools`. A single machine
3
+ // can have more than one of these six tools installed at once, and each is
4
+ // tracked independently - never assume "one AI tool per user" (that's the
5
+ // whole reason detectAiTools() returns an array, not a single match).
6
+ //
7
+ // The self-reported subscription-plan concept this file used to test
8
+ // (promptForToolPlans, silentToolReport, a per-tool `plans` menu) has been
9
+ // removed entirely - a member's own guess should never feed cost math again
10
+ // (see troxy-tf-live's dashboard.py handle_token_analysis). What's left is
11
+ // presence-only: detect, then report {slug, installed:true} with nothing
12
+ // else attached.
7
13
  //
8
14
  // Path-based detection (cursor/github_copilot/windsurf/continue) runs
9
15
  // against a real temp HOME, the same technique config.test.js and
@@ -37,8 +43,6 @@ process.env.LOCALAPPDATA = path.join(TMP, 'AppData/Local');
37
43
 
38
44
  const {
39
45
  detectAiTools,
40
- promptForToolPlans,
41
- silentToolReport,
42
46
  reportToolPlans,
43
47
  runToolDetection,
44
48
  binExists,
@@ -59,25 +63,11 @@ describe('TOOLS registry', () => {
59
63
  );
60
64
  });
61
65
 
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
- }
66
+ it('carries no plan/price data any more - presence only', () => {
67
+ for (const t of TOOLS) {
68
+ assert.equal(t.plans, undefined, `${t.slug} must not have a plans array`);
69
+ }
70
+ });
81
71
  });
82
72
 
83
73
  describe('binExists (the mechanism behind claude_code/aider detection)', () => {
@@ -97,7 +87,7 @@ describe('binExists (the mechanism behind claude_code/aider detection)', () => {
97
87
  describe('claude_code / aider detection wiring', () => {
98
88
  it('claude_code reuses init.js\'s hasClaudeCode() rather than reimplementing the probe', () => {
99
89
  assert.ok(
100
- toolDetectSrc.includes("import { prompt, hasClaudeCode } from './init.js'"),
90
+ toolDetectSrc.includes("import { hasClaudeCode } from './init.js'"),
101
91
  'tool_detect.js must import hasClaudeCode from init.js, not redeclare its own execSync probe',
102
92
  );
103
93
  const claudeBlock = toolDetectSrc.slice(
@@ -178,28 +168,47 @@ describe('detectAiTools - path-based markers (cursor / github_copilot / windsurf
178
168
  });
179
169
  });
180
170
 
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', () => {
171
+ describe('reportToolPlans (presence-only report)', () => {
172
+ let originalReportToolPlans;
173
+ beforeEach(() => { originalReportToolPlans = api.reportToolPlans; });
174
+ afterEach(() => { api.reportToolPlans = originalReportToolPlans; });
175
+
176
+ it('POSTs only installed tools, as bare {slug, installed:true} - no plan, no cycle_day', async () => {
177
+ let captured;
178
+ api.reportToolPlans = async (apiKey, tools) => { captured = { apiKey, tools }; return {}; };
183
179
  const detected = [
184
180
  { slug: 'claude_code', name: 'Claude Code', installed: true },
185
181
  { slug: 'cursor', name: 'Cursor', installed: false },
186
182
  { slug: 'aider', name: 'Aider', installed: true },
187
183
  ];
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
- ]);
184
+ await reportToolPlans('txy-key', detected);
185
+ assert.deepEqual(captured, {
186
+ apiKey: 'txy-key',
187
+ tools: [
188
+ { slug: 'claude_code', installed: true },
189
+ { slug: 'aider', installed: true },
190
+ ],
191
+ });
192
192
  });
193
193
 
194
- it('returns an empty array when nothing is installed', () => {
195
- assert.deepEqual(silentToolReport([{ slug: 'cursor', name: 'Cursor', installed: false }]), []);
194
+ it('makes no API call when nothing is installed', async () => {
195
+ let called = false;
196
+ api.reportToolPlans = async () => { called = true; };
197
+ await reportToolPlans('txy-key', [{ slug: 'cursor', name: 'Cursor', installed: false }]);
198
+ assert.equal(called, false);
199
+ });
200
+
201
+ it('swallows a failed report rather than throwing - must never break `troxy init`', async () => {
202
+ api.reportToolPlans = async () => { throw new Error('network down'); };
203
+ await assert.doesNotReject(
204
+ reportToolPlans('txy-key', [{ slug: 'cursor', name: 'Cursor', installed: true }]),
205
+ );
196
206
  });
197
207
  });
198
208
 
199
- describe('runToolDetection - non-interactive guard (a scripted/CI init must never hang on stdin)', () => {
209
+ describe('runToolDetection', () => {
200
210
  let calls;
201
211
  let originalReportToolPlans;
202
- let originalIsTTY;
203
212
 
204
213
  before(() => {
205
214
  // A deterministic installed tool, independent of whether claude/aider
@@ -214,16 +223,13 @@ describe('runToolDetection - non-interactive guard (a scripted/CI init must neve
214
223
  calls = [];
215
224
  originalReportToolPlans = api.reportToolPlans;
216
225
  api.reportToolPlans = async (apiKey, tools) => { calls.push({ apiKey, tools }); return {}; };
217
- originalIsTTY = process.stdin.isTTY;
218
226
  });
219
227
  afterEach(() => {
220
228
  api.reportToolPlans = originalReportToolPlans;
221
- process.stdin.isTTY = originalIsTTY;
222
229
  });
223
230
 
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 });
231
+ it('reports every detected tool as bare presence - no prompt, no stdin involved', async () => {
232
+ await runToolDetection('txy-test-key');
227
233
 
228
234
  assert.equal(calls.length, 1, 'reportToolPlans must be called exactly once when at least one tool is installed');
229
235
  assert.equal(calls[0].apiKey, 'txy-test-key');
@@ -231,125 +237,15 @@ describe('runToolDetection - non-interactive guard (a scripted/CI init must neve
231
237
  const cont = tools.find(t => t.slug === 'continue');
232
238
  assert.ok(cont, 'the tool detected via the temp-HOME marker must be in the reported set');
233
239
  for (const t of tools) {
240
+ assert.deepEqual(Object.keys(t).sort(), ['installed', 'slug']);
234
241
  assert.equal(t.installed, true);
235
- assert.equal(t.plan, 'not_sure');
236
- assert.equal(t.cycle_day, null);
237
242
  }
238
243
  });
239
244
 
240
- it('returns promptly rather than blocking on stdin when stdin is not a TTY', async () => {
241
- process.stdin.isTTY = false;
245
+ it('returns promptly - nothing here ever waits on stdin', async () => {
242
246
  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
- );
247
+ await runToolDetection('txy-test-key');
248
+ assert.ok(Date.now() - start < 2000, 'runToolDetection must never block waiting on input');
353
249
  });
354
250
  });
355
251
 
@@ -3,13 +3,14 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { execSync } from 'child_process';
5
5
  import { api } from './api.js';
6
- import { prompt, hasClaudeCode } from './init.js';
6
+ import { hasClaudeCode } from './init.js';
7
7
 
8
- // AI coding tools we detect + the subscription plans a user can confirm for
9
- // each. Mirrors the backend registry (troxy-tf-live tool_plans.py TOOL_PLANS)
10
- // deliberately - six rows that rarely change aren't worth a shared package.
11
- // Keep the plan slugs identical on both sides; the backend validates against
12
- // its own copy and resolves the price, so the CLI never sends a price.
8
+ // AI coding tools we detect on this machine. Used to mirror the backend
9
+ // registry (troxy-tf-live tool_plans.py TOOL_PLANS) with a `plans` list per
10
+ // tool - that self-reported subscription-plan concept has been removed from
11
+ // cost math entirely (a member's own guess should never feed a dollar total
12
+ // again), so this is presence-only now: does the tool look installed, yes
13
+ // or no.
13
14
  //
14
15
  // `detect()` returns true if the tool looks present. Two techniques, same as
15
16
  // init.js's detectMcpClients: a `--version` probe for CLI tools, an
@@ -38,14 +39,6 @@ export const TOOLS = [
38
39
  // --version')) rather than reimplementing it - one source of truth for
39
40
  // "is Claude Code on PATH".
40
41
  detect: () => hasClaudeCode(),
41
- plans: [
42
- { slug: 'free', label: 'Free' },
43
- { slug: 'pro', label: 'Pro ($20/mo)' },
44
- { slug: 'max_5x', label: 'Max 5x ($100/mo)' },
45
- { slug: 'max_20x', label: 'Max 20x ($200/mo)' },
46
- { slug: 'api', label: 'API / pay-per-token' },
47
- { slug: 'not_sure', label: 'Not sure' },
48
- ],
49
42
  },
50
43
  {
51
44
  slug: 'cursor',
@@ -55,12 +48,6 @@ export const TOOLS = [
55
48
  path.join(process.env.APPDATA || home, 'Cursor'),
56
49
  path.join(home, '.config/Cursor'),
57
50
  ]),
58
- plans: [
59
- { slug: 'hobby', label: 'Hobby (free)' },
60
- { slug: 'pro', label: 'Pro ($20/mo)' },
61
- { slug: 'business', label: 'Business ($40/mo)' },
62
- { slug: 'not_sure', label: 'Not sure' },
63
- ],
64
51
  },
65
52
  {
66
53
  slug: 'github_copilot',
@@ -70,42 +57,21 @@ export const TOOLS = [
70
57
  path.join(home, '.config/github-copilot/apps.json'),
71
58
  path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData/Local'), 'github-copilot/hosts.json'),
72
59
  ]),
73
- plans: [
74
- { slug: 'individual', label: 'Individual ($10/mo)' },
75
- { slug: 'business', label: 'Business ($19/mo)' },
76
- { slug: 'enterprise', label: 'Enterprise ($39/mo)' },
77
- { slug: 'not_sure', label: 'Not sure' },
78
- ],
79
60
  },
80
61
  {
81
62
  slug: 'windsurf',
82
63
  name: 'Windsurf',
83
64
  detect: () => anyPathExists([path.join(home, '.codeium/windsurf')]),
84
- plans: [
85
- { slug: 'free', label: 'Free' },
86
- { slug: 'pro', label: 'Pro ($15/mo)' },
87
- { slug: 'not_sure', label: 'Not sure' },
88
- ],
89
65
  },
90
66
  {
91
67
  slug: 'continue',
92
68
  name: 'Continue',
93
69
  detect: () => anyPathExists([path.join(home, '.continue')]),
94
- plans: [
95
- { slug: 'free', label: 'Free' },
96
- { slug: 'pro', label: 'Pro ($15/mo)' },
97
- { slug: 'not_sure', label: 'Not sure' },
98
- ],
99
70
  },
100
71
  {
101
72
  slug: 'aider',
102
73
  name: 'Aider',
103
74
  detect: () => binExists('aider --version'),
104
- plans: [
105
- { slug: 'free', label: 'Free (bring your own API key)' },
106
- { slug: 'pro', label: 'Pro ($15/mo)' },
107
- { slug: 'not_sure', label: 'Not sure' },
108
- ],
109
75
  },
110
76
  ];
111
77
 
@@ -113,69 +79,24 @@ export function detectAiTools() {
113
79
  return TOOLS.map(t => ({ slug: t.slug, name: t.name, installed: !!t.detect() }));
114
80
  }
115
81
 
116
- // For each detected tool: show a numbered plan menu, read a choice, then ask
117
- // the renewal day. Returns [{slug, installed:true, plan, cycle_day}]. A tool
118
- // detected but skipped (blank / invalid choice) is reported as 'not_sure' so
119
- // the dashboard still shows it's installed. Never throws on bad input - a
120
- // stray keystroke degrades to 'not_sure', it doesn't abort setup.
121
- export async function promptForToolPlans(detected) {
82
+ export async function reportToolPlans(apiKey, detected) {
122
83
  const installed = detected.filter(d => d.installed);
123
- if (installed.length === 0) return [];
124
-
125
- console.log('\n Found these AI tools on this machine. Set your plan for each so');
126
- console.log(' the dashboard shows what you actually pay, not raw token estimates.\n');
127
-
128
- const results = [];
129
- for (const d of installed) {
130
- const tool = TOOLS.find(t => t.slug === d.slug);
131
- console.log(` ${tool.name}:`);
132
- tool.plans.forEach((p, i) => console.log(` ${i + 1}) ${p.label}`));
133
- const ans = await prompt(` Which plan? [1-${tool.plans.length}, Enter to skip]: `);
134
- const idx = parseInt(ans, 10) - 1;
135
- const plan = (idx >= 0 && idx < tool.plans.length) ? tool.plans[idx].slug : 'not_sure';
136
-
137
- let cycle_day = null;
138
- const chosen = tool.plans.find(p => p.slug === plan);
139
- // Only ask the renewal day for a real paid plan - "Free"/"Not sure"/API
140
- // have no monthly renewal to track a usage window against.
141
- const isPaid = chosen && !['free', 'hobby', 'not_sure', 'api'].includes(plan);
142
- if (isPaid) {
143
- const dayAns = await prompt(' What day of the month does it renew? [1-31, Enter to skip]: ');
144
- const day = parseInt(dayAns, 10);
145
- if (day >= 1 && day <= 31) cycle_day = day;
146
- }
147
- results.push({ slug: d.slug, installed: true, plan, cycle_day });
148
- console.log('');
149
- }
150
- return results;
151
- }
152
-
153
- // Non-interactive fallback (piped stdin / CI): report what's installed as
154
- // 'not_sure' with no prompt, so a scripted `troxy init` never hangs. The
155
- // dashboard still gets the inventory; the plan can be set later via the
156
- // dashboard or an interactive `troxy tools`.
157
- export function silentToolReport(detected) {
158
- return detected.filter(d => d.installed).map(d => ({ slug: d.slug, installed: true, plan: 'not_sure', cycle_day: null }));
159
- }
160
-
161
- export async function reportToolPlans(apiKey, results) {
162
- if (!results || results.length === 0) return;
84
+ if (installed.length === 0) return;
163
85
  try {
164
- await api.reportToolPlans(apiKey, results);
86
+ await api.reportToolPlans(apiKey, installed.map(d => ({ slug: d.slug, installed: true })));
165
87
  } catch (err) {
166
- // Non-fatal: a failed tool-plan report must never break `troxy init`.
167
- console.error(`\n Could not save AI tool info: ${err.message} (you can set it later in the dashboard).`);
88
+ // Non-fatal: a failed tool report must never break `troxy init`.
89
+ console.error(`\n Could not save AI tool info: ${err.message} (you can retry later with "troxy tools").`);
168
90
  }
169
91
  }
170
92
 
171
- // The full flow, shared by `troxy init` (interactive step) and `troxy tools`.
172
- export async function runToolDetection(apiKey, { interactive = true } = {}) {
93
+ // The full flow, shared by `troxy init` (a quiet inventory step) and
94
+ // `troxy tools` (an explicit re-scan). No prompting - detection is a fact,
95
+ // not a question - so `interactive` no longer changes behavior; kept as a
96
+ // parameter so callers don't need to change, in case a future non-detection
97
+ // step needs the distinction.
98
+ export async function runToolDetection(apiKey) {
173
99
  const detected = detectAiTools();
174
- const installedCount = detected.filter(d => d.installed).length;
175
- if (installedCount === 0) return;
176
-
177
- const results = (interactive && process.stdin.isTTY)
178
- ? await promptForToolPlans(detected)
179
- : silentToolReport(detected);
180
- await reportToolPlans(apiKey, results);
100
+ if (detected.every(d => !d.installed)) return;
101
+ await reportToolPlans(apiKey, detected);
181
102
  }