@anyforge/control 0.2.2 → 0.2.4
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 +15 -3
- package/dist/cli.js +5 -1
- package/dist/commands/init.js +143 -38
- package/dist/commands/logout.js +22 -9
- package/dist/lib/configPatch.js +312 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,13 +28,25 @@ Pick whichever suits the moment — the choices aren't mutually exclusive over t
|
|
|
28
28
|
|
|
29
29
|
- Want to add a second repo? Re-run `npx @anyforge/control init` from that repo's root and pick `repo` again.
|
|
30
30
|
- Want to switch to a system-wide install later? Re-run from anywhere and pick `global`.
|
|
31
|
-
- Want to undo a per-repo install?
|
|
31
|
+
- Want to undo a per-repo install? Run `anyforge logout` — it now reverts the patched configs (`~/.claude/settings.json`, `<repo>/.claude/settings.local.json`, the `# === AnyForge Control ===` block in `.env` / your shell rc) and restores any previously-shadowed login.
|
|
32
32
|
|
|
33
33
|
> The `.env` written in per-repo mode contains a live `ANYFORGE_API_KEY`. The CLI warns if your repo's `.gitignore` doesn't already exclude `.env`. **Add it before committing.**
|
|
34
34
|
|
|
35
|
+
### Claude Pro / Max subscription users
|
|
36
|
+
|
|
37
|
+
The inference proxy is **BYOK-only**: it routes Claude Code through your own Anthropic *API key* for metering and governance. It is **incompatible with a Claude Pro/Max (subscription) login** — the `ANTHROPIC_AUTH_TOKEN` it patches out-ranks (Claude Code auth precedence #2) and therefore disables the subscription login (#6), and the proxy has no upstream API key for a subscription user, so Claude Code falls back to "please login".
|
|
38
|
+
|
|
39
|
+
`anyforge init` detects a subscription login and **skips patching**, steering you to the **AnyForge MCP server** instead — which keeps your subscription for inference and adds AnyForge control (HIL approvals, work items, crews) on top, with no auth conflict:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
claude mcp add anyforge --env ANYFORGE_API_KEY=<your key> -- npx -y @anyforge/mcp-server
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If you really want the metered BYOK proxy, re-run `anyforge init --force` (you'll need an Anthropic API key configured on your company; this disables your subscription in Claude Code until you `anyforge logout`).
|
|
46
|
+
|
|
35
47
|
### Why `ANTHROPIC_AUTH_TOKEN`, not `ANTHROPIC_API_KEY`?
|
|
36
48
|
|
|
37
|
-
Claude Code recognises both, but they behave differently. `ANTHROPIC_API_KEY` is sent as `X-Api-Key` and — when an OAuth Console login is also present — surfaces an "Auth conflict: Using ANTHROPIC_API_KEY instead of Anthropic Console key" warning, and can cascade into a "selected model may not exist" error. `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer …`, is documented for "routing through an LLM gateway or proxy",
|
|
49
|
+
Claude Code recognises both, but they behave differently. `ANTHROPIC_API_KEY` is sent as `X-Api-Key` and — when an OAuth Console login is also present — surfaces an "Auth conflict: Using ANTHROPIC_API_KEY instead of Anthropic Console key" warning, and can cascade into a "selected model may not exist" error. `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer …`, is documented for "routing through an LLM gateway or proxy", and matches the bearer-token shape AnyForge expects. We patch the token form, and on every install we explicitly delete any stale `ANTHROPIC_API_KEY` left behind by older versions. Note that it out-ranks a subscription login (see above) — which is exactly why `init` refuses to patch over a Pro/Max login by default.
|
|
38
50
|
|
|
39
51
|
## Commands
|
|
40
52
|
|
|
@@ -43,7 +55,7 @@ Claude Code recognises both, but they behave differently. `ANTHROPIC_API_KEY` is
|
|
|
43
55
|
| `anyforge init` | Authorise this device and patch local agent configs |
|
|
44
56
|
| `anyforge whoami` | Show the company tied to the saved key |
|
|
45
57
|
| `anyforge status` | Verify the proxy is reachable and your BYOK key is configured |
|
|
46
|
-
| `anyforge logout` | Clear the local credential file (does not revoke server-side) |
|
|
58
|
+
| `anyforge logout` | Clear the local credential file **and revert patched configs** (does not revoke server-side) |
|
|
47
59
|
|
|
48
60
|
## Endpoints
|
|
49
61
|
|
package/dist/cli.js
CHANGED
|
@@ -41,6 +41,8 @@ function parseArgs(argv) {
|
|
|
41
41
|
a.threeWay = true;
|
|
42
42
|
else if (v === '--dry-run')
|
|
43
43
|
a.dryRun = true;
|
|
44
|
+
else if (v === '--force' || v === '-f')
|
|
45
|
+
a.force = true;
|
|
44
46
|
else if (!v.startsWith('-'))
|
|
45
47
|
a.positional.push(v);
|
|
46
48
|
}
|
|
@@ -63,6 +65,8 @@ ${bold('COMMANDS')}
|
|
|
63
65
|
|
|
64
66
|
${bold('OPTIONS')}
|
|
65
67
|
--endpoint <url> Override the proxy base URL (default: ${dim('https://crew.anyforge.ai')})
|
|
68
|
+
--force, -f init: patch the BYOK proxy even if a Claude subscription
|
|
69
|
+
login is present (this disables that subscription in Claude Code)
|
|
66
70
|
--3way apply-patch: try 3-way merge if the patch doesn't apply cleanly
|
|
67
71
|
--dry-run apply-patch: only check applicability, don't write
|
|
68
72
|
--help, -h Show this message
|
|
@@ -97,7 +101,7 @@ async function main() {
|
|
|
97
101
|
try {
|
|
98
102
|
switch (args.command) {
|
|
99
103
|
case 'init':
|
|
100
|
-
await runInit({ endpoint });
|
|
104
|
+
await runInit({ endpoint, force: args.force });
|
|
101
105
|
break;
|
|
102
106
|
case 'whoami':
|
|
103
107
|
await runWhoami();
|
package/dist/commands/init.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { homedir } from 'os';
|
|
3
|
-
import { join } from 'path';
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
4
2
|
import open from 'open';
|
|
5
3
|
import prompts from 'prompts';
|
|
6
4
|
import { green, dim, bold, yellow, cyan } from 'kleur/colors';
|
|
7
5
|
import { saveCredentials } from '../lib/credentials.js';
|
|
8
|
-
import { detectAndPatchConfigs } from '../lib/configPatch.js';
|
|
6
|
+
import { detectAndPatchConfigs, detectClaudeSubscription, patchClaudeTelemetry, } from '../lib/configPatch.js';
|
|
9
7
|
const POLL_INTERVAL_MS = 2_000;
|
|
10
8
|
const POLL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
11
9
|
export async function runInit(opts) {
|
|
@@ -14,8 +12,18 @@ export async function runInit(opts) {
|
|
|
14
12
|
console.log(bold('AnyForge Control — device authorisation'));
|
|
15
13
|
// eslint-disable-next-line no-console
|
|
16
14
|
console.log(dim(` endpoint: ${endpoint}`));
|
|
17
|
-
// 1. Issue a one-time code.
|
|
18
|
-
|
|
15
|
+
// 1. Issue a one-time code. We detect the git remote up-front when
|
|
16
|
+
// running in a repo so it can be attached to the API key later.
|
|
17
|
+
// Detection happens before scope-pick so the server has it
|
|
18
|
+
// regardless of which scope the operator chooses.
|
|
19
|
+
const cwd0 = process.cwd();
|
|
20
|
+
const gitRoot0 = findGitRoot(cwd0);
|
|
21
|
+
const earlyRepo = gitRoot0 ? detectGitRemoteRepo(gitRoot0) : null;
|
|
22
|
+
const initRes = await fetch(`${endpoint}/api/cli/init`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'content-type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ repo: earlyRepo }),
|
|
26
|
+
});
|
|
19
27
|
const init = (await initRes.json());
|
|
20
28
|
if (!init.ok || !init.data) {
|
|
21
29
|
throw new Error(`Could not start the authorisation flow: ${init.error ?? 'unknown error'}`);
|
|
@@ -45,12 +53,25 @@ export async function runInit(opts) {
|
|
|
45
53
|
await saveCredentials(credentials);
|
|
46
54
|
// eslint-disable-next-line no-console
|
|
47
55
|
console.log(green('✔') + ' API key saved to ' + dim('~/.anyforge/credentials'));
|
|
56
|
+
// 4b. A Claude Pro/Max subscription login is mutually exclusive with
|
|
57
|
+
// the BYOK inference proxy: the ANTHROPIC_AUTH_TOKEN we'd patch
|
|
58
|
+
// out-ranks (and thereby disables) the subscription login, and the
|
|
59
|
+
// proxy has no upstream Anthropic key for a subscription user. So
|
|
60
|
+
// we skip patching and steer them to the MCP server instead — which
|
|
61
|
+
// keeps their subscription for inference and adds AnyForge control
|
|
62
|
+
// on top. `--force` overrides for users who really do want a
|
|
63
|
+
// BYOK-keyed metered proxy.
|
|
64
|
+
const sub = await detectClaudeSubscription();
|
|
65
|
+
if (sub.isSubscription && !opts.force) {
|
|
66
|
+
await guideSubscriptionUser(sub, endpoint, credentials.apiKey);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
48
69
|
// 5. Pick a scope. The repo option only shows when we're sitting
|
|
49
70
|
// in a git working tree root — that matches the documented
|
|
50
71
|
// contract and avoids accidentally writing settings into a
|
|
51
72
|
// subdirectory the operator didn't mean to target.
|
|
52
73
|
const cwd = process.cwd();
|
|
53
|
-
const repoRoot = (
|
|
74
|
+
const repoRoot = findGitRoot(cwd);
|
|
54
75
|
const scope = await pickScope(repoRoot);
|
|
55
76
|
// 6. Patch the chosen target. apiKey is inlined into the env map
|
|
56
77
|
// so Claude Code can read it without a shell restart.
|
|
@@ -73,24 +94,17 @@ export async function runInit(opts) {
|
|
|
73
94
|
}
|
|
74
95
|
// eslint-disable-next-line no-console
|
|
75
96
|
console.log();
|
|
76
|
-
if (
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// by precedence (#2 vs #6), the warning is cosmetic-but-loud. The
|
|
80
|
-
// only way to silence it is to stay logged out: /logout once, then
|
|
81
|
-
// skip the login prompt on the next launch.
|
|
82
|
-
// eslint-disable-next-line no-console
|
|
83
|
-
console.log(yellow(' ⚠ Existing Claude Code login detected (~/.claude/.credentials.json).'));
|
|
84
|
-
// eslint-disable-next-line no-console
|
|
85
|
-
console.log(dim(' Claude Code will work, but it will warn that both a token and a'));
|
|
97
|
+
if (sub.isSubscription && opts.force) {
|
|
98
|
+
// They overrode the subscription guard. Be explicit about the
|
|
99
|
+
// trade-off and the one-command way back.
|
|
86
100
|
// eslint-disable-next-line no-console
|
|
87
|
-
console.log(
|
|
101
|
+
console.log(yellow(` ⚠ --force: your Claude ${sub.plan ?? ''} subscription login is now shadowed`));
|
|
88
102
|
// eslint-disable-next-line no-console
|
|
89
|
-
console.log(
|
|
103
|
+
console.log(yellow(' by the AnyForge token — Claude Code will use the BYOK proxy, which'));
|
|
90
104
|
// eslint-disable-next-line no-console
|
|
91
|
-
console.log(
|
|
105
|
+
console.log(yellow(' needs an Anthropic API key on your company. Run ') + bold('anyforge logout'));
|
|
92
106
|
// eslint-disable-next-line no-console
|
|
93
|
-
console.log(
|
|
107
|
+
console.log(yellow(' to revert and restore your subscription.'));
|
|
94
108
|
}
|
|
95
109
|
if (scope === 'repo') {
|
|
96
110
|
// eslint-disable-next-line no-console
|
|
@@ -132,29 +146,90 @@ async function pollForToken(args) {
|
|
|
132
146
|
}
|
|
133
147
|
throw new Error('Authorisation timed out (15 min). Re-run `anyforge init`.');
|
|
134
148
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Steer a subscription user to the MCP server instead of patching the
|
|
151
|
+
* BYOK proxy. The MCP server keeps their Claude Pro/Max login for
|
|
152
|
+
* inference and adds AnyForge control (HIL approvals, work items, crews)
|
|
153
|
+
* on top, with no auth conflict. Offers to run `claude mcp add` for them,
|
|
154
|
+
* and to enable OpenTelemetry usage metrics so their spend is still
|
|
155
|
+
* visible on the AnyForge dashboard even though the proxy isn't in path.
|
|
156
|
+
*/
|
|
157
|
+
async function guideSubscriptionUser(sub, endpoint, apiKey) {
|
|
158
|
+
const who = sub.email ? ` (${sub.email})` : '';
|
|
159
|
+
/* eslint-disable no-console */
|
|
160
|
+
console.log();
|
|
161
|
+
console.log(yellow(` ⚠ Claude ${sub.plan ?? ''} subscription login detected${who}.`));
|
|
162
|
+
console.log();
|
|
163
|
+
console.log(' AnyForge Control’s inference proxy is ' + bold('BYOK-only') + ' — it routes');
|
|
164
|
+
console.log(' Claude Code through your own Anthropic API key for metering and');
|
|
165
|
+
console.log(' governance. Pointing Claude Code at the proxy would ' + bold('override and'));
|
|
166
|
+
console.log(' ' + bold('disable your subscription login') + ', so we did NOT patch your config.');
|
|
167
|
+
console.log();
|
|
168
|
+
console.log(' Instead, use your subscription for inference and add the ' + bold('AnyForge'));
|
|
169
|
+
console.log(' ' + bold('MCP server') + ' for control (HIL approvals, work items, crews):');
|
|
170
|
+
console.log();
|
|
171
|
+
const mcpCmd = `claude mcp add anyforge --env ANYFORGE_API_KEY=${apiKey} -- npx -y @anyforge/mcp-server`;
|
|
172
|
+
const add = await prompts({
|
|
173
|
+
type: 'confirm',
|
|
174
|
+
name: 'go',
|
|
175
|
+
message: 'Add the AnyForge MCP server to Claude Code now?',
|
|
176
|
+
initial: true,
|
|
177
|
+
});
|
|
178
|
+
if (add.go) {
|
|
179
|
+
try {
|
|
180
|
+
execFileSync('claude', ['mcp', 'add', 'anyforge', '--env', `ANYFORGE_API_KEY=${apiKey}`, '--', 'npx', '-y', '@anyforge/mcp-server'], { stdio: 'inherit' });
|
|
181
|
+
console.log(green(' ✔') + ' AnyForge MCP server added to Claude Code.');
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
console.log(yellow(' Could not run `claude mcp add` automatically (is the `claude` CLI on PATH?).'));
|
|
185
|
+
console.log(dim(' Add it manually:'));
|
|
186
|
+
console.log(' ' + mcpCmd);
|
|
187
|
+
}
|
|
144
188
|
}
|
|
145
|
-
|
|
146
|
-
|
|
189
|
+
else {
|
|
190
|
+
console.log(dim(' Add it later with:'));
|
|
191
|
+
console.log(' ' + mcpCmd);
|
|
147
192
|
}
|
|
193
|
+
// Spend visibility without the proxy: Claude Code exports OpenTelemetry
|
|
194
|
+
// usage metrics (token counts + estimated cost — no prompt content) that
|
|
195
|
+
// AnyForge can ingest. Safe alongside the subscription login (no auth
|
|
196
|
+
// env touched).
|
|
197
|
+
console.log();
|
|
198
|
+
const tel = await prompts({
|
|
199
|
+
type: 'confirm',
|
|
200
|
+
name: 'go',
|
|
201
|
+
message: 'Send Claude Code usage to your AnyForge dashboard? (token counts only — no prompt content)',
|
|
202
|
+
initial: true,
|
|
203
|
+
});
|
|
204
|
+
if (tel.go) {
|
|
205
|
+
const sum = await patchClaudeTelemetry({ endpoint, apiKey });
|
|
206
|
+
if (sum) {
|
|
207
|
+
console.log(green(' ✔') + ' Usage telemetry enabled — view it at ' + cyan(`${endpoint}/usage`));
|
|
208
|
+
console.log(dim(' (written to ' + sum.path + '; `anyforge logout` removes it)'));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
console.log(dim(' Skipped — your subscription usage stays local to Claude Code.'));
|
|
213
|
+
}
|
|
214
|
+
console.log();
|
|
215
|
+
console.log(dim(' Want the metered BYOK proxy instead? Re-run with ') + bold('--force') + dim(' using an'));
|
|
216
|
+
console.log(dim(' Anthropic API key on your company (this disables your subscription in'));
|
|
217
|
+
console.log(dim(' Claude Code; ') + bold('anyforge logout') + dim(' reverts it).'));
|
|
218
|
+
console.log();
|
|
219
|
+
console.log(bold('Done.') + ' Visit ' + cyan(`${endpoint}/usage`) + ' to see your traffic.');
|
|
220
|
+
/* eslint-enable no-console */
|
|
148
221
|
}
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
// accept either a directory or a regular file.
|
|
222
|
+
function findGitRoot(cwd) {
|
|
223
|
+
// Works from any subdirectory — git walks up the tree to find the root.
|
|
152
224
|
try {
|
|
153
|
-
|
|
154
|
-
|
|
225
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
226
|
+
cwd,
|
|
227
|
+
encoding: 'utf8',
|
|
228
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
229
|
+
}).trim();
|
|
155
230
|
}
|
|
156
231
|
catch {
|
|
157
|
-
return
|
|
232
|
+
return null;
|
|
158
233
|
}
|
|
159
234
|
}
|
|
160
235
|
async function pickScope(repoRoot) {
|
|
@@ -186,6 +261,36 @@ function summarisePatches(patches) {
|
|
|
186
261
|
console.log(` ${dim('•')} ${p.label} ${dim('(' + p.path + ')')}`);
|
|
187
262
|
}
|
|
188
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Read the `origin` remote URL and normalise it to `owner/repo`.
|
|
266
|
+
* Handles both SSH (`git@github.com:owner/repo.git`) and HTTPS forms.
|
|
267
|
+
* Returns null when there is no remote, git is unavailable, or the
|
|
268
|
+
* URL can't be parsed into an owner/repo pair.
|
|
269
|
+
*
|
|
270
|
+
* Uses execFileSync (not exec/execSync) so no shell interpolation
|
|
271
|
+
* occurs — the args array is passed directly to the git binary.
|
|
272
|
+
*/
|
|
273
|
+
function detectGitRemoteRepo(repoRoot) {
|
|
274
|
+
try {
|
|
275
|
+
const raw = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
276
|
+
cwd: repoRoot,
|
|
277
|
+
encoding: 'utf8',
|
|
278
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
279
|
+
}).trim();
|
|
280
|
+
// SSH: git@github.com:owner/repo.git
|
|
281
|
+
const sshMatch = raw.match(/^git@[\w.-]+:([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
282
|
+
if (sshMatch)
|
|
283
|
+
return sshMatch[1];
|
|
284
|
+
// HTTPS: https://github.com/owner/repo[.git]
|
|
285
|
+
const httpsMatch = raw.match(/^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
286
|
+
if (httpsMatch)
|
|
287
|
+
return httpsMatch[1];
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
189
294
|
// Re-exported so future commands (e.g. status) can reuse the prompt
|
|
190
295
|
// shape without duplicating the question text.
|
|
191
296
|
export { prompts };
|
package/dist/commands/logout.js
CHANGED
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
import { green, dim, yellow } from 'kleur/colors';
|
|
2
|
-
import { clearCredentials } from '../lib/credentials.js';
|
|
2
|
+
import { clearCredentials, loadCredentials } from '../lib/credentials.js';
|
|
3
|
+
import { revertClaudeCodeConfigs } from '../lib/configPatch.js';
|
|
3
4
|
/**
|
|
4
|
-
* `npx @anyforge/control logout` — wipe the local credential file
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `npx @anyforge/control logout` — wipe the local credential file AND
|
|
6
|
+
* revert the config patches `init` wrote (so a previously-shadowed
|
|
7
|
+
* Claude subscription login is restored). Does NOT revoke the key on
|
|
8
|
+
* the server side — that's a separate action on /settings → API keys.
|
|
8
9
|
*/
|
|
9
10
|
export async function runLogout() {
|
|
11
|
+
// Load before clearing so we know which proxy host we patched in
|
|
12
|
+
// (matters when the user ran `init --endpoint <staging>`).
|
|
13
|
+
const creds = await loadCredentials();
|
|
10
14
|
const removed = await clearCredentials();
|
|
15
|
+
const { reverted } = await revertClaudeCodeConfigs({ endpoint: creds?.endpoint ?? null });
|
|
16
|
+
/* eslint-disable no-console */
|
|
11
17
|
if (removed) {
|
|
12
|
-
// eslint-disable-next-line no-console
|
|
13
18
|
console.log(green('✔') + ' Local credentials cleared.');
|
|
14
|
-
// eslint-disable-next-line no-console
|
|
15
|
-
console.log(dim(' Revoke the key server-side at /settings → API keys.'));
|
|
16
19
|
}
|
|
17
20
|
else {
|
|
18
|
-
// eslint-disable-next-line no-console
|
|
19
21
|
console.log(yellow('No credentials were saved on this machine.'));
|
|
20
22
|
}
|
|
23
|
+
if (reverted.length) {
|
|
24
|
+
console.log(green('✔') + ' Reverted patched configs:');
|
|
25
|
+
for (const p of reverted)
|
|
26
|
+
console.log(` ${dim('•')} ${p}`);
|
|
27
|
+
console.log(dim(' Your previous Claude Code login (if any) is restored.'));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
console.log(dim(' No AnyForge config patches found to revert.'));
|
|
31
|
+
}
|
|
32
|
+
console.log(dim(' Revoke the key server-side at /settings → API keys.'));
|
|
33
|
+
/* eslint-enable no-console */
|
|
21
34
|
}
|
package/dist/lib/configPatch.js
CHANGED
|
@@ -1,8 +1,70 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
2
3
|
import { homedir } from 'os';
|
|
3
4
|
import { join } from 'path';
|
|
4
5
|
import prompts from 'prompts';
|
|
5
6
|
import { dim, green, yellow } from 'kleur/colors';
|
|
7
|
+
/**
|
|
8
|
+
* Patch local agent configurations so they route through AnyForge.
|
|
9
|
+
*
|
|
10
|
+
* Scope:
|
|
11
|
+
* - 'global' → ~/.claude/settings.json, plus optional shell-rc
|
|
12
|
+
* hints for Cursor / Cline / Continue (which don't have a
|
|
13
|
+
* repo-local config we can target).
|
|
14
|
+
* - 'repo' → <repoRoot>/.claude/settings.local.json (gitignored
|
|
15
|
+
* by Claude Code's own conventions) plus <repoRoot>/.env. We
|
|
16
|
+
* skip Cursor/Cline shell-rc patches in this scope so the
|
|
17
|
+
* install stays scoped to the repo with no global side effects.
|
|
18
|
+
*
|
|
19
|
+
* Auth header: Claude Code has two env vars for credentials.
|
|
20
|
+
* - ANTHROPIC_API_KEY → sent as `X-Api-Key`. Triggers an "Auth
|
|
21
|
+
* conflict: Using ANTHROPIC_API_KEY instead of Anthropic
|
|
22
|
+
* Console key" warning when an OAuth login is also present,
|
|
23
|
+
* and (per the docs) can cascade into a "selected model may
|
|
24
|
+
* not exist" error.
|
|
25
|
+
* - ANTHROPIC_AUTH_TOKEN → sent as `Authorization: Bearer …`.
|
|
26
|
+
* Documented for "routing through an LLM gateway or proxy"
|
|
27
|
+
* and matches AnyForge's bearer-token shape.
|
|
28
|
+
* We use ANTHROPIC_AUTH_TOKEN. We also explicitly delete any
|
|
29
|
+
* stale ANTHROPIC_API_KEY from the env map on every run so users
|
|
30
|
+
* upgrading from earlier installs don't keep both set.
|
|
31
|
+
*
|
|
32
|
+
* IMPORTANT precedence caveat: ANTHROPIC_AUTH_TOKEN (#2 in Claude
|
|
33
|
+
* Code's auth order) OUT-RANKS a Claude Pro/Max subscription login
|
|
34
|
+
* (#6). Patching it for a subscription user silently disables their
|
|
35
|
+
* subscription and points Claude Code at the BYOK-only proxy, which
|
|
36
|
+
* has no upstream key for them → "please login". The caller
|
|
37
|
+
* (`init`) therefore gates patching behind `detectClaudeSubscription()`
|
|
38
|
+
* and steers subscription users to the MCP server instead. We always
|
|
39
|
+
* write a `.anyforge-bak` before patching so `logout` can revert.
|
|
40
|
+
*
|
|
41
|
+
* The actual API key is inlined into the env map. We used to write
|
|
42
|
+
* a `${ANYFORGE_API_KEY}` placeholder and rely on a shell export,
|
|
43
|
+
* which (a) needed a fresh shell to take effect and (b) failed
|
|
44
|
+
* silently when the export never landed because the user only had
|
|
45
|
+
* Claude Code installed.
|
|
46
|
+
*/
|
|
47
|
+
// Sentinel markers wrapping the block we own in .env / shell-rc files,
|
|
48
|
+
// so re-running replaces in place and `logout` can strip it cleanly.
|
|
49
|
+
const SENTINEL_BEGIN = '# === AnyForge Control — added by `anyforge init` ===';
|
|
50
|
+
const SENTINEL_END = '# === end AnyForge Control ===';
|
|
51
|
+
// Suffix for the pre-patch backup of a Claude Code settings file.
|
|
52
|
+
const BACKUP_SUFFIX = '.anyforge-bak';
|
|
53
|
+
// The Claude.ai subscription org types that an ANTHROPIC_AUTH_TOKEN
|
|
54
|
+
// patch would shadow. A Console (API-key) login does not carry these,
|
|
55
|
+
// so BYOK users are unaffected.
|
|
56
|
+
const SUBSCRIPTION_ORG_TYPES = /claude_(max|pro|team|enterprise)/i;
|
|
57
|
+
// Env keys we set when enabling Claude Code → AnyForge usage telemetry
|
|
58
|
+
// (OpenTelemetry metrics only — no prompt/response content). Removed on
|
|
59
|
+
// `logout` when the OTLP endpoint points at AnyForge.
|
|
60
|
+
const TELEMETRY_KEYS = [
|
|
61
|
+
'CLAUDE_CODE_ENABLE_TELEMETRY',
|
|
62
|
+
'OTEL_METRICS_EXPORTER',
|
|
63
|
+
'OTEL_EXPORTER_OTLP_PROTOCOL',
|
|
64
|
+
'OTEL_EXPORTER_OTLP_ENDPOINT',
|
|
65
|
+
'OTEL_EXPORTER_OTLP_HEADERS',
|
|
66
|
+
'OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE',
|
|
67
|
+
];
|
|
6
68
|
export async function detectAndPatchConfigs(opts) {
|
|
7
69
|
const summary = [];
|
|
8
70
|
const cc = await patchClaudeCode(opts);
|
|
@@ -64,6 +126,42 @@ async function patchClaudeCode(opts) {
|
|
|
64
126
|
json = {};
|
|
65
127
|
}
|
|
66
128
|
const env = (json.env ?? {});
|
|
129
|
+
// Detect an existing local LLM endpoint (Ollama, LM Studio, etc.) and warn
|
|
130
|
+
// before clobbering it. The AnyForge proxy only routes Anthropic Claude
|
|
131
|
+
// models — local models (Gemma, Llama, etc.) won't work through it.
|
|
132
|
+
const existingUrl = env.ANTHROPIC_BASE_URL;
|
|
133
|
+
const isLocalUrl = existingUrl &&
|
|
134
|
+
!existingUrl.includes(opts.endpoint) &&
|
|
135
|
+
(existingUrl.includes('localhost') || existingUrl.includes('127.0.0.1') || existingUrl.includes('0.0.0.0'));
|
|
136
|
+
if (isLocalUrl) {
|
|
137
|
+
// eslint-disable-next-line no-console
|
|
138
|
+
console.log();
|
|
139
|
+
// eslint-disable-next-line no-console
|
|
140
|
+
console.log(yellow(' ⚠ Local LLM proxy detected: ') + dim(existingUrl));
|
|
141
|
+
// eslint-disable-next-line no-console
|
|
142
|
+
console.log(dim(' AnyForge only routes Anthropic Claude models. Local models (Ollama,'));
|
|
143
|
+
// eslint-disable-next-line no-console
|
|
144
|
+
console.log(dim(' LM Studio, etc.) will not work in Claude Code after this patch.'));
|
|
145
|
+
// eslint-disable-next-line no-console
|
|
146
|
+
console.log(dim(' To restore: remove ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN'));
|
|
147
|
+
// eslint-disable-next-line no-console
|
|
148
|
+
console.log(dim(` from ${path}`));
|
|
149
|
+
// eslint-disable-next-line no-console
|
|
150
|
+
console.log();
|
|
151
|
+
const overwrite = await prompts({
|
|
152
|
+
type: 'confirm',
|
|
153
|
+
name: 'go',
|
|
154
|
+
message: 'Overwrite existing local LLM proxy config?',
|
|
155
|
+
initial: false,
|
|
156
|
+
});
|
|
157
|
+
if (!overwrite.go)
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// Back up the pre-patch file once so `anyforge logout` (or the user)
|
|
161
|
+
// can restore it verbatim. Only the first patch writes a backup.
|
|
162
|
+
if ((await fileExists(path)) && !(await fileExists(path + BACKUP_SUFFIX))) {
|
|
163
|
+
await fs.copyFile(path, path + BACKUP_SUFFIX);
|
|
164
|
+
}
|
|
67
165
|
env.ANTHROPIC_BASE_URL = `${opts.endpoint}/v1/anthropic`;
|
|
68
166
|
env.ANTHROPIC_AUTH_TOKEN = opts.apiKey;
|
|
69
167
|
// Strip stale ANTHROPIC_API_KEY from prior installs — keeping
|
|
@@ -80,6 +178,57 @@ async function patchClaudeCode(opts) {
|
|
|
80
178
|
path,
|
|
81
179
|
};
|
|
82
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Enable Claude Code → AnyForge usage telemetry by writing the standard
|
|
183
|
+
* OpenTelemetry env into ~/.claude/settings.json. This does NOT touch
|
|
184
|
+
* auth (no ANTHROPIC_* keys), so it is safe alongside a Pro/Max
|
|
185
|
+
* subscription login — it just exports `claude_code.token.usage` /
|
|
186
|
+
* `claude_code.cost.usage` metrics (counts + estimated cost, no prompt
|
|
187
|
+
* content) to AnyForge's OTLP collector. Returns null if a non-AnyForge
|
|
188
|
+
* OTel endpoint is already configured (we never clobber a user's own).
|
|
189
|
+
*/
|
|
190
|
+
export async function patchClaudeTelemetry(opts) {
|
|
191
|
+
const dir = join(homedir(), '.claude');
|
|
192
|
+
const path = join(dir, 'settings.json');
|
|
193
|
+
await fs.mkdir(dir, { recursive: true });
|
|
194
|
+
const txt = await fs.readFile(path, 'utf8').catch(() => '{}');
|
|
195
|
+
let json;
|
|
196
|
+
try {
|
|
197
|
+
json = JSON.parse(txt);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
json = {};
|
|
201
|
+
}
|
|
202
|
+
const env = (json.env ?? {});
|
|
203
|
+
const existing = env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
204
|
+
if (existing && !isAnyforgeValue(existing, safeHost(opts.endpoint))) {
|
|
205
|
+
/* eslint-disable no-console */
|
|
206
|
+
console.log(yellow(' ⚠ An OpenTelemetry endpoint is already configured: ') + dim(existing));
|
|
207
|
+
console.log(dim(' Leaving it untouched. To send Claude Code usage to AnyForge, point'));
|
|
208
|
+
console.log(dim(' OTEL_EXPORTER_OTLP_ENDPOINT at ' + opts.endpoint + '/api/otel'));
|
|
209
|
+
/* eslint-enable no-console */
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
// Back up once so `logout` can restore verbatim.
|
|
213
|
+
if ((await fileExists(path)) && !(await fileExists(path + BACKUP_SUFFIX))) {
|
|
214
|
+
await fs.copyFile(path, path + BACKUP_SUFFIX);
|
|
215
|
+
}
|
|
216
|
+
// Metrics only — we deliberately do NOT set OTEL_LOGS_EXPORTER, which
|
|
217
|
+
// is the channel that can carry prompt/response content.
|
|
218
|
+
// - http/json so our collector can parse the body with req.json()
|
|
219
|
+
// and needs no protobuf decoder.
|
|
220
|
+
// - delta temporality so each 60s export carries the tokens spent in
|
|
221
|
+
// that interval (cumulative would force the collector to diff).
|
|
222
|
+
env.CLAUDE_CODE_ENABLE_TELEMETRY = '1';
|
|
223
|
+
env.OTEL_METRICS_EXPORTER = 'otlp';
|
|
224
|
+
env.OTEL_EXPORTER_OTLP_PROTOCOL = 'http/json';
|
|
225
|
+
env.OTEL_EXPORTER_OTLP_ENDPOINT = `${opts.endpoint}/api/otel`;
|
|
226
|
+
env.OTEL_EXPORTER_OTLP_HEADERS = `Authorization=Bearer ${opts.apiKey}`;
|
|
227
|
+
env.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = 'delta';
|
|
228
|
+
json.env = env;
|
|
229
|
+
await fs.writeFile(path, JSON.stringify(json, null, 2) + '\n');
|
|
230
|
+
return { label: 'Claude Code usage telemetry (global)', path };
|
|
231
|
+
}
|
|
83
232
|
async function patchRepoEnv(opts) {
|
|
84
233
|
if (!opts.repoRoot)
|
|
85
234
|
return null;
|
|
@@ -92,8 +241,6 @@ async function patchRepoEnv(opts) {
|
|
|
92
241
|
});
|
|
93
242
|
if (!proceed.go)
|
|
94
243
|
return null;
|
|
95
|
-
const SENTINEL_BEGIN = '# === AnyForge Control — added by `anyforge init` ===';
|
|
96
|
-
const SENTINEL_END = '# === end AnyForge Control ===';
|
|
97
244
|
const block = [
|
|
98
245
|
SENTINEL_BEGIN,
|
|
99
246
|
`ANYFORGE_API_KEY=${opts.apiKey}`,
|
|
@@ -176,8 +323,6 @@ async function ensureShellRcExports(opts) {
|
|
|
176
323
|
// Idempotent: re-running the CLI shouldn't append duplicate
|
|
177
324
|
// exports. We tag our block with a sentinel comment so we can
|
|
178
325
|
// detect + replace cleanly on subsequent runs.
|
|
179
|
-
const SENTINEL_BEGIN = '# === AnyForge Control — added by `anyforge init` ===';
|
|
180
|
-
const SENTINEL_END = '# === end AnyForge Control ===';
|
|
181
326
|
const block = [
|
|
182
327
|
SENTINEL_BEGIN,
|
|
183
328
|
`export ANYFORGE_API_KEY="${opts.apiKey}"`,
|
|
@@ -214,3 +359,166 @@ async function fileExists(p) {
|
|
|
214
359
|
return false;
|
|
215
360
|
}
|
|
216
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Detect a Claude Pro/Max/Team/Enterprise (subscription) login.
|
|
364
|
+
*
|
|
365
|
+
* `~/.claude.json` carries an `oauthAccount` block whenever the user
|
|
366
|
+
* logged in with a Claude.ai account. It lives in the plain-JSON config
|
|
367
|
+
* on every platform, so — unlike the token itself, which is in the macOS
|
|
368
|
+
* Keychain — we can read it without triggering a credential prompt. A
|
|
369
|
+
* Console (API-key) login does not carry these subscription org types,
|
|
370
|
+
* so BYOK users are correctly left to proceed.
|
|
371
|
+
*/
|
|
372
|
+
export async function detectClaudeSubscription() {
|
|
373
|
+
try {
|
|
374
|
+
const txt = await fs.readFile(join(homedir(), '.claude.json'), 'utf8');
|
|
375
|
+
const json = JSON.parse(txt);
|
|
376
|
+
const acct = json.oauthAccount;
|
|
377
|
+
const orgType = acct?.organizationType ?? '';
|
|
378
|
+
if (acct && SUBSCRIPTION_ORG_TYPES.test(orgType)) {
|
|
379
|
+
const plan = /max/i.test(orgType) ? 'Max' :
|
|
380
|
+
/pro/i.test(orgType) ? 'Pro' :
|
|
381
|
+
/enterprise/i.test(orgType) ? 'Enterprise' :
|
|
382
|
+
/team/i.test(orgType) ? 'Team' :
|
|
383
|
+
orgType;
|
|
384
|
+
return { isSubscription: true, plan, email: acct.emailAddress ?? null };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
// No config / unreadable / unparseable → assume no subscription.
|
|
389
|
+
}
|
|
390
|
+
return { isSubscription: false, plan: null, email: null };
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Undo what `init` patched: strip the proxy env keys from Claude Code's
|
|
394
|
+
* global + repo settings, and remove the sentinel block from .env and
|
|
395
|
+
* shell rc files. Only touches keys whose value points at the AnyForge
|
|
396
|
+
* proxy, so a hand-edited base URL is left alone. Restores any
|
|
397
|
+
* `.anyforge-bak` cleanup afterwards.
|
|
398
|
+
*/
|
|
399
|
+
export async function revertClaudeCodeConfigs(opts = {}) {
|
|
400
|
+
const host = opts.endpoint ? safeHost(opts.endpoint) : null;
|
|
401
|
+
const reverted = [];
|
|
402
|
+
// Global Claude Code settings + shell-rc exports.
|
|
403
|
+
const globalSettings = join(homedir(), '.claude', 'settings.json');
|
|
404
|
+
if (await revertSettingsFile(globalSettings, host))
|
|
405
|
+
reverted.push(globalSettings);
|
|
406
|
+
for (const rc of ['.zshrc', '.bashrc']) {
|
|
407
|
+
const p = join(homedir(), rc);
|
|
408
|
+
if (await stripSentinelBlock(p))
|
|
409
|
+
reverted.push(p);
|
|
410
|
+
}
|
|
411
|
+
// Repo-scoped settings + .env, when invoked inside a working tree.
|
|
412
|
+
const repoRoot = opts.repoRoot ?? findGitRootSafe(process.cwd());
|
|
413
|
+
if (repoRoot) {
|
|
414
|
+
const localSettings = join(repoRoot, '.claude', 'settings.local.json');
|
|
415
|
+
if (await revertSettingsFile(localSettings, host))
|
|
416
|
+
reverted.push(localSettings);
|
|
417
|
+
const envPath = join(repoRoot, '.env');
|
|
418
|
+
if (await stripSentinelBlock(envPath))
|
|
419
|
+
reverted.push(envPath);
|
|
420
|
+
}
|
|
421
|
+
return { reverted };
|
|
422
|
+
}
|
|
423
|
+
function isAnyforgeValue(v, host) {
|
|
424
|
+
if (!v)
|
|
425
|
+
return false;
|
|
426
|
+
if (v.includes('anyforge'))
|
|
427
|
+
return true;
|
|
428
|
+
return host ? v.includes(host) : false;
|
|
429
|
+
}
|
|
430
|
+
/** Remove the proxy keys we injected from a Claude Code settings JSON,
|
|
431
|
+
* preferring a verbatim restore from the `.anyforge-bak` backup when it
|
|
432
|
+
* exists. Returns true if the file changed. */
|
|
433
|
+
async function revertSettingsFile(path, host) {
|
|
434
|
+
if (!(await fileExists(path)))
|
|
435
|
+
return false;
|
|
436
|
+
// Prefer a clean restore from the pre-patch backup.
|
|
437
|
+
if (await fileExists(path + BACKUP_SUFFIX)) {
|
|
438
|
+
await fs.copyFile(path + BACKUP_SUFFIX, path);
|
|
439
|
+
await fs.unlink(path + BACKUP_SUFFIX).catch(() => { });
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
// No backup (e.g. patched by an older CLI): surgically drop our keys.
|
|
443
|
+
let json;
|
|
444
|
+
try {
|
|
445
|
+
json = JSON.parse(await fs.readFile(path, 'utf8'));
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
const env = json.env;
|
|
451
|
+
if (!env)
|
|
452
|
+
return false;
|
|
453
|
+
let changed = false;
|
|
454
|
+
const pairs = [
|
|
455
|
+
['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'],
|
|
456
|
+
['OPENAI_BASE_URL', 'OPENAI_API_KEY'],
|
|
457
|
+
];
|
|
458
|
+
for (const [urlKey, tokenKey] of pairs) {
|
|
459
|
+
if (isAnyforgeValue(env[urlKey], host)) {
|
|
460
|
+
delete env[urlKey];
|
|
461
|
+
delete env[tokenKey];
|
|
462
|
+
changed = true;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (env.ANYFORGE_API_KEY) {
|
|
466
|
+
delete env.ANYFORGE_API_KEY;
|
|
467
|
+
changed = true;
|
|
468
|
+
}
|
|
469
|
+
// Telemetry we injected — only when the OTLP endpoint is AnyForge's, so
|
|
470
|
+
// we never strip a user's own OpenTelemetry configuration.
|
|
471
|
+
if (isAnyforgeValue(env.OTEL_EXPORTER_OTLP_ENDPOINT, host)) {
|
|
472
|
+
for (const k of TELEMETRY_KEYS) {
|
|
473
|
+
if (k in env) {
|
|
474
|
+
delete env[k];
|
|
475
|
+
changed = true;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (!changed)
|
|
480
|
+
return false;
|
|
481
|
+
if (Object.keys(env).length === 0)
|
|
482
|
+
delete json.env;
|
|
483
|
+
await fs.writeFile(path, JSON.stringify(json, null, 2) + '\n');
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
/** Remove the `# === AnyForge Control === … === end === ` block from a
|
|
487
|
+
* .env or shell-rc file. Returns true if the file changed. */
|
|
488
|
+
async function stripSentinelBlock(path) {
|
|
489
|
+
let txt;
|
|
490
|
+
try {
|
|
491
|
+
txt = await fs.readFile(path, 'utf8');
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
if (!txt.includes(SENTINEL_BEGIN))
|
|
497
|
+
return false;
|
|
498
|
+
const next = txt
|
|
499
|
+
.replace(new RegExp(`\\n?${escapeRe(SENTINEL_BEGIN)}[\\s\\S]*?${escapeRe(SENTINEL_END)}\\n?`), '\n')
|
|
500
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
501
|
+
await fs.writeFile(path, next);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
function escapeRe(s) {
|
|
505
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
506
|
+
}
|
|
507
|
+
function safeHost(endpoint) {
|
|
508
|
+
try {
|
|
509
|
+
return new URL(endpoint).host;
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function findGitRootSafe(cwd) {
|
|
516
|
+
try {
|
|
517
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
518
|
+
cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
519
|
+
}).trim();
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anyforge/control",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "AnyForge Control CLI — governed LLM proxy for Claude Code, Cursor, Cline, and any agent that talks to OpenAI- or Anthropic-shape APIs.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://anyforge.ai",
|