@anyforge/control 0.2.3 → 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 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? Delete the `# === AnyForge Control ===` block in `<repo>/.env` and the `env` keys in `<repo>/.claude/settings.local.json`. `anyforge logout` clears the saved credential but doesn't touch patched configs.
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", out-ranks the OAuth login cleanly, 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.
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();
@@ -1,12 +1,9 @@
1
- import { promises as fs } from 'fs';
2
1
  import { execFileSync } from 'child_process';
3
- import { homedir } from 'os';
4
- import { join } from 'path';
5
2
  import open from 'open';
6
3
  import prompts from 'prompts';
7
4
  import { green, dim, bold, yellow, cyan } from 'kleur/colors';
8
5
  import { saveCredentials } from '../lib/credentials.js';
9
- import { detectAndPatchConfigs } from '../lib/configPatch.js';
6
+ import { detectAndPatchConfigs, detectClaudeSubscription, patchClaudeTelemetry, } from '../lib/configPatch.js';
10
7
  const POLL_INTERVAL_MS = 2_000;
11
8
  const POLL_TIMEOUT_MS = 15 * 60 * 1000;
12
9
  export async function runInit(opts) {
@@ -56,6 +53,19 @@ export async function runInit(opts) {
56
53
  await saveCredentials(credentials);
57
54
  // eslint-disable-next-line no-console
58
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
+ }
59
69
  // 5. Pick a scope. The repo option only shows when we're sitting
60
70
  // in a git working tree root — that matches the documented
61
71
  // contract and avoids accidentally writing settings into a
@@ -84,24 +94,17 @@ export async function runInit(opts) {
84
94
  }
85
95
  // eslint-disable-next-line no-console
86
96
  console.log();
87
- if (await hasClaudeCodeLogin()) {
88
- // Claude Code shows an "Auth conflict" warning when both a /login
89
- // credential and an env-token are set — even though the token wins
90
- // by precedence (#2 vs #6), the warning is cosmetic-but-loud. The
91
- // only way to silence it is to stay logged out: /logout once, then
92
- // skip the login prompt on the next launch.
93
- // eslint-disable-next-line no-console
94
- console.log(yellow(' ⚠ Existing Claude Code login detected (~/.claude/.credentials.json).'));
95
- // eslint-disable-next-line no-console
96
- 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.
97
100
  // eslint-disable-next-line no-console
98
- console.log(dim(' /login key are set. To silence the warning:'));
101
+ console.log(yellow(` ⚠ --force: your Claude ${sub.plan ?? ''} subscription login is now shadowed`));
99
102
  // eslint-disable-next-line no-console
100
- console.log(dim(' 1) run `claude /logout`,'));
103
+ console.log(yellow(' by the AnyForge token — Claude Code will use the BYOK proxy, which'));
101
104
  // eslint-disable-next-line no-console
102
- console.log(dim(' 2) on the next launch, skip the login prompt (Esc / Ctrl+C).'));
105
+ console.log(yellow(' needs an Anthropic API key on your company. Run ') + bold('anyforge logout'));
103
106
  // eslint-disable-next-line no-console
104
- console.log(dim(' The AnyForge token in settings.local.json is enough on its own.'));
107
+ console.log(yellow(' to revert and restore your subscription.'));
105
108
  }
106
109
  if (scope === 'repo') {
107
110
  // eslint-disable-next-line no-console
@@ -143,19 +146,78 @@ async function pollForToken(args) {
143
146
  }
144
147
  throw new Error('Authorisation timed out (15 min). Re-run `anyforge init`.');
145
148
  }
146
- async function hasClaudeCodeLogin() {
147
- // Claude Code's docs document the credentials file at
148
- // ~/.claude/.credentials.json (Linux/Windows) or the macOS Keychain.
149
- // We can only see the file on Linux/Windows. On macOS the keychain
150
- // is invisible to us, so this check is best-effort — false negatives
151
- // are fine since the warning is just a hint.
152
- try {
153
- await fs.access(join(homedir(), '.claude', '.credentials.json'));
154
- return true;
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
+ }
155
188
  }
156
- catch {
157
- return false;
189
+ else {
190
+ console.log(dim(' Add it later with:'));
191
+ console.log(' ' + mcpCmd);
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.'));
158
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 */
159
221
  }
160
222
  function findGitRoot(cwd) {
161
223
  // Works from any subdirectory — git walks up the tree to find the root.
@@ -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
- * Does NOT revoke the key on the server side — that's a separate
6
- * action on /settings → API keys (server-controlled). This is the
7
- * "remove from this machine" affordance.
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
  }
@@ -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",
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",