@anyforge/control 0.1.0-beta.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 ADDED
@@ -0,0 +1,55 @@
1
+ # @anyforge/control
2
+
3
+ Governed LLM proxy for any agent. Use the AI tools you already use — Claude Code, Cursor, Cline, Continue, or your own SDK — and route every call through AnyForge for cost analytics, audit log, and policy enforcement.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npx @anyforge/control init
9
+ ```
10
+
11
+ The CLI:
12
+
13
+ 1. Issues a one-time auth code, opens your browser to authorise it.
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`.
16
+
17
+ After that, your next LLM call shows up under `Control` on `/usage` within seconds.
18
+
19
+ ## Commands
20
+
21
+ | | |
22
+ |---|---|
23
+ | `anyforge init` | Authorise this device and patch local agent configs |
24
+ | `anyforge whoami` | Show the company tied to the saved key |
25
+ | `anyforge status` | Verify the proxy is reachable and your BYOK key is configured |
26
+ | `anyforge logout` | Clear the local credential file (does not revoke server-side) |
27
+
28
+ ## Endpoints
29
+
30
+ The CLI defaults to `https://crew.anyforge.ai`. Override per-invocation with `--endpoint`, or globally with the `ANYFORGE_ENDPOINT` environment variable.
31
+
32
+ The proxy exposes:
33
+
34
+ - `POST /v1/anthropic/messages` — drop-in for `https://api.anthropic.com/v1/messages`. Use `Authorization: Bearer sk_anyforge_…` instead of `x-api-key`.
35
+ - `POST /v1/openai/chat/completions` — drop-in for `https://api.openai.com/v1/chat/completions`.
36
+
37
+ Both validate your AnyForge API key, look up your company's BYOK provider key, forward the request, and stream the response back unchanged. Token counts and cost are logged to your usage dashboard.
38
+
39
+ ## What you get
40
+
41
+ - **Cost analytics** — per-call, per-company, per-model spend on `/usage`.
42
+ - **Audit log** — hash-chained record of every prompt + response.
43
+ - **BYOK** — your provider keys live in your AnyForge company; the CLI never holds raw provider keys on the local machine.
44
+ - **One key, every agent** — the same `sk_anyforge_…` works for any agent that talks OpenAI- or Anthropic-shape APIs.
45
+
46
+ ## Coming soon
47
+
48
+ - `anyforge proxy` — local proxy mode for OPA-enforced redaction before requests leave your network.
49
+ - Intent-based routing (rules / regex / embeddings).
50
+ - Portable memory injection.
51
+ - Ollama support for local models.
52
+
53
+ ## License
54
+
55
+ Apache-2.0
package/dist/cli.js ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @anyforge/control — entrypoint.
4
+ *
5
+ * Subcommands:
6
+ * init — browser-based auth flow + config patching
7
+ * whoami — show current identity
8
+ * logout — clear stored credentials
9
+ * status — connectivity + key health check
10
+ * apply-patch — apply a unified diff (file or stdin) via git apply
11
+ * proxy — local proxy mode (Phase 2 — coming soon)
12
+ *
13
+ * Each subcommand is a separate module under src/commands/. The
14
+ * argv parsing here is deliberately tiny — no commander, no yargs.
15
+ * For three flags and four commands, hand-rolled is shorter than
16
+ * the lib + plugin chain.
17
+ */
18
+ import { red, yellow, dim, bold } from 'kleur/colors';
19
+ import { runInit } from './commands/init.js';
20
+ import { runWhoami } from './commands/whoami.js';
21
+ import { runLogout } from './commands/logout.js';
22
+ import { runStatus } from './commands/status.js';
23
+ import { runApplyPatch } from './commands/applyPatch.js';
24
+ function parseArgs(argv) {
25
+ const a = { positional: [] };
26
+ for (let i = 0; i < argv.length; i += 1) {
27
+ const v = argv[i];
28
+ if (!a.command && !v.startsWith('-')) {
29
+ a.command = v;
30
+ continue;
31
+ }
32
+ if (v === '--help' || v === '-h')
33
+ a.help = true;
34
+ else if (v === '--version' || v === '-V')
35
+ a.version = true;
36
+ else if (v === '--endpoint' && argv[i + 1]) {
37
+ a.endpoint = argv[i + 1];
38
+ i += 1;
39
+ }
40
+ else if (v === '--3way')
41
+ a.threeWay = true;
42
+ else if (v === '--dry-run')
43
+ a.dryRun = true;
44
+ else if (!v.startsWith('-'))
45
+ a.positional.push(v);
46
+ }
47
+ return a;
48
+ }
49
+ function printHelp() {
50
+ // eslint-disable-next-line no-console
51
+ console.log(`
52
+ ${bold('AnyForge Control')} — governed LLM proxy for any agent.
53
+
54
+ ${bold('USAGE')}
55
+ npx @anyforge/control <command> [options]
56
+
57
+ ${bold('COMMANDS')}
58
+ init Authorise this device and patch local agent configs
59
+ whoami Show the company tied to the saved API key
60
+ status Check connectivity to the AnyForge proxy
61
+ logout Clear the saved API key and patched configs
62
+ apply-patch Apply a unified diff (file path or stdin) via git apply
63
+
64
+ ${bold('OPTIONS')}
65
+ --endpoint <url> Override the proxy base URL (default: ${dim('https://crew.anyforge.ai')})
66
+ --3way apply-patch: try 3-way merge if the patch doesn't apply cleanly
67
+ --dry-run apply-patch: only check applicability, don't write
68
+ --help, -h Show this message
69
+ --version, -V Print CLI version
70
+
71
+ ${bold('EXAMPLES')}
72
+ npx @anyforge/control init
73
+ npx @anyforge/control status
74
+ npx @anyforge/control apply-patch ./agent-suggestion.diff
75
+ pbpaste | npx @anyforge/control apply-patch
76
+ npx @anyforge/control apply-patch ./conflict.diff --3way
77
+ `);
78
+ }
79
+ async function main() {
80
+ const args = parseArgs(process.argv.slice(2));
81
+ if (args.version) {
82
+ const pkg = await import('../package.json', { assert: { type: 'json' } });
83
+ // eslint-disable-next-line no-console
84
+ console.log(pkg.default.version);
85
+ return;
86
+ }
87
+ if (args.help || !args.command) {
88
+ printHelp();
89
+ return;
90
+ }
91
+ // The proxy lives at ${endpoint} — same host as Code Studio, Crew,
92
+ // and the operator console. Override via --endpoint or
93
+ // ANYFORGE_ENDPOINT for staging.
94
+ const endpoint = args.endpoint
95
+ ?? process.env.ANYFORGE_ENDPOINT
96
+ ?? 'https://crew.anyforge.ai';
97
+ try {
98
+ switch (args.command) {
99
+ case 'init':
100
+ await runInit({ endpoint });
101
+ break;
102
+ case 'whoami':
103
+ await runWhoami();
104
+ break;
105
+ case 'status':
106
+ await runStatus({ endpoint });
107
+ break;
108
+ case 'logout':
109
+ await runLogout();
110
+ break;
111
+ case 'apply-patch':
112
+ await runApplyPatch({
113
+ patchFile: args.positional[0],
114
+ threeWay: args.threeWay,
115
+ dryRun: args.dryRun,
116
+ });
117
+ break;
118
+ case 'proxy':
119
+ // eslint-disable-next-line no-console
120
+ console.log(yellow('Local proxy mode is coming in a future release.'));
121
+ // eslint-disable-next-line no-console
122
+ console.log(dim(' Until then, your agents talk directly to the AnyForge proxy.'));
123
+ break;
124
+ default:
125
+ // eslint-disable-next-line no-console
126
+ console.error(red(`Unknown command: ${args.command}`));
127
+ printHelp();
128
+ process.exit(1);
129
+ }
130
+ }
131
+ catch (err) {
132
+ // eslint-disable-next-line no-console
133
+ console.error(red('Error: ') + (err instanceof Error ? err.message : String(err)));
134
+ process.exit(1);
135
+ }
136
+ }
137
+ void main();
@@ -0,0 +1,92 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { red, green, yellow, dim, bold } from 'kleur/colors';
4
+ export async function runApplyPatch(opts) {
5
+ const patchContent = await loadPatch(opts.patchFile);
6
+ if (!patchContent.trim()) {
7
+ process.stderr.write(red('Empty patch — nothing to apply.\n'));
8
+ process.exit(1);
9
+ }
10
+ const stats = parseDiffStats(patchContent);
11
+ process.stderr.write(dim(`Patch: ${stats.files} ${stats.files === 1 ? 'file' : 'files'}, `) +
12
+ green(`+${stats.added}`) + ' ' + red(`-${stats.removed}`) + '\n');
13
+ // git apply --check first — never make a partial mess
14
+ const check = await runGit(['apply', '--check'], patchContent);
15
+ if (check.code !== 0) {
16
+ if (opts.threeWay) {
17
+ const three = await runGit(['apply', '--3way'], patchContent);
18
+ if (three.code !== 0) {
19
+ process.stderr.write(red('3-way apply failed:\n') + three.stderr + '\n');
20
+ process.exit(1);
21
+ }
22
+ process.stderr.write(yellow('✓ Applied via 3-way merge — review conflicts before committing.\n'));
23
+ return;
24
+ }
25
+ process.stderr.write(red('Patch does not apply cleanly:\n') + check.stderr + '\n');
26
+ process.stderr.write(dim('Try ') + bold('--3way') + dim(' to attempt a 3-way merge against the index.\n'));
27
+ process.exit(1);
28
+ }
29
+ if (opts.dryRun) {
30
+ process.stderr.write(green('✓ Patch would apply cleanly.\n'));
31
+ return;
32
+ }
33
+ const apply = await runGit(['apply'], patchContent);
34
+ if (apply.code !== 0) {
35
+ process.stderr.write(red('git apply failed:\n') + apply.stderr + '\n');
36
+ process.exit(1);
37
+ }
38
+ process.stderr.write(green('✓ Applied.\n'));
39
+ if (stats.files > 0) {
40
+ process.stderr.write(dim(' Run ') + bold('git diff --stat') + dim(' to review.\n'));
41
+ }
42
+ }
43
+ async function loadPatch(filePath) {
44
+ if (filePath) {
45
+ return await readFile(filePath, 'utf8');
46
+ }
47
+ // Read stdin to a single string
48
+ const chunks = [];
49
+ for await (const chunk of process.stdin) {
50
+ chunks.push(chunk);
51
+ }
52
+ return Buffer.concat(chunks).toString('utf8');
53
+ }
54
+ /** Spawn `git <args>`, pipe `stdinData` to its stdin, return code +
55
+ * captured streams. No shell — args are passed as a list to spawn. */
56
+ function runGit(args, stdinData) {
57
+ return new Promise((resolve, reject) => {
58
+ const child = spawn('git', args, { stdio: ['pipe', 'pipe', 'pipe'] });
59
+ let stdout = '';
60
+ let stderr = '';
61
+ child.stdout.on('data', d => { stdout += d.toString(); });
62
+ child.stderr.on('data', d => { stderr += d.toString(); });
63
+ child.on('error', reject);
64
+ child.on('close', code => resolve({ code: code ?? 0, stdout, stderr }));
65
+ child.stdin.write(stdinData);
66
+ child.stdin.end();
67
+ });
68
+ }
69
+ /** Quick stats parse off a unified diff — counts file headers and
70
+ * +/- lines. Skips diff metadata lines (+++, ---) so the report
71
+ * matches `git apply --stat` shape close enough for an inline note. */
72
+ function parseDiffStats(patch) {
73
+ let files = 0;
74
+ let added = 0;
75
+ let removed = 0;
76
+ for (const line of patch.split('\n')) {
77
+ if (line.startsWith('diff --git'))
78
+ files += 1;
79
+ else if (line.startsWith('+++') || line.startsWith('---'))
80
+ continue;
81
+ else if (line.startsWith('+'))
82
+ added += 1;
83
+ else if (line.startsWith('-'))
84
+ removed += 1;
85
+ }
86
+ if (files === 0 && (added > 0 || removed > 0)) {
87
+ // Patch had no `diff --git` headers (a hand-rolled fragment);
88
+ // count filename headers instead.
89
+ files = (patch.match(/^\+\+\+ /gm) ?? []).length;
90
+ }
91
+ return { files, added, removed };
92
+ }
@@ -0,0 +1,104 @@
1
+ import open from 'open';
2
+ import prompts from 'prompts';
3
+ import { green, dim, bold, yellow, cyan } from 'kleur/colors';
4
+ import { saveCredentials } from '../lib/credentials.js';
5
+ import { detectAndPatchConfigs } from '../lib/configPatch.js';
6
+ const POLL_INTERVAL_MS = 2_000;
7
+ const POLL_TIMEOUT_MS = 15 * 60 * 1000;
8
+ export async function runInit(opts) {
9
+ const { endpoint } = opts;
10
+ // eslint-disable-next-line no-console
11
+ console.log(bold('AnyForge Control — device authorisation'));
12
+ // eslint-disable-next-line no-console
13
+ console.log(dim(` endpoint: ${endpoint}`));
14
+ // 1. Issue a one-time code.
15
+ const initRes = await fetch(`${endpoint}/api/cli/init`, { method: 'POST' });
16
+ const init = (await initRes.json());
17
+ if (!init.ok || !init.data) {
18
+ throw new Error(`Could not start the authorisation flow: ${init.error ?? 'unknown error'}`);
19
+ }
20
+ const { code, authUrl, pollUrl } = init.data;
21
+ // 2. Open the browser. Some shells (CI, headless) can't open one;
22
+ // the URL also prints so the user can copy it manually.
23
+ // eslint-disable-next-line no-console
24
+ console.log();
25
+ // eslint-disable-next-line no-console
26
+ console.log(`Open this URL in your browser to authorise:`);
27
+ // eslint-disable-next-line no-console
28
+ console.log(` ${cyan(authUrl)}`);
29
+ // eslint-disable-next-line no-console
30
+ console.log(dim(` Code: ${code}`));
31
+ // eslint-disable-next-line no-console
32
+ console.log();
33
+ try {
34
+ await open(authUrl);
35
+ }
36
+ catch {
37
+ // Headless / CI — the printed URL is fine.
38
+ }
39
+ // 3. Poll. Keep the message live so the user sees we're alive.
40
+ const credentials = await pollForToken({ pollUrl, endpoint });
41
+ // 4. Save credentials with restrictive perms.
42
+ await saveCredentials(credentials);
43
+ // eslint-disable-next-line no-console
44
+ 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
+ if (patches.length === 0) {
48
+ // eslint-disable-next-line no-console
49
+ console.log(yellow('No local agent configs detected.'));
50
+ // eslint-disable-next-line no-console
51
+ console.log(dim(' Set ANTHROPIC_BASE_URL=' + endpoint + '/v1/anthropic and'));
52
+ // eslint-disable-next-line no-console
53
+ console.log(dim(' OPENAI_BASE_URL=' + endpoint + '/v1/openai in your shell when ready.'));
54
+ }
55
+ else {
56
+ summarisePatches(patches);
57
+ }
58
+ // eslint-disable-next-line no-console
59
+ console.log();
60
+ // eslint-disable-next-line no-console
61
+ console.log(bold('Done.') + ' Visit ' + cyan(`${endpoint}/usage`) + ' to see your traffic.');
62
+ }
63
+ async function pollForToken(args) {
64
+ const start = Date.now();
65
+ // eslint-disable-next-line no-console
66
+ process.stdout.write('Waiting for browser authorisation… ');
67
+ let dotCount = 0;
68
+ while (Date.now() - start < POLL_TIMEOUT_MS) {
69
+ await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
70
+ process.stdout.write('.');
71
+ dotCount += 1;
72
+ if (dotCount > 40) {
73
+ process.stdout.write('\n');
74
+ dotCount = 0;
75
+ }
76
+ const res = await fetch(args.pollUrl);
77
+ const body = (await res.json());
78
+ if (body.status === 'authorized' && body.data) {
79
+ process.stdout.write(' ' + green('done') + '\n');
80
+ return {
81
+ apiKey: body.data.apiKey,
82
+ companyId: body.data.companyId,
83
+ endpoint: args.endpoint,
84
+ savedAt: new Date().toISOString(),
85
+ };
86
+ }
87
+ if (body.status === 'expired' || body.status === 'not_found') {
88
+ process.stdout.write('\n');
89
+ throw new Error(`Authorisation ${body.status}. Re-run \`anyforge init\`.`);
90
+ }
91
+ }
92
+ throw new Error('Authorisation timed out (15 min). Re-run `anyforge init`.');
93
+ }
94
+ function summarisePatches(patches) {
95
+ // eslint-disable-next-line no-console
96
+ console.log(green('✔') + ' Patched ' + patches.length + ' agent config' + (patches.length === 1 ? '' : 's') + ':');
97
+ for (const p of patches) {
98
+ // eslint-disable-next-line no-console
99
+ console.log(` ${dim('•')} ${p.label} ${dim('(' + p.path + ')')}`);
100
+ }
101
+ }
102
+ // Re-exported so future commands (e.g. status) can reuse the prompt
103
+ // shape without duplicating the question text.
104
+ export { prompts };
@@ -0,0 +1,21 @@
1
+ import { green, dim, yellow } from 'kleur/colors';
2
+ import { clearCredentials } from '../lib/credentials.js';
3
+ /**
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.
8
+ */
9
+ export async function runLogout() {
10
+ const removed = await clearCredentials();
11
+ if (removed) {
12
+ // eslint-disable-next-line no-console
13
+ 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
+ }
17
+ else {
18
+ // eslint-disable-next-line no-console
19
+ console.log(yellow('No credentials were saved on this machine.'));
20
+ }
21
+ }
@@ -0,0 +1,58 @@
1
+ import { green, red, yellow, dim } from 'kleur/colors';
2
+ import { loadCredentials } from '../lib/credentials.js';
3
+ export async function runStatus(opts) {
4
+ const c = await loadCredentials();
5
+ if (!c) {
6
+ // eslint-disable-next-line no-console
7
+ console.log(yellow('Not logged in.') + ' Run ' + dim('npx @anyforge/control init') + ' first.');
8
+ return;
9
+ }
10
+ const endpoint = opts.endpoint || c.endpoint;
11
+ // eslint-disable-next-line no-console
12
+ console.log(`Endpoint: ${endpoint}`);
13
+ // eslint-disable-next-line no-console
14
+ console.log(`Company: ${c.companyId}`);
15
+ // We don't have a dedicated /v1/health endpoint yet — the cheapest
16
+ // probe is a 1-token Anthropic Messages call. Costs ~$0.0001, fine.
17
+ const probeBody = {
18
+ model: 'claude-3-5-haiku-20241022',
19
+ max_tokens: 1,
20
+ messages: [{ role: 'user', content: 'Hi' }],
21
+ };
22
+ let res;
23
+ try {
24
+ res = await fetch(`${endpoint}/v1/anthropic/messages`, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ 'anthropic-version': '2023-06-01',
29
+ Authorization: `Bearer ${c.apiKey}`,
30
+ },
31
+ body: JSON.stringify(probeBody),
32
+ });
33
+ }
34
+ catch (err) {
35
+ // eslint-disable-next-line no-console
36
+ console.log(red('✗ Proxy unreachable: ') + err.message);
37
+ process.exit(1);
38
+ }
39
+ if (res.status === 401) {
40
+ // eslint-disable-next-line no-console
41
+ console.log(red('✗ API key rejected.') + ' Re-run ' + dim('npx @anyforge/control init') + '.');
42
+ process.exit(1);
43
+ }
44
+ if (res.status === 502) {
45
+ // eslint-disable-next-line no-console
46
+ console.log(red('✗ No BYOK Anthropic key configured for your company.'));
47
+ // eslint-disable-next-line no-console
48
+ console.log(dim(' Add one at ') + endpoint + dim('/settings → API keys.'));
49
+ process.exit(1);
50
+ }
51
+ if (!res.ok) {
52
+ // eslint-disable-next-line no-console
53
+ console.log(red(`✗ Proxy returned ${res.status}: `) + (await res.text()).slice(0, 200));
54
+ process.exit(1);
55
+ }
56
+ // eslint-disable-next-line no-console
57
+ console.log(green('✔ Proxy reachable, key valid, BYOK configured.'));
58
+ }
@@ -0,0 +1,23 @@
1
+ import { dim, yellow } from 'kleur/colors';
2
+ import { loadCredentials } from '../lib/credentials.js';
3
+ /**
4
+ * `npx @anyforge/control whoami` — show the company id tied to the
5
+ * currently saved credential. Useful for verifying the right device
6
+ * is configured before running long agent sessions.
7
+ */
8
+ export async function runWhoami() {
9
+ const c = await loadCredentials();
10
+ if (!c) {
11
+ // eslint-disable-next-line no-console
12
+ console.log(yellow('Not logged in.') + ' Run ' + dim('npx @anyforge/control init') + ' first.');
13
+ return;
14
+ }
15
+ // eslint-disable-next-line no-console
16
+ console.log(`Company: ${c.companyId}`);
17
+ // eslint-disable-next-line no-console
18
+ console.log(`Endpoint: ${c.endpoint}`);
19
+ // eslint-disable-next-line no-console
20
+ console.log(`Saved: ${new Date(c.savedAt).toLocaleString()}`);
21
+ // eslint-disable-next-line no-console
22
+ console.log(`Key tail: ${dim('…' + c.apiKey.slice(-4))}`);
23
+ }
@@ -0,0 +1,139 @@
1
+ import { promises as fs } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import prompts from 'prompts';
5
+ import { dim, green } from 'kleur/colors';
6
+ export async function detectAndPatchConfigs(opts) {
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) {
12
+ const found = await detector.detect();
13
+ if (!found)
14
+ continue;
15
+ const proceed = await prompts({
16
+ type: 'confirm',
17
+ name: 'go',
18
+ message: `Patch ${detector.label} config at ${dim(found.path)} ?`,
19
+ initial: true,
20
+ });
21
+ if (!proceed.go)
22
+ continue;
23
+ try {
24
+ await detector.patch(found, opts);
25
+ summary.push({ label: detector.label, path: found.path });
26
+ // eslint-disable-next-line no-console
27
+ console.log(green(' ✔') + ' ' + detector.label);
28
+ }
29
+ catch (err) {
30
+ // eslint-disable-next-line no-console
31
+ console.warn(' ⚠ Skipped ' + detector.label + ': ' + err.message);
32
+ }
33
+ }
34
+ return summary;
35
+ }
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.
74
+ {
75
+ label: 'Cursor (shell hint)',
76
+ async detect() {
77
+ const path = join(homedir(), '.cursor');
78
+ const exists = await fileExists(path);
79
+ return exists ? { path: '.cursor (shell hint)' } : null;
80
+ },
81
+ async patch(_found, opts) {
82
+ await ensureShellRcExports(opts);
83
+ },
84
+ },
85
+ // Cline / Continue reads OPENAI_BASE_URL by default. Same shell
86
+ // rc patch as the Cursor case.
87
+ {
88
+ label: 'Cline / Continue (shell hint)',
89
+ async detect() {
90
+ const a = await fileExists(join(homedir(), '.cline'));
91
+ const b = await fileExists(join(homedir(), '.continue'));
92
+ return a || b ? { path: '~/.cline or ~/.continue (shell hint)' } : null;
93
+ },
94
+ async patch(_found, opts) {
95
+ await ensureShellRcExports(opts);
96
+ },
97
+ },
98
+ ];
99
+ async function ensureShellRcExports(opts) {
100
+ // Idempotent: re-running the CLI shouldn't append duplicate
101
+ // exports. We tag our block with a sentinel comment so we can
102
+ // detect + replace cleanly on subsequent runs.
103
+ const SENTINEL_BEGIN = '# === AnyForge Control — added by `anyforge init` ===';
104
+ const SENTINEL_END = '# === end AnyForge Control ===';
105
+ const block = [
106
+ SENTINEL_BEGIN,
107
+ `export ANYFORGE_API_KEY="$(jq -r '.apiKey' ~/.anyforge/credentials 2>/dev/null)"`,
108
+ `export ANTHROPIC_BASE_URL="${opts.endpoint}/v1/anthropic"`,
109
+ `export OPENAI_BASE_URL="${opts.endpoint}/v1/openai"`,
110
+ `export OPENAI_API_KEY="$ANYFORGE_API_KEY"`,
111
+ SENTINEL_END,
112
+ '',
113
+ ].join('\n');
114
+ for (const rc of ['.zshrc', '.bashrc']) {
115
+ const path = join(homedir(), rc);
116
+ if (!(await fileExists(path)))
117
+ continue;
118
+ const cur = await fs.readFile(path, 'utf8');
119
+ let next;
120
+ if (cur.includes(SENTINEL_BEGIN)) {
121
+ // Replace the existing block in place so re-runs stay clean.
122
+ next = cur.replace(new RegExp(`${SENTINEL_BEGIN}[\\s\\S]*?${SENTINEL_END}\\n?`), block);
123
+ }
124
+ else {
125
+ next = cur.endsWith('\n') ? `${cur}\n${block}` : `${cur}\n\n${block}`;
126
+ }
127
+ if (next !== cur)
128
+ await fs.writeFile(path, next);
129
+ }
130
+ }
131
+ async function fileExists(p) {
132
+ try {
133
+ await fs.access(p);
134
+ return true;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
@@ -0,0 +1,40 @@
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 };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@anyforge/control",
3
+ "version": "0.1.0-beta.0",
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
+ "license": "Apache-2.0",
6
+ "homepage": "https://anyforge.ai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pootonline/sovereign-engineering.git",
10
+ "directory": "packages/control/cli"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "main": "dist/cli.js",
17
+ "bin": {
18
+ "anyforge": "dist/cli.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc && chmod +x dist/cli.js",
26
+ "dev": "tsx src/cli.ts",
27
+ "prepublishOnly": "npm run build",
28
+ "publish:beta": "npm publish --access public --tag beta",
29
+ "publish:stable": "npm publish --access public"
30
+ },
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "dependencies": {
35
+ "kleur": "^4.1.5",
36
+ "open": "^10.0.0",
37
+ "prompts": "^2.4.2"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.11.0",
41
+ "@types/prompts": "^2.4.9",
42
+ "tsx": "^4.7.0",
43
+ "typescript": "^5.5.3"
44
+ }
45
+ }