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.
- package/README.md +2 -1
- package/bin/troxy.js +97 -22
- package/package.json +7 -4
- package/src/api.js +18 -1
- package/src/data/policy-spec.json +496 -0
- package/src/data/templates.json +1429 -0
- package/src/init.js +13 -2
- package/src/mcp-server.js +86 -1
- package/src/mcps.js +1 -0
- package/src/policies.js +351 -114
- package/src/policy-fields.js +107 -0
- package/src/secrets.js +1 -1
- package/src/settings.js +0 -3
- package/src/simulate.js +251 -0
- package/src/tests/mcp-checkpoints.test.js +79 -0
- package/src/tests/model-checkpoint.test.js +5 -1
- package/src/tests/policies-create.test.js +218 -0
- package/src/tests/policies-org.test.js +60 -0
- package/src/tests/simulate.test.js +127 -0
- package/src/tests/templates.test.js +46 -0
- package/src/tests/tool_detect.test.js +389 -0
- package/src/tool_detect.js +181 -0
- package/src/chat-budget.js +0 -73
|
@@ -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
|
+
}
|
package/src/chat-budget.js
DELETED
|
@@ -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
|
-
}
|