@anyforge/control 0.1.0-beta.0 → 0.2.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 +17 -1
- package/dist/commands/init.js +57 -4
- package/dist/lib/configPatch.js +120 -49
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -12,10 +12,26 @@ The CLI:
|
|
|
12
12
|
|
|
13
13
|
1. Issues a one-time auth code, opens your browser to authorise it.
|
|
14
14
|
2. Stores the resulting API key at `~/.anyforge/credentials` (chmod 600).
|
|
15
|
-
3.
|
|
15
|
+
3. Asks whether to install **globally** (`~/.claude/settings.json`) or **per-repo** (the per-repo option only appears when you run the command at the root of a git working tree).
|
|
16
|
+
4. Patches the chosen target so Claude Code routes through `https://crew.anyforge.ai/v1/anthropic` and `…/v1/openai`. The actual API key is **inlined** into the patched config — no `${ANYFORGE_API_KEY}` indirection, no shell restart required.
|
|
16
17
|
|
|
17
18
|
After that, your next LLM call shows up under `Control` on `/usage` within seconds.
|
|
18
19
|
|
|
20
|
+
### Install scope
|
|
21
|
+
|
|
22
|
+
| Choice | Files written | Effect |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `global` | `~/.claude/settings.json` (+ optional shell-rc hints for Cursor / Cline if installed) | Every Claude Code session on this machine routes through AnyForge |
|
|
25
|
+
| `repo` | `<repo>/.claude/settings.local.json` and `<repo>/.env` | Only Claude Code sessions inside this repo are routed; no global side effects |
|
|
26
|
+
|
|
27
|
+
Pick whichever suits the moment — the choices aren't mutually exclusive over time:
|
|
28
|
+
|
|
29
|
+
- Want to add a second repo? Re-run `npx @anyforge/control init` from that repo's root and pick `repo` again.
|
|
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.
|
|
32
|
+
|
|
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
|
+
|
|
19
35
|
## Commands
|
|
20
36
|
|
|
21
37
|
| | |
|
package/dist/commands/init.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
1
3
|
import open from 'open';
|
|
2
4
|
import prompts from 'prompts';
|
|
3
5
|
import { green, dim, bold, yellow, cyan } from 'kleur/colors';
|
|
@@ -42,21 +44,40 @@ export async function runInit(opts) {
|
|
|
42
44
|
await saveCredentials(credentials);
|
|
43
45
|
// eslint-disable-next-line no-console
|
|
44
46
|
console.log(green('✔') + ' API key saved to ' + dim('~/.anyforge/credentials'));
|
|
45
|
-
// 5.
|
|
46
|
-
|
|
47
|
+
// 5. Pick a scope. The repo option only shows when we're sitting
|
|
48
|
+
// in a git working tree root — that matches the documented
|
|
49
|
+
// contract and avoids accidentally writing settings into a
|
|
50
|
+
// subdirectory the operator didn't mean to target.
|
|
51
|
+
const cwd = process.cwd();
|
|
52
|
+
const repoRoot = (await isGitRepoRoot(cwd)) ? cwd : null;
|
|
53
|
+
const scope = await pickScope(repoRoot);
|
|
54
|
+
// 6. Patch the chosen target. apiKey is inlined into the env map
|
|
55
|
+
// so Claude Code can read it without a shell restart.
|
|
56
|
+
const patches = await detectAndPatchConfigs({
|
|
57
|
+
endpoint,
|
|
58
|
+
apiKey: credentials.apiKey,
|
|
59
|
+
scope,
|
|
60
|
+
repoRoot,
|
|
61
|
+
});
|
|
47
62
|
if (patches.length === 0) {
|
|
48
63
|
// eslint-disable-next-line no-console
|
|
49
|
-
console.log(yellow('No
|
|
64
|
+
console.log(yellow('No agent configs were patched.'));
|
|
50
65
|
// eslint-disable-next-line no-console
|
|
51
66
|
console.log(dim(' Set ANTHROPIC_BASE_URL=' + endpoint + '/v1/anthropic and'));
|
|
52
67
|
// eslint-disable-next-line no-console
|
|
53
|
-
console.log(dim('
|
|
68
|
+
console.log(dim(' ANTHROPIC_API_KEY=<your AnyForge key> manually if needed.'));
|
|
54
69
|
}
|
|
55
70
|
else {
|
|
56
71
|
summarisePatches(patches);
|
|
57
72
|
}
|
|
58
73
|
// eslint-disable-next-line no-console
|
|
59
74
|
console.log();
|
|
75
|
+
if (scope === 'repo') {
|
|
76
|
+
// eslint-disable-next-line no-console
|
|
77
|
+
console.log(dim(' Re-run this command in another repo to patch it too,'));
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.log(dim(' or run from any directory and choose "global" to install system-wide.'));
|
|
80
|
+
}
|
|
60
81
|
// eslint-disable-next-line no-console
|
|
61
82
|
console.log(bold('Done.') + ' Visit ' + cyan(`${endpoint}/usage`) + ' to see your traffic.');
|
|
62
83
|
}
|
|
@@ -91,6 +112,38 @@ async function pollForToken(args) {
|
|
|
91
112
|
}
|
|
92
113
|
throw new Error('Authorisation timed out (15 min). Re-run `anyforge init`.');
|
|
93
114
|
}
|
|
115
|
+
async function isGitRepoRoot(cwd) {
|
|
116
|
+
// git worktrees use a `.git` file pointing at the real gitdir, so
|
|
117
|
+
// accept either a directory or a regular file.
|
|
118
|
+
try {
|
|
119
|
+
const stat = await fs.stat(join(cwd, '.git'));
|
|
120
|
+
return stat.isDirectory() || stat.isFile();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function pickScope(repoRoot) {
|
|
127
|
+
if (!repoRoot) {
|
|
128
|
+
// eslint-disable-next-line no-console
|
|
129
|
+
console.log(dim(' Not a git repo root — installing globally.'));
|
|
130
|
+
// eslint-disable-next-line no-console
|
|
131
|
+
console.log(dim(' Run this from a repo root if you want a per-repo install instead.'));
|
|
132
|
+
return 'global';
|
|
133
|
+
}
|
|
134
|
+
const choice = await prompts({
|
|
135
|
+
type: 'select',
|
|
136
|
+
name: 'scope',
|
|
137
|
+
message: 'Where should AnyForge patch Claude Code?',
|
|
138
|
+
choices: [
|
|
139
|
+
{ title: `This repo only (${repoRoot}/.claude/settings.local.json + .env)`, value: 'repo' },
|
|
140
|
+
{ title: 'Globally (~/.claude/settings.json)', value: 'global' },
|
|
141
|
+
],
|
|
142
|
+
initial: 0,
|
|
143
|
+
});
|
|
144
|
+
// prompts returns {} on cancel — default to repo since they were given the choice.
|
|
145
|
+
return choice.scope ?? 'repo';
|
|
146
|
+
}
|
|
94
147
|
function summarisePatches(patches) {
|
|
95
148
|
// eslint-disable-next-line no-console
|
|
96
149
|
console.log(green('✔') + ' Patched ' + patches.length + ' agent config' + (patches.length === 1 ? '' : 's') + ':');
|
package/dist/lib/configPatch.js
CHANGED
|
@@ -2,20 +2,28 @@ import { promises as fs } from 'fs';
|
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import prompts from 'prompts';
|
|
5
|
-
import { dim, green } from 'kleur/colors';
|
|
5
|
+
import { dim, green, yellow } from 'kleur/colors';
|
|
6
6
|
export async function detectAndPatchConfigs(opts) {
|
|
7
7
|
const summary = [];
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
const cc = await patchClaudeCode(opts);
|
|
9
|
+
if (cc)
|
|
10
|
+
summary.push(cc);
|
|
11
|
+
if (opts.scope === 'repo') {
|
|
12
|
+
const env = await patchRepoEnv(opts);
|
|
13
|
+
if (env)
|
|
14
|
+
summary.push(env);
|
|
15
|
+
return summary;
|
|
16
|
+
}
|
|
17
|
+
// Global scope only — Cursor / Cline have no per-repo config we
|
|
18
|
+
// can usefully target, so we leave them out of the repo flow.
|
|
19
|
+
for (const detector of OPTIONAL_GLOBAL_DETECTORS) {
|
|
12
20
|
const found = await detector.detect();
|
|
13
21
|
if (!found)
|
|
14
22
|
continue;
|
|
15
23
|
const proceed = await prompts({
|
|
16
24
|
type: 'confirm',
|
|
17
25
|
name: 'go',
|
|
18
|
-
message: `Patch ${detector.label}
|
|
26
|
+
message: `Patch ${detector.label} via shell rc (${dim(found.path)}) ?`,
|
|
19
27
|
initial: true,
|
|
20
28
|
});
|
|
21
29
|
if (!proceed.go)
|
|
@@ -33,44 +41,109 @@ export async function detectAndPatchConfigs(opts) {
|
|
|
33
41
|
}
|
|
34
42
|
return summary;
|
|
35
43
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
44
|
+
async function patchClaudeCode(opts) {
|
|
45
|
+
const target = opts.scope === 'repo' && opts.repoRoot
|
|
46
|
+
? { dir: join(opts.repoRoot, '.claude'), file: 'settings.local.json' }
|
|
47
|
+
: { dir: join(homedir(), '.claude'), file: 'settings.json' };
|
|
48
|
+
const path = join(target.dir, target.file);
|
|
49
|
+
const proceed = await prompts({
|
|
50
|
+
type: 'confirm',
|
|
51
|
+
name: 'go',
|
|
52
|
+
message: `Patch Claude Code config at ${dim(path)} ?`,
|
|
53
|
+
initial: true,
|
|
54
|
+
});
|
|
55
|
+
if (!proceed.go)
|
|
56
|
+
return null;
|
|
57
|
+
await fs.mkdir(target.dir, { recursive: true });
|
|
58
|
+
const txt = await fs.readFile(path, 'utf8').catch(() => '{}');
|
|
59
|
+
let json;
|
|
60
|
+
try {
|
|
61
|
+
json = JSON.parse(txt);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
json = {};
|
|
65
|
+
}
|
|
66
|
+
const env = (json.env ?? {});
|
|
67
|
+
env.ANTHROPIC_BASE_URL = `${opts.endpoint}/v1/anthropic`;
|
|
68
|
+
env.ANTHROPIC_API_KEY = opts.apiKey;
|
|
69
|
+
json.env = env;
|
|
70
|
+
await fs.writeFile(path, JSON.stringify(json, null, 2) + '\n');
|
|
71
|
+
// eslint-disable-next-line no-console
|
|
72
|
+
console.log(green(' ✔') + ' Claude Code');
|
|
73
|
+
return {
|
|
74
|
+
label: opts.scope === 'repo' ? 'Claude Code (repo)' : 'Claude Code (global)',
|
|
75
|
+
path,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async function patchRepoEnv(opts) {
|
|
79
|
+
if (!opts.repoRoot)
|
|
80
|
+
return null;
|
|
81
|
+
const envPath = join(opts.repoRoot, '.env');
|
|
82
|
+
const proceed = await prompts({
|
|
83
|
+
type: 'confirm',
|
|
84
|
+
name: 'go',
|
|
85
|
+
message: `Write AnyForge env block to ${dim(envPath)} ?`,
|
|
86
|
+
initial: true,
|
|
87
|
+
});
|
|
88
|
+
if (!proceed.go)
|
|
89
|
+
return null;
|
|
90
|
+
const SENTINEL_BEGIN = '# === AnyForge Control — added by `anyforge init` ===';
|
|
91
|
+
const SENTINEL_END = '# === end AnyForge Control ===';
|
|
92
|
+
const block = [
|
|
93
|
+
SENTINEL_BEGIN,
|
|
94
|
+
`ANYFORGE_API_KEY=${opts.apiKey}`,
|
|
95
|
+
`ANTHROPIC_BASE_URL=${opts.endpoint}/v1/anthropic`,
|
|
96
|
+
`ANTHROPIC_API_KEY=${opts.apiKey}`,
|
|
97
|
+
`OPENAI_BASE_URL=${opts.endpoint}/v1/openai`,
|
|
98
|
+
`OPENAI_API_KEY=${opts.apiKey}`,
|
|
99
|
+
SENTINEL_END,
|
|
100
|
+
'',
|
|
101
|
+
].join('\n');
|
|
102
|
+
let cur = '';
|
|
103
|
+
try {
|
|
104
|
+
cur = await fs.readFile(envPath, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
catch { /* missing is fine */ }
|
|
107
|
+
let next;
|
|
108
|
+
if (cur.includes(SENTINEL_BEGIN)) {
|
|
109
|
+
// Idempotent: re-running replaces the existing block in place.
|
|
110
|
+
next = cur.replace(new RegExp(`${SENTINEL_BEGIN}[\\s\\S]*?${SENTINEL_END}\\n?`), block);
|
|
111
|
+
}
|
|
112
|
+
else if (cur.length === 0) {
|
|
113
|
+
next = block;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
next = cur.endsWith('\n') ? `${cur}${block}` : `${cur}\n${block}`;
|
|
117
|
+
}
|
|
118
|
+
if (next !== cur)
|
|
119
|
+
await fs.writeFile(envPath, next);
|
|
120
|
+
// The .env contains a live API key — flag it if it isn't gitignored.
|
|
121
|
+
await warnIfNotGitignored(opts.repoRoot);
|
|
122
|
+
// eslint-disable-next-line no-console
|
|
123
|
+
console.log(green(' ✔') + ' .env');
|
|
124
|
+
return { label: '.env (repo)', path: envPath };
|
|
125
|
+
}
|
|
126
|
+
async function warnIfNotGitignored(repoRoot) {
|
|
127
|
+
const gi = join(repoRoot, '.gitignore');
|
|
128
|
+
try {
|
|
129
|
+
const txt = await fs.readFile(gi, 'utf8');
|
|
130
|
+
const lines = txt.split('\n').map(l => l.trim());
|
|
131
|
+
const ignored = lines.some(l => l === '.env' || l === '/.env' || l === '*.env');
|
|
132
|
+
if (!ignored) {
|
|
133
|
+
// eslint-disable-next-line no-console
|
|
134
|
+
console.log(yellow(' ⚠ .env is not in .gitignore — add it before committing.'));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// eslint-disable-next-line no-console
|
|
139
|
+
console.log(yellow(' ⚠ No .gitignore found — make sure .env is excluded before committing.'));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Optional best-effort detectors for tools that don't expose a config
|
|
143
|
+
// file we can patch directly. They just append exports to the user's
|
|
144
|
+
// shell rc; the API key is inlined so a brand-new shell picks it up
|
|
145
|
+
// without depending on jq or the credentials file being readable.
|
|
146
|
+
const OPTIONAL_GLOBAL_DETECTORS = [
|
|
74
147
|
{
|
|
75
148
|
label: 'Cursor (shell hint)',
|
|
76
149
|
async detect() {
|
|
@@ -82,8 +155,6 @@ const DETECTORS = [
|
|
|
82
155
|
await ensureShellRcExports(opts);
|
|
83
156
|
},
|
|
84
157
|
},
|
|
85
|
-
// Cline / Continue reads OPENAI_BASE_URL by default. Same shell
|
|
86
|
-
// rc patch as the Cursor case.
|
|
87
158
|
{
|
|
88
159
|
label: 'Cline / Continue (shell hint)',
|
|
89
160
|
async detect() {
|
|
@@ -104,10 +175,11 @@ async function ensureShellRcExports(opts) {
|
|
|
104
175
|
const SENTINEL_END = '# === end AnyForge Control ===';
|
|
105
176
|
const block = [
|
|
106
177
|
SENTINEL_BEGIN,
|
|
107
|
-
`export ANYFORGE_API_KEY="$
|
|
178
|
+
`export ANYFORGE_API_KEY="${opts.apiKey}"`,
|
|
108
179
|
`export ANTHROPIC_BASE_URL="${opts.endpoint}/v1/anthropic"`,
|
|
180
|
+
`export ANTHROPIC_API_KEY="${opts.apiKey}"`,
|
|
109
181
|
`export OPENAI_BASE_URL="${opts.endpoint}/v1/openai"`,
|
|
110
|
-
`export OPENAI_API_KEY="$
|
|
182
|
+
`export OPENAI_API_KEY="${opts.apiKey}"`,
|
|
111
183
|
SENTINEL_END,
|
|
112
184
|
'',
|
|
113
185
|
].join('\n');
|
|
@@ -118,7 +190,6 @@ async function ensureShellRcExports(opts) {
|
|
|
118
190
|
const cur = await fs.readFile(path, 'utf8');
|
|
119
191
|
let next;
|
|
120
192
|
if (cur.includes(SENTINEL_BEGIN)) {
|
|
121
|
-
// Replace the existing block in place so re-runs stay clean.
|
|
122
193
|
next = cur.replace(new RegExp(`${SENTINEL_BEGIN}[\\s\\S]*?${SENTINEL_END}\\n?`), block);
|
|
123
194
|
}
|
|
124
195
|
else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anyforge/control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"type": "module",
|
|
16
16
|
"main": "dist/cli.js",
|
|
17
17
|
"bin": {
|
|
18
|
-
"anyforge": "dist/cli.js"
|
|
18
|
+
"anyforge": "dist/cli.js",
|
|
19
|
+
"control": "dist/cli.js"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"dist",
|