troxy-cli 1.23.0 → 1.25.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/package.json +1 -1
- package/src/init.js +68 -3
- package/src/mcp-server.js +8 -2
- package/src/tests/claude-code-proxy.test.js +71 -0
- package/src/tests/tool_detect.test.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.25.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/init.js
CHANGED
|
@@ -47,7 +47,7 @@ export async function refreshHostedInstructions(key) {
|
|
|
47
47
|
return true;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export async function reprovisionKeyConsumers(key, agentName) {
|
|
50
|
+
export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null) {
|
|
51
51
|
const platform = process.platform;
|
|
52
52
|
const { detected, hasOpenClaw, hasClaude, none } = detectMcpClients();
|
|
53
53
|
|
|
@@ -100,6 +100,7 @@ export async function reprovisionKeyConsumers(key, agentName) {
|
|
|
100
100
|
} catch (err) {
|
|
101
101
|
console.log(` • Claude Code usage capture ✗ (${err.message})`);
|
|
102
102
|
}
|
|
103
|
+
await maybeEnableClaudeCodeProxy(proxyOptIn);
|
|
103
104
|
}
|
|
104
105
|
console.log('\n Restart your MCP client to activate Troxy.');
|
|
105
106
|
}
|
|
@@ -239,7 +240,10 @@ const MCP_CLIENTS = [
|
|
|
239
240
|
},
|
|
240
241
|
];
|
|
241
242
|
|
|
242
|
-
export async function runInit({ key, name } = {}) {
|
|
243
|
+
export async function runInit({ key, name, proxy } = {}) {
|
|
244
|
+
// undefined (flag never passed) -> null, so reprovisionKeyConsumers can
|
|
245
|
+
// tell "not decided, ask interactively" apart from an explicit --proxy.
|
|
246
|
+
const proxyOptIn = proxy === undefined ? null : Boolean(proxy);
|
|
243
247
|
if (!key || !key.startsWith('txy-')) {
|
|
244
248
|
console.error('\n Error: --key is required and must start with txy-');
|
|
245
249
|
console.error(' Usage: npx troxy-cli init --key txy-...\n');
|
|
@@ -300,7 +304,7 @@ export async function runInit({ key, name } = {}) {
|
|
|
300
304
|
// Non-fatal: the daemon's own heartbeat will retry this later.
|
|
301
305
|
}
|
|
302
306
|
|
|
303
|
-
await reprovisionKeyConsumers(key, agentName);
|
|
307
|
+
await reprovisionKeyConsumers(key, agentName, proxyOptIn);
|
|
304
308
|
|
|
305
309
|
// Scan for local AI coding tools and (interactively) confirm the plan for
|
|
306
310
|
// each, so the dashboard shows real subscription cost, not token math.
|
|
@@ -559,6 +563,67 @@ export function patchClaudeCodeHooks(
|
|
|
559
563
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
560
564
|
}
|
|
561
565
|
|
|
566
|
+
// checklist #17 ("one-command proxy setup") - routes Claude Code's model
|
|
567
|
+
// calls through Troxy's model-proxy instead of straight to Anthropic, so
|
|
568
|
+
// evaluate_model's advisory model-swap suggestions become real, enforced
|
|
569
|
+
// savings instead of just advice an agent can ignore. proxy.troxy.io is a
|
|
570
|
+
// small Cloudflare Worker (troxy-proxy-gateway) in front of the model-proxy
|
|
571
|
+
// Lambda - see that repo's README for why a plain custom domain doesn't
|
|
572
|
+
// work here.
|
|
573
|
+
export function troxyModelProxyBaseUrl() {
|
|
574
|
+
return 'https://proxy.troxy.io';
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Two real, verified side effects of setting ANTHROPIC_BASE_URL (confirmed
|
|
578
|
+
// against Anthropic's own docs, code.claude.com/docs/en/env-vars,
|
|
579
|
+
// 2026-08-29), which is why this is opt-in with a warning rather than
|
|
580
|
+
// silently turned on for every Claude Code user this function runs against:
|
|
581
|
+
// 1. MCP tool search is disabled by default once the base URL isn't
|
|
582
|
+
// api.anthropic.com, unless ENABLE_TOOL_SEARCH=true is also set - the
|
|
583
|
+
// model-proxy already forwards tool_reference blocks fine (it's a
|
|
584
|
+
// pure JSON pass-through), so this is set alongside, not worked around.
|
|
585
|
+
// 2. Remote Control is disabled while the base URL points elsewhere
|
|
586
|
+
// (Claude Code v2.1.196+) - nothing to configure around this one, it's
|
|
587
|
+
// just true while the proxy is active, hence the up-front warning.
|
|
588
|
+
export function patchClaudeCodeProxy(configPath, baseUrl = troxyModelProxyBaseUrl()) {
|
|
589
|
+
let config = {};
|
|
590
|
+
try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
|
|
591
|
+
if (!config.env) config.env = {};
|
|
592
|
+
config.env.ANTHROPIC_BASE_URL = baseUrl;
|
|
593
|
+
config.env.ENABLE_TOOL_SEARCH = 'true';
|
|
594
|
+
|
|
595
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
596
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Opt-in only (Gilad, 2026-08-29): the Remote Control tradeoff is real and
|
|
600
|
+
// user-visible, so this must be a deliberate yes, never a default-on
|
|
601
|
+
// behavior change slipped into an ordinary init run.
|
|
602
|
+
// proxyOptIn === true -> --proxy was passed explicitly, enable without asking
|
|
603
|
+
// proxyOptIn === null -> not passed; ask interactively if there's a TTY,
|
|
604
|
+
// otherwise skip (a scripted/CI init must never hang
|
|
605
|
+
// on a prompt, and silence must never mean yes)
|
|
606
|
+
async function maybeEnableClaudeCodeProxy(proxyOptIn) {
|
|
607
|
+
let enable = proxyOptIn === true;
|
|
608
|
+
if (proxyOptIn === null && process.stdin.isTTY) {
|
|
609
|
+
console.log("\n Route Claude Code's model calls through Troxy for automatic cost");
|
|
610
|
+
console.log(' optimization? Troxy can swap in a cheaper model when it finds an');
|
|
611
|
+
console.log(' opportunity, not just suggest one.');
|
|
612
|
+
console.log(" Trade-off: while this is on, Claude Code's Remote Control feature is");
|
|
613
|
+
console.log(' disabled (Anthropic disables it whenever the API base URL points');
|
|
614
|
+
console.log(' anywhere other than api.anthropic.com).');
|
|
615
|
+
const answer = await prompt(' Enable? (y/N): ');
|
|
616
|
+
enable = /^y(es)?$/i.test(answer);
|
|
617
|
+
}
|
|
618
|
+
if (!enable) return;
|
|
619
|
+
try {
|
|
620
|
+
patchClaudeCodeProxy(claudeCodeSettingsPath());
|
|
621
|
+
console.log(` • Claude Code model proxy (cost optimization) ✓`);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
console.log(` • Claude Code model proxy ✗ (${err.message})`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
562
627
|
// Detects Claude Code the same way the daemon detects OpenClaw: by whether
|
|
563
628
|
// its own CLI is on PATH, since (unlike the other five clients) there is no
|
|
564
629
|
// single marker *file* whose mere existence means "Claude Code is
|
package/src/mcp-server.js
CHANGED
|
@@ -578,17 +578,23 @@ export async function runMcp() {
|
|
|
578
578
|
if (result.error) {
|
|
579
579
|
return { content: [{ type: 'text', text: `Troxy error: ${result.error}` }], isError: true };
|
|
580
580
|
}
|
|
581
|
-
const { decision, reason, audit_id, approval_token } = result;
|
|
581
|
+
const { decision, reason, audit_id, approval_token, suggested_model, suggestion_reason } = result;
|
|
582
582
|
const what = `${args.model}${args.effort ? ` at ${args.effort} effort` : ''}`;
|
|
583
583
|
// Every non-blocked branch repeats the audit_id and the instruction to
|
|
584
584
|
// report back. Half the value of this checkpoint is the second call, and
|
|
585
585
|
// an agent that is only told "approved" has no reason to make it.
|
|
586
586
|
const followUp = ` When this turn finishes, call report_model_usage(audit_id="${audit_id}", actual_tokens=<real total>).`;
|
|
587
|
+
// Advisory only, dark-shipped per org - most orgs never see this field.
|
|
588
|
+
// Same "call again with the new id" pattern a BLOCK already teaches,
|
|
589
|
+
// just optional here: the call is still approved either way.
|
|
590
|
+
const suggestionText = suggested_model
|
|
591
|
+
? ` Suggestion: ${suggestion_reason || `${suggested_model} would likely work just as well here.`} If you'd like to use it, call evaluate_model again with model="${suggested_model}" — no obligation, this run is already approved as-is.`
|
|
592
|
+
: '';
|
|
587
593
|
let modelText;
|
|
588
594
|
switch (decision) {
|
|
589
595
|
case 'ALLOW':
|
|
590
596
|
case 'NOTIFY':
|
|
591
|
-
modelText = `✓ Approved: ${what}.${reason ? ` ${reason}` : ''} You may run it.${followUp} (audit: ${audit_id})`;
|
|
597
|
+
modelText = `✓ Approved: ${what}.${reason ? ` ${reason}` : ''} You may run it.${followUp}${suggestionText} (audit: ${audit_id})`;
|
|
592
598
|
break;
|
|
593
599
|
case 'BLOCK':
|
|
594
600
|
modelText = `✗ Blocked: ${what}.${reason ? ` ${reason}` : ''} Do not use this model for this task. Choose a cheaper or smaller model and call evaluate_model again with the new model id. (audit: ${audit_id})`;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Checklist #17 ("one-command proxy setup"): patchClaudeCodeProxy writes
|
|
2
|
+
// ANTHROPIC_BASE_URL + ENABLE_TOOL_SEARCH into Claude Code's global
|
|
3
|
+
// settings.json `env` block, so its own model calls route through
|
|
4
|
+
// proxy.troxy.io instead of straight to Anthropic. This is opt-in only
|
|
5
|
+
// (Gilad, 2026-08-29) - the prompt/flag gating that decides WHETHER to call
|
|
6
|
+
// this lives in reprovisionKeyConsumers/maybeEnableClaudeCodeProxy, not
|
|
7
|
+
// tested here; this file covers what actually gets written once it's
|
|
8
|
+
// called, same split as claude-code-hook.test.js does for the hooks patch.
|
|
9
|
+
|
|
10
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { patchClaudeCodeProxy, troxyModelProxyBaseUrl } from '../init.js';
|
|
17
|
+
|
|
18
|
+
let dir;
|
|
19
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-cc-proxy-')); });
|
|
20
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
21
|
+
|
|
22
|
+
describe('patchClaudeCodeProxy', () => {
|
|
23
|
+
const configPath = () => path.join(dir, 'settings.json');
|
|
24
|
+
const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
25
|
+
|
|
26
|
+
it('creates the env block with ANTHROPIC_BASE_URL when the file does not exist', () => {
|
|
27
|
+
patchClaudeCodeProxy(configPath());
|
|
28
|
+
const cfg = read();
|
|
29
|
+
assert.equal(cfg.env.ANTHROPIC_BASE_URL, troxyModelProxyBaseUrl());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('sets ENABLE_TOOL_SEARCH so the model-proxy\'s forwarded tool_reference blocks still work', () => {
|
|
33
|
+
patchClaudeCodeProxy(configPath());
|
|
34
|
+
const cfg = read();
|
|
35
|
+
assert.equal(cfg.env.ENABLE_TOOL_SEARCH, 'true');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('accepts a custom base URL for testing/overrides instead of hardcoding proxy.troxy.io', () => {
|
|
39
|
+
patchClaudeCodeProxy(configPath(), 'https://staging.example.com');
|
|
40
|
+
const cfg = read();
|
|
41
|
+
assert.equal(cfg.env.ANTHROPIC_BASE_URL, 'https://staging.example.com');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('preserves unrelated existing env keys instead of replacing the whole block', () => {
|
|
45
|
+
fs.writeFileSync(configPath(), JSON.stringify({ env: { SOME_OTHER_VAR: 'keep-me' } }));
|
|
46
|
+
patchClaudeCodeProxy(configPath());
|
|
47
|
+
const cfg = read();
|
|
48
|
+
assert.equal(cfg.env.SOME_OTHER_VAR, 'keep-me');
|
|
49
|
+
assert.equal(cfg.env.ANTHROPIC_BASE_URL, troxyModelProxyBaseUrl());
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('preserves unrelated top-level keys (e.g. hooks already written by patchClaudeCodeHooks)', () => {
|
|
53
|
+
fs.writeFileSync(configPath(), JSON.stringify({ hooks: { Stop: [{ hooks: [{ command: 'x' }] }] } }));
|
|
54
|
+
patchClaudeCodeProxy(configPath());
|
|
55
|
+
const cfg = read();
|
|
56
|
+
assert.ok(cfg.hooks.Stop.length === 1, 'existing hooks block was dropped');
|
|
57
|
+
assert.equal(cfg.env.ANTHROPIC_BASE_URL, troxyModelProxyBaseUrl());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('re-running is idempotent - overwrites rather than duplicating or erroring', () => {
|
|
61
|
+
patchClaudeCodeProxy(configPath());
|
|
62
|
+
patchClaudeCodeProxy(configPath());
|
|
63
|
+
const cfg = read();
|
|
64
|
+
assert.equal(cfg.env.ANTHROPIC_BASE_URL, troxyModelProxyBaseUrl());
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not throw when the existing file is corrupted JSON', () => {
|
|
68
|
+
fs.writeFileSync(configPath(), '{ not json');
|
|
69
|
+
assert.doesNotThrow(() => patchClaudeCodeProxy(configPath()));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -373,7 +373,7 @@ describe('wiring: api.js / bin/troxy.js / init.js', () => {
|
|
|
373
373
|
});
|
|
374
374
|
|
|
375
375
|
it('runInit calls tool detection after reprovisionKeyConsumers, wrapped so a scan failure never blocks init', () => {
|
|
376
|
-
const reprovisionIdx = initSrc.indexOf('reprovisionKeyConsumers(key, agentName)');
|
|
376
|
+
const reprovisionIdx = initSrc.indexOf('reprovisionKeyConsumers(key, agentName, proxyOptIn)');
|
|
377
377
|
const toolIdx = initSrc.indexOf('runToolDetection');
|
|
378
378
|
assert.ok(reprovisionIdx !== -1, 'reprovisionKeyConsumers call not found in init.js');
|
|
379
379
|
assert.ok(toolIdx !== -1, 'runToolDetection not wired into runInit');
|