@anyforge/control 0.1.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 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. Detects local agent configs (Claude Code, Cursor, Cline, Continue) and offers to patch them so existing tools route through `https://crew.anyforge.ai/v1/anthropic` and `…/v1/openai`.
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
  | | |
@@ -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. Patch local agent configs (interactive — operator confirms each).
46
- const patches = await detectAndPatchConfigs({ endpoint });
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 local agent configs detected.'));
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(' OPENAI_BASE_URL=' + endpoint + '/v1/openai in your shell when ready.'));
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') + ':');
@@ -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
- // Each detector is independent so a missing config never blocks
9
- // the others. Prompts batched at the end isn't worth it — the
10
- // operator wants to see what we found before approving.
11
- for (const detector of DETECTORS) {
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} config at ${dim(found.path)} ?`,
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
- const DETECTORS = [
37
- // Claude Code keeps user-level settings at ~/.claude/settings.json
38
- // and reads ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY from the
39
- // process env or its `env` block. We patch the `env` block in the
40
- // settings file so the change persists across shell restarts.
41
- {
42
- label: 'Claude Code',
43
- async detect() {
44
- const path = join(homedir(), '.claude', 'settings.json');
45
- const exists = await fileExists(path);
46
- return exists ? { path } : null;
47
- },
48
- async patch(found, opts) {
49
- const txt = await fs.readFile(found.path, 'utf8').catch(() => '{}');
50
- let json;
51
- try {
52
- json = JSON.parse(txt);
53
- }
54
- catch {
55
- json = {};
56
- }
57
- const env = (json.env ?? {});
58
- env.ANTHROPIC_BASE_URL = `${opts.endpoint}/v1/anthropic`;
59
- // ANTHROPIC_API_KEY references the saved credential. Claude Code
60
- // expands `$` env vars so we use ANYFORGE_API_KEY which the user
61
- // exports separately, keeping the actual secret out of the json.
62
- env.ANTHROPIC_API_KEY = '${ANYFORGE_API_KEY}';
63
- json.env = env;
64
- await fs.writeFile(found.path, JSON.stringify(json, null, 2));
65
- // Drop a hint into the user's shell-rc so ANYFORGE_API_KEY is
66
- // exported next time they open a terminal. We never cat the
67
- // key into the file — they exported the credentials when they
68
- // ran `init`, the credentials reader will surface it.
69
- },
70
- },
71
- // Cursor reads cursor.config.json or its in-app Settings. We don't
72
- // patch those today (they're product-managed) — but we leave a
73
- // hint about ANTHROPIC_BASE_URL via shell rc.
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="$(jq -r '.apiKey' ~/.anyforge/credentials 2>/dev/null)"`,
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="$ANYFORGE_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.1.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",