@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 +55 -0
- package/dist/cli.js +137 -0
- package/dist/commands/applyPatch.js +92 -0
- package/dist/commands/init.js +104 -0
- package/dist/commands/logout.js +21 -0
- package/dist/commands/status.js +58 -0
- package/dist/commands/whoami.js +23 -0
- package/dist/lib/configPatch.js +139 -0
- package/dist/lib/credentials.js +40 -0
- package/package.json +45 -0
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
|
+
}
|