@anyforge/control 0.2.4 → 0.3.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 +52 -4
- package/dist/cli.js +91 -135
- package/package.json +8 -6
- package/dist/commands/applyPatch.js +0 -92
- package/dist/commands/init.js +0 -296
- package/dist/commands/logout.js +0 -34
- package/dist/commands/status.js +0 -58
- package/dist/commands/whoami.js +0 -23
- package/dist/lib/configPatch.js +0 -524
- package/dist/lib/credentials.js +0 -40
package/dist/lib/configPatch.js
DELETED
|
@@ -1,524 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import { execFileSync } from 'child_process';
|
|
3
|
-
import { homedir } from 'os';
|
|
4
|
-
import { join } from 'path';
|
|
5
|
-
import prompts from 'prompts';
|
|
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
|
-
];
|
|
68
|
-
export async function detectAndPatchConfigs(opts) {
|
|
69
|
-
const summary = [];
|
|
70
|
-
const cc = await patchClaudeCode(opts);
|
|
71
|
-
if (cc)
|
|
72
|
-
summary.push(cc);
|
|
73
|
-
if (opts.scope === 'repo') {
|
|
74
|
-
const env = await patchRepoEnv(opts);
|
|
75
|
-
if (env)
|
|
76
|
-
summary.push(env);
|
|
77
|
-
return summary;
|
|
78
|
-
}
|
|
79
|
-
// Global scope only — Cursor / Cline have no per-repo config we
|
|
80
|
-
// can usefully target, so we leave them out of the repo flow.
|
|
81
|
-
for (const detector of OPTIONAL_GLOBAL_DETECTORS) {
|
|
82
|
-
const found = await detector.detect();
|
|
83
|
-
if (!found)
|
|
84
|
-
continue;
|
|
85
|
-
const proceed = await prompts({
|
|
86
|
-
type: 'confirm',
|
|
87
|
-
name: 'go',
|
|
88
|
-
message: `Patch ${detector.label} via shell rc (${dim(found.path)}) ?`,
|
|
89
|
-
initial: true,
|
|
90
|
-
});
|
|
91
|
-
if (!proceed.go)
|
|
92
|
-
continue;
|
|
93
|
-
try {
|
|
94
|
-
await detector.patch(found, opts);
|
|
95
|
-
summary.push({ label: detector.label, path: found.path });
|
|
96
|
-
// eslint-disable-next-line no-console
|
|
97
|
-
console.log(green(' ✔') + ' ' + detector.label);
|
|
98
|
-
}
|
|
99
|
-
catch (err) {
|
|
100
|
-
// eslint-disable-next-line no-console
|
|
101
|
-
console.warn(' ⚠ Skipped ' + detector.label + ': ' + err.message);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return summary;
|
|
105
|
-
}
|
|
106
|
-
async function patchClaudeCode(opts) {
|
|
107
|
-
const target = opts.scope === 'repo' && opts.repoRoot
|
|
108
|
-
? { dir: join(opts.repoRoot, '.claude'), file: 'settings.local.json' }
|
|
109
|
-
: { dir: join(homedir(), '.claude'), file: 'settings.json' };
|
|
110
|
-
const path = join(target.dir, target.file);
|
|
111
|
-
const proceed = await prompts({
|
|
112
|
-
type: 'confirm',
|
|
113
|
-
name: 'go',
|
|
114
|
-
message: `Patch Claude Code config at ${dim(path)} ?`,
|
|
115
|
-
initial: true,
|
|
116
|
-
});
|
|
117
|
-
if (!proceed.go)
|
|
118
|
-
return null;
|
|
119
|
-
await fs.mkdir(target.dir, { recursive: true });
|
|
120
|
-
const txt = await fs.readFile(path, 'utf8').catch(() => '{}');
|
|
121
|
-
let json;
|
|
122
|
-
try {
|
|
123
|
-
json = JSON.parse(txt);
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
json = {};
|
|
127
|
-
}
|
|
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
|
-
}
|
|
165
|
-
env.ANTHROPIC_BASE_URL = `${opts.endpoint}/v1/anthropic`;
|
|
166
|
-
env.ANTHROPIC_AUTH_TOKEN = opts.apiKey;
|
|
167
|
-
// Strip stale ANTHROPIC_API_KEY from prior installs — keeping
|
|
168
|
-
// both set re-triggers the auth-conflict warning even though
|
|
169
|
-
// ANTHROPIC_AUTH_TOKEN is what we actually want Claude Code
|
|
170
|
-
// to use.
|
|
171
|
-
delete env.ANTHROPIC_API_KEY;
|
|
172
|
-
json.env = env;
|
|
173
|
-
await fs.writeFile(path, JSON.stringify(json, null, 2) + '\n');
|
|
174
|
-
// eslint-disable-next-line no-console
|
|
175
|
-
console.log(green(' ✔') + ' Claude Code');
|
|
176
|
-
return {
|
|
177
|
-
label: opts.scope === 'repo' ? 'Claude Code (repo)' : 'Claude Code (global)',
|
|
178
|
-
path,
|
|
179
|
-
};
|
|
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
|
-
}
|
|
232
|
-
async function patchRepoEnv(opts) {
|
|
233
|
-
if (!opts.repoRoot)
|
|
234
|
-
return null;
|
|
235
|
-
const envPath = join(opts.repoRoot, '.env');
|
|
236
|
-
const proceed = await prompts({
|
|
237
|
-
type: 'confirm',
|
|
238
|
-
name: 'go',
|
|
239
|
-
message: `Write AnyForge env block to ${dim(envPath)} ?`,
|
|
240
|
-
initial: true,
|
|
241
|
-
});
|
|
242
|
-
if (!proceed.go)
|
|
243
|
-
return null;
|
|
244
|
-
const block = [
|
|
245
|
-
SENTINEL_BEGIN,
|
|
246
|
-
`ANYFORGE_API_KEY=${opts.apiKey}`,
|
|
247
|
-
`ANTHROPIC_BASE_URL=${opts.endpoint}/v1/anthropic`,
|
|
248
|
-
`ANTHROPIC_AUTH_TOKEN=${opts.apiKey}`,
|
|
249
|
-
`OPENAI_BASE_URL=${opts.endpoint}/v1/openai`,
|
|
250
|
-
`OPENAI_API_KEY=${opts.apiKey}`,
|
|
251
|
-
SENTINEL_END,
|
|
252
|
-
'',
|
|
253
|
-
].join('\n');
|
|
254
|
-
let cur = '';
|
|
255
|
-
try {
|
|
256
|
-
cur = await fs.readFile(envPath, 'utf8');
|
|
257
|
-
}
|
|
258
|
-
catch { /* missing is fine */ }
|
|
259
|
-
let next;
|
|
260
|
-
if (cur.includes(SENTINEL_BEGIN)) {
|
|
261
|
-
// Idempotent: re-running replaces the existing block in place.
|
|
262
|
-
next = cur.replace(new RegExp(`${SENTINEL_BEGIN}[\\s\\S]*?${SENTINEL_END}\\n?`), block);
|
|
263
|
-
}
|
|
264
|
-
else if (cur.length === 0) {
|
|
265
|
-
next = block;
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
next = cur.endsWith('\n') ? `${cur}${block}` : `${cur}\n${block}`;
|
|
269
|
-
}
|
|
270
|
-
if (next !== cur)
|
|
271
|
-
await fs.writeFile(envPath, next);
|
|
272
|
-
// The .env contains a live API key — flag it if it isn't gitignored.
|
|
273
|
-
await warnIfNotGitignored(opts.repoRoot);
|
|
274
|
-
// eslint-disable-next-line no-console
|
|
275
|
-
console.log(green(' ✔') + ' .env');
|
|
276
|
-
return { label: '.env (repo)', path: envPath };
|
|
277
|
-
}
|
|
278
|
-
async function warnIfNotGitignored(repoRoot) {
|
|
279
|
-
const gi = join(repoRoot, '.gitignore');
|
|
280
|
-
try {
|
|
281
|
-
const txt = await fs.readFile(gi, 'utf8');
|
|
282
|
-
const lines = txt.split('\n').map(l => l.trim());
|
|
283
|
-
const ignored = lines.some(l => l === '.env' || l === '/.env' || l === '*.env');
|
|
284
|
-
if (!ignored) {
|
|
285
|
-
// eslint-disable-next-line no-console
|
|
286
|
-
console.log(yellow(' ⚠ .env is not in .gitignore — add it before committing.'));
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
// eslint-disable-next-line no-console
|
|
291
|
-
console.log(yellow(' ⚠ No .gitignore found — make sure .env is excluded before committing.'));
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
// Optional best-effort detectors for tools that don't expose a config
|
|
295
|
-
// file we can patch directly. They just append exports to the user's
|
|
296
|
-
// shell rc; the API key is inlined so a brand-new shell picks it up
|
|
297
|
-
// without depending on jq or the credentials file being readable.
|
|
298
|
-
const OPTIONAL_GLOBAL_DETECTORS = [
|
|
299
|
-
{
|
|
300
|
-
label: 'Cursor (shell hint)',
|
|
301
|
-
async detect() {
|
|
302
|
-
const path = join(homedir(), '.cursor');
|
|
303
|
-
const exists = await fileExists(path);
|
|
304
|
-
return exists ? { path: '.cursor (shell hint)' } : null;
|
|
305
|
-
},
|
|
306
|
-
async patch(_found, opts) {
|
|
307
|
-
await ensureShellRcExports(opts);
|
|
308
|
-
},
|
|
309
|
-
},
|
|
310
|
-
{
|
|
311
|
-
label: 'Cline / Continue (shell hint)',
|
|
312
|
-
async detect() {
|
|
313
|
-
const a = await fileExists(join(homedir(), '.cline'));
|
|
314
|
-
const b = await fileExists(join(homedir(), '.continue'));
|
|
315
|
-
return a || b ? { path: '~/.cline or ~/.continue (shell hint)' } : null;
|
|
316
|
-
},
|
|
317
|
-
async patch(_found, opts) {
|
|
318
|
-
await ensureShellRcExports(opts);
|
|
319
|
-
},
|
|
320
|
-
},
|
|
321
|
-
];
|
|
322
|
-
async function ensureShellRcExports(opts) {
|
|
323
|
-
// Idempotent: re-running the CLI shouldn't append duplicate
|
|
324
|
-
// exports. We tag our block with a sentinel comment so we can
|
|
325
|
-
// detect + replace cleanly on subsequent runs.
|
|
326
|
-
const block = [
|
|
327
|
-
SENTINEL_BEGIN,
|
|
328
|
-
`export ANYFORGE_API_KEY="${opts.apiKey}"`,
|
|
329
|
-
`export ANTHROPIC_BASE_URL="${opts.endpoint}/v1/anthropic"`,
|
|
330
|
-
`export ANTHROPIC_AUTH_TOKEN="${opts.apiKey}"`,
|
|
331
|
-
`export OPENAI_BASE_URL="${opts.endpoint}/v1/openai"`,
|
|
332
|
-
`export OPENAI_API_KEY="${opts.apiKey}"`,
|
|
333
|
-
`unset ANTHROPIC_API_KEY 2>/dev/null || true`,
|
|
334
|
-
SENTINEL_END,
|
|
335
|
-
'',
|
|
336
|
-
].join('\n');
|
|
337
|
-
for (const rc of ['.zshrc', '.bashrc']) {
|
|
338
|
-
const path = join(homedir(), rc);
|
|
339
|
-
if (!(await fileExists(path)))
|
|
340
|
-
continue;
|
|
341
|
-
const cur = await fs.readFile(path, 'utf8');
|
|
342
|
-
let next;
|
|
343
|
-
if (cur.includes(SENTINEL_BEGIN)) {
|
|
344
|
-
next = cur.replace(new RegExp(`${SENTINEL_BEGIN}[\\s\\S]*?${SENTINEL_END}\\n?`), block);
|
|
345
|
-
}
|
|
346
|
-
else {
|
|
347
|
-
next = cur.endsWith('\n') ? `${cur}\n${block}` : `${cur}\n\n${block}`;
|
|
348
|
-
}
|
|
349
|
-
if (next !== cur)
|
|
350
|
-
await fs.writeFile(path, next);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
async function fileExists(p) {
|
|
354
|
-
try {
|
|
355
|
-
await fs.access(p);
|
|
356
|
-
return true;
|
|
357
|
-
}
|
|
358
|
-
catch {
|
|
359
|
-
return false;
|
|
360
|
-
}
|
|
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/dist/lib/credentials.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import { homedir } from 'os';
|
|
3
|
-
import { join, dirname } from 'path';
|
|
4
|
-
const DIR = join(homedir(), '.anyforge');
|
|
5
|
-
const FILE = join(DIR, 'credentials');
|
|
6
|
-
export async function saveCredentials(c) {
|
|
7
|
-
await fs.mkdir(DIR, { recursive: true, mode: 0o700 });
|
|
8
|
-
await fs.writeFile(FILE, JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
9
|
-
}
|
|
10
|
-
export async function loadCredentials() {
|
|
11
|
-
try {
|
|
12
|
-
const text = await fs.readFile(FILE, 'utf8');
|
|
13
|
-
return JSON.parse(text);
|
|
14
|
-
}
|
|
15
|
-
catch (err) {
|
|
16
|
-
if (err.code === 'ENOENT')
|
|
17
|
-
return null;
|
|
18
|
-
throw err;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
export async function clearCredentials() {
|
|
22
|
-
try {
|
|
23
|
-
await fs.unlink(FILE);
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
catch (err) {
|
|
27
|
-
if (err.code === 'ENOENT')
|
|
28
|
-
return false;
|
|
29
|
-
throw err;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
export function credentialsPath() {
|
|
33
|
-
return FILE;
|
|
34
|
-
}
|
|
35
|
-
export function credentialsDir() {
|
|
36
|
-
return DIR;
|
|
37
|
-
}
|
|
38
|
-
// Exported so test code can stub this at the module boundary if
|
|
39
|
-
// needed without monkey-patching node's `fs`.
|
|
40
|
-
export const __internal = { DIR, FILE, dirname };
|