troxy-cli 1.21.1 → 1.23.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.
@@ -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
+ });
@@ -0,0 +1,181 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { execSync } from 'child_process';
5
+ import { api } from './api.js';
6
+ import { prompt, hasClaudeCode } from './init.js';
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.
13
+ //
14
+ // `detect()` returns true if the tool looks present. Two techniques, same as
15
+ // init.js's detectMcpClients: a `--version` probe for CLI tools, an
16
+ // existence check on the tool's OWN config dir for the rest - never the
17
+ // `.cursor/mcp.json` / windsurf `mcp_config.json` files, since `troxy init`
18
+ // may have created those itself even where the tool was later removed.
19
+
20
+ const home = os.homedir();
21
+
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) {
26
+ try { execSync(cmd, { stdio: 'ignore' }); return true; } catch { return false; }
27
+ }
28
+
29
+ export function anyPathExists(paths) {
30
+ return paths.some(p => { try { return fs.existsSync(p); } catch { return false; } });
31
+ }
32
+
33
+ export const TOOLS = [
34
+ {
35
+ slug: 'claude_code',
36
+ name: 'Claude Code',
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(),
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
+ },
50
+ {
51
+ slug: 'cursor',
52
+ name: 'Cursor',
53
+ detect: () => anyPathExists([
54
+ path.join(home, 'Library/Application Support/Cursor'),
55
+ path.join(process.env.APPDATA || home, 'Cursor'),
56
+ path.join(home, '.config/Cursor'),
57
+ ]),
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
+ },
65
+ {
66
+ slug: 'github_copilot',
67
+ name: 'GitHub Copilot',
68
+ detect: () => anyPathExists([
69
+ path.join(home, '.config/github-copilot/hosts.json'),
70
+ path.join(home, '.config/github-copilot/apps.json'),
71
+ path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData/Local'), 'github-copilot/hosts.json'),
72
+ ]),
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
+ },
80
+ {
81
+ slug: 'windsurf',
82
+ name: 'Windsurf',
83
+ 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
+ },
90
+ {
91
+ slug: 'continue',
92
+ name: 'Continue',
93
+ 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
+ },
100
+ {
101
+ slug: 'aider',
102
+ name: 'Aider',
103
+ 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
+ },
110
+ ];
111
+
112
+ export function detectAiTools() {
113
+ return TOOLS.map(t => ({ slug: t.slug, name: t.name, installed: !!t.detect() }));
114
+ }
115
+
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) {
122
+ 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;
163
+ try {
164
+ await api.reportToolPlans(apiKey, results);
165
+ } 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).`);
168
+ }
169
+ }
170
+
171
+ // The full flow, shared by `troxy init` (interactive step) and `troxy tools`.
172
+ export async function runToolDetection(apiKey, { interactive = true } = {}) {
173
+ 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);
181
+ }
@@ -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
- }