@anyforge/control 0.2.3 → 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 CHANGED
@@ -1,6 +1,11 @@
1
1
  # @anyforge/control
2
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.
3
+ Two things in one binary:
4
+
5
+ 1. **A governed LLM proxy** for any agent — Claude Code, Cursor, Cline, Continue, or your own SDK. Route their calls through AnyForge for cost analytics, audit log, and policy enforcement.
6
+ 2. **A scriptable AnyForge client** — approvals, crews, work items and workflows from a shell, with no browser and no agent in the loop.
7
+
8
+ The second half is what makes AnyForge usable from CI. Every command speaks the same `/api/v1/control/*` API the MCP server does, but without needing a language model to drive it.
4
9
 
5
10
  ## Install
6
11
 
@@ -28,22 +33,77 @@ Pick whichever suits the moment — the choices aren't mutually exclusive over t
28
33
 
29
34
  - Want to add a second repo? Re-run `npx @anyforge/control init` from that repo's root and pick `repo` again.
30
35
  - 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.
36
+ - Want to undo a per-repo install? Run `anyforge logout` — it now reverts the patched configs (`~/.claude/settings.json`, `<repo>/.claude/settings.local.json`, the `# === AnyForge Control ===` block in `.env` / your shell rc) and restores any previously-shadowed login.
32
37
 
33
38
  > 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
39
 
40
+ ### Claude Pro / Max subscription users
41
+
42
+ The inference proxy is **BYOK-only**: it routes Claude Code through your own Anthropic *API key* for metering and governance. It is **incompatible with a Claude Pro/Max (subscription) login** — the `ANTHROPIC_AUTH_TOKEN` it patches out-ranks (Claude Code auth precedence #2) and therefore disables the subscription login (#6), and the proxy has no upstream API key for a subscription user, so Claude Code falls back to "please login".
43
+
44
+ `anyforge init` detects a subscription login and **skips patching**, steering you to the **AnyForge MCP server** instead — which keeps your subscription for inference and adds AnyForge control (HIL approvals, work items, crews) on top, with no auth conflict:
45
+
46
+ ```bash
47
+ claude mcp add anyforge --env ANYFORGE_API_KEY=<your key> -- npx -y @anyforge/mcp-server
48
+ ```
49
+
50
+ If you really want the metered BYOK proxy, re-run `anyforge init --force` (you'll need an Anthropic API key configured on your company; this disables your subscription in Claude Code until you `anyforge logout`).
51
+
35
52
  ### Why `ANTHROPIC_AUTH_TOKEN`, not `ANTHROPIC_API_KEY`?
36
53
 
37
- Claude Code recognises both, but they behave differently. `ANTHROPIC_API_KEY` is sent as `X-Api-Key` and — when an OAuth Console login is also present — surfaces an "Auth conflict: Using ANTHROPIC_API_KEY instead of Anthropic Console key" warning, and can cascade into a "selected model may not exist" error. `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer …`, is documented for "routing through an LLM gateway or proxy", out-ranks the OAuth login cleanly, and matches the bearer-token shape AnyForge expects. We patch the token form, and on every install we explicitly delete any stale `ANTHROPIC_API_KEY` left behind by older versions.
54
+ Claude Code recognises both, but they behave differently. `ANTHROPIC_API_KEY` is sent as `X-Api-Key` and — when an OAuth Console login is also present — surfaces an "Auth conflict: Using ANTHROPIC_API_KEY instead of Anthropic Console key" warning, and can cascade into a "selected model may not exist" error. `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer …`, is documented for "routing through an LLM gateway or proxy", and matches the bearer-token shape AnyForge expects. We patch the token form, and on every install we explicitly delete any stale `ANTHROPIC_API_KEY` left behind by older versions. Note that it out-ranks a subscription login (see above) — which is exactly why `init` refuses to patch over a Pro/Max login by default.
38
55
 
39
56
  ## Commands
40
57
 
58
+ ### Setup
59
+
41
60
  | | |
42
61
  |---|---|
43
62
  | `anyforge init` | Authorise this device and patch local agent configs |
44
- | `anyforge whoami` | Show the company tied to the saved key |
63
+ | `anyforge whoami` | Show the company, scopes and endpoint this key acts as (asks the server) |
45
64
  | `anyforge status` | Verify the proxy is reachable and your BYOK key is configured |
46
- | `anyforge logout` | Clear the local credential file (does not revoke server-side) |
65
+ | `anyforge logout` | Clear the local credential file **and revert patched configs** (does not revoke server-side) |
66
+ | `anyforge apply-patch` | Apply a unified diff from a file or stdin via `git apply` |
67
+
68
+ ### Scriptable
69
+
70
+ | | | Scope |
71
+ |---|---|---|
72
+ | `anyforge approvals` | List HIL approvals awaiting a decision | `read` |
73
+ | `anyforge approve <id>` | Approve one (`--notes` is recorded in the audit trail) | `approve` |
74
+ | `anyforge reject <id>` | Reject one | `approve` |
75
+ | `anyforge crews` | List crews (`--active` for non-terminal only) | `read` |
76
+ | `anyforge crew <id>` | Show one crew, its status and recent output | `read` |
77
+ | `anyforge halt <id>` / `resume <id>` | Stop or restart a crew | `approve` |
78
+ | `anyforge work` | List work items (`--status`, `--type`, `--priority`, `--search`, …) | `read` |
79
+ | `anyforge workflows` | List operator workflows | `read` |
80
+ | `anyforge run <id>` | Dispatch a run (`--input '{"repo":"acme/web"}'`) | `write` |
81
+ | `anyforge ask <question>` | Ask the Oracle one question | `read` |
82
+
83
+ Every command takes `--json`, which prints the API payload verbatim:
84
+
85
+ ```bash
86
+ anyforge approvals --json | jq 'length'
87
+ anyforge crews --active --json | jq -r '.[].id' | xargs -n1 anyforge halt
88
+ ```
89
+
90
+ ### Exit codes
91
+
92
+ CI gates on these, so they are a contract:
93
+
94
+ | | |
95
+ |---|---|
96
+ | `0` | Success — an empty list included |
97
+ | `1` | Network or unexpected failure (retryable) |
98
+ | `2` | Usage error |
99
+ | `3` | Not authenticated — run `init`, or set `ANYFORGE_API_TOKEN` |
100
+ | `4` | The API refused; its error code prints to stderr |
101
+
102
+ Errors and progress go to stderr, so a `--json` pipe stays parseable under a failure.
103
+
104
+ ### Unattended use
105
+
106
+ Set `ANYFORGE_API_TOKEN` (and optionally `ANYFORGE_API_URL`) instead of running `init` — no browser, no credential file. Mint the key under **Settings → API keys** with the narrowest scope the job needs.
47
107
 
48
108
  ## Endpoints
49
109
 
@@ -51,8 +111,8 @@ The CLI defaults to `https://crew.anyforge.ai`. Override per-invocation with `--
51
111
 
52
112
  The proxy exposes:
53
113
 
54
- - `POST /v1/anthropic/messages` — drop-in for `https://api.anthropic.com/v1/messages`. Use `Authorization: Bearer sk_anyforge_…` instead of `x-api-key`.
55
- - `POST /v1/openai/chat/completions` — drop-in for `https://api.openai.com/v1/chat/completions`.
114
+ - `POST /v1/anthropic/v1/messages` — drop-in for `https://api.anthropic.com/v1/messages`. Use `Authorization: Bearer sk_anyforge_…` instead of `x-api-key`. The `/v1` repeats because you set `ANTHROPIC_BASE_URL` to `https://crew.anyforge.ai/v1/anthropic` and the Anthropic SDK appends `/v1/messages` to it.
115
+ - `POST /v1/openai/v1/chat/completions` — drop-in for `https://api.openai.com/v1/chat/completions`.
56
116
 
57
117
  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.
58
118
 
package/dist/cli.js CHANGED
@@ -1,137 +1,97 @@
1
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.
2
+ var rt=Object.defineProperty;var it=(e,t)=>()=>(e&&(t=e(e=0)),t);var st=(e,t)=>{for(var o in t)rt(e,o,{get:t[o],enumerable:!0})};var Qe={};st(Qe,{default:()=>Ht});var Ht,Ze=it(()=>{Ht={name:"@anyforge/control",version:"0.3.0",description:"AnyForge Control CLI \u2014 governed LLM proxy for any agent, and a scriptable client for AnyForge approvals, crews, work items and workflows.",license:"Apache-2.0",homepage:"https://anyforge.ai",repository:{type:"git",url:"git+https://github.com/pootonline/sovereign-engineering.git",directory:"packages/control/cli"},publishConfig:{access:"public"},type:"module",main:"dist/cli.js",bin:{anyforge:"dist/cli.js",control:"dist/cli.js"},files:["dist","README.md"],scripts:{build:"esbuild src/cli.ts --bundle --minify --platform=node --format=esm --outfile=dist/cli.js --packages=external && chmod +x dist/cli.js","type-check":"tsc --noEmit",dev:"tsx src/cli.ts",prepublishOnly:"npm run build","publish:beta":"npm publish --access public --tag beta","publish:stable":"npm publish --access public"},engines:{node:">=22"},dependencies:{kleur:"^4.1.5",open:"^10.0.0",prompts:"^2.4.2"},devDependencies:{"@types/node":"^22.20.0","@types/prompts":"^2.4.9",esbuild:"^0.25.0",tsx:"^4.7.0",typescript:"^7.0.2"}}});import{red as et,yellow as Bt,dim as F,bold as P}from"kleur/colors";import{execFileSync as ne}from"child_process";import yt from"open";import oe from"prompts";import{green as $,dim as c,bold as E,yellow as I,cyan as M}from"kleur/colors";import{promises as U}from"fs";import{homedir as at}from"os";import{join as Ee,dirname as no}from"path";var Re=Ee(at(),".anyforge"),Z=Ee(Re,"credentials");async function ve(e){await U.mkdir(Re,{recursive:!0,mode:448}),await U.writeFile(Z,JSON.stringify(e,null,2),{mode:384})}async function b(){try{let e=await U.readFile(Z,"utf8");return JSON.parse(e)}catch(e){if(e.code==="ENOENT")return null;throw e}}async function Ae(){try{return await U.unlink(Z),!0}catch(e){if(e.code==="ENOENT")return!1;throw e}}import{promises as l}from"fs";import{execFileSync as lt}from"child_process";import{homedir as C}from"os";import{join as f}from"path";import D from"prompts";import{dim as w,green as te,yellow as K}from"kleur/colors";var T="# === AnyForge Control \u2014 added by `anyforge init` ===",S="# === end AnyForge Control ===",k=".anyforge-bak",ct=/claude_(max|pro|team|enterprise)/i,dt=["CLAUDE_CODE_ENABLE_TELEMETRY","OTEL_METRICS_EXPORTER","OTEL_EXPORTER_OTLP_PROTOCOL","OTEL_EXPORTER_OTLP_ENDPOINT","OTEL_EXPORTER_OTLP_HEADERS","OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"];async function Te(e){let t=[],o=await ut(e);if(o&&t.push(o),e.scope==="repo"){let n=await pt(e);return n&&t.push(n),t}for(let n of gt){let r=await n.detect();if(!(!r||!(await D({type:"confirm",name:"go",message:`Patch ${n.label} via shell rc (${w(r.path)}) ?`,initial:!0})).go))try{await n.patch(r,e),t.push({label:n.label,path:r.path}),console.log(te(" \u2714")+" "+n.label)}catch(s){console.warn(" \u26A0 Skipped "+n.label+": "+s.message)}}return t}async function ut(e){let t=e.scope==="repo"&&e.repoRoot?{dir:f(e.repoRoot,".claude"),file:"settings.local.json"}:{dir:f(C(),".claude"),file:"settings.json"},o=f(t.dir,t.file);if(!(await D({type:"confirm",name:"go",message:`Patch Claude Code config at ${w(o)} ?`,initial:!0})).go)return null;await l.mkdir(t.dir,{recursive:!0});let r=await l.readFile(o,"utf8").catch(()=>"{}"),i;try{i=JSON.parse(r)}catch{i={}}let s=i.env??{},a=s.ANTHROPIC_BASE_URL;return a&&!a.includes(e.endpoint)&&(a.includes("localhost")||a.includes("127.0.0.1")||a.includes("0.0.0.0"))&&(console.log(),console.log(K(" \u26A0 Local LLM proxy detected: ")+w(a)),console.log(w(" AnyForge only routes Anthropic Claude models. Local models (Ollama,")),console.log(w(" LM Studio, etc.) will not work in Claude Code after this patch.")),console.log(w(" To restore: remove ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN")),console.log(w(` from ${o}`)),console.log(),!(await D({type:"confirm",name:"go",message:"Overwrite existing local LLM proxy config?",initial:!1})).go)?null:(await R(o)&&!await R(o+k)&&await l.copyFile(o,o+k),s.ANTHROPIC_BASE_URL=`${e.endpoint}/v1/anthropic`,s.ANTHROPIC_AUTH_TOKEN=e.apiKey,delete s.ANTHROPIC_API_KEY,i.env=s,await l.writeFile(o,JSON.stringify(i,null,2)+`
3
+ `),console.log(te(" \u2714")+" Claude Code"),{label:e.scope==="repo"?"Claude Code (repo)":"Claude Code (global)",path:o})}async function xe(e){let t=f(C(),".claude"),o=f(t,"settings.json");await l.mkdir(t,{recursive:!0});let n=await l.readFile(o,"utf8").catch(()=>"{}"),r;try{r=JSON.parse(n)}catch{r={}}let i=r.env??{},s=i.OTEL_EXPORTER_OTLP_ENDPOINT;return s&&!ee(s,_e(e.endpoint))?(console.log(K(" \u26A0 An OpenTelemetry endpoint is already configured: ")+w(s)),console.log(w(" Leaving it untouched. To send Claude Code usage to AnyForge, point")),console.log(w(" OTEL_EXPORTER_OTLP_ENDPOINT at "+e.endpoint+"/api/otel")),null):(await R(o)&&!await R(o+k)&&await l.copyFile(o,o+k),i.CLAUDE_CODE_ENABLE_TELEMETRY="1",i.OTEL_METRICS_EXPORTER="otlp",i.OTEL_EXPORTER_OTLP_PROTOCOL="http/json",i.OTEL_EXPORTER_OTLP_ENDPOINT=`${e.endpoint}/api/otel`,i.OTEL_EXPORTER_OTLP_HEADERS=`Authorization=Bearer ${e.apiKey}`,i.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE="delta",r.env=i,await l.writeFile(o,JSON.stringify(r,null,2)+`
4
+ `),{label:"Claude Code usage telemetry (global)",path:o})}async function pt(e){if(!e.repoRoot)return null;let t=f(e.repoRoot,".env");if(!(await D({type:"confirm",name:"go",message:`Write AnyForge env block to ${w(t)} ?`,initial:!0})).go)return null;let n=[T,`ANYFORGE_API_KEY=${e.apiKey}`,`ANTHROPIC_BASE_URL=${e.endpoint}/v1/anthropic`,`ANTHROPIC_AUTH_TOKEN=${e.apiKey}`,`OPENAI_BASE_URL=${e.endpoint}/v1/openai`,`OPENAI_API_KEY=${e.apiKey}`,S,""].join(`
5
+ `),r="";try{r=await l.readFile(t,"utf8")}catch{}let i;return r.includes(T)?i=r.replace(new RegExp(`${T}[\\s\\S]*?${S}\\n?`),n):r.length===0?i=n:i=r.endsWith(`
6
+ `)?`${r}${n}`:`${r}
7
+ ${n}`,i!==r&&await l.writeFile(t,i),await ft(e.repoRoot),console.log(te(" \u2714")+" .env"),{label:".env (repo)",path:t}}async function ft(e){let t=f(e,".gitignore");try{(await l.readFile(t,"utf8")).split(`
8
+ `).map(i=>i.trim()).some(i=>i===".env"||i==="/.env"||i==="*.env")||console.log(K(" \u26A0 .env is not in .gitignore \u2014 add it before committing."))}catch{console.log(K(" \u26A0 No .gitignore found \u2014 make sure .env is excluded before committing."))}}var gt=[{label:"Cursor (shell hint)",async detect(){let e=f(C(),".cursor");return await R(e)?{path:".cursor (shell hint)"}:null},async patch(e,t){await Ce(t)}},{label:"Cline / Continue (shell hint)",async detect(){let e=await R(f(C(),".cline")),t=await R(f(C(),".continue"));return e||t?{path:"~/.cline or ~/.continue (shell hint)"}:null},async patch(e,t){await Ce(t)}}];async function Ce(e){let t=[T,`export ANYFORGE_API_KEY="${e.apiKey}"`,`export ANTHROPIC_BASE_URL="${e.endpoint}/v1/anthropic"`,`export ANTHROPIC_AUTH_TOKEN="${e.apiKey}"`,`export OPENAI_BASE_URL="${e.endpoint}/v1/openai"`,`export OPENAI_API_KEY="${e.apiKey}"`,"unset ANTHROPIC_API_KEY 2>/dev/null || true",S,""].join(`
9
+ `);for(let o of[".zshrc",".bashrc"]){let n=f(C(),o);if(!await R(n))continue;let r=await l.readFile(n,"utf8"),i;r.includes(T)?i=r.replace(new RegExp(`${T}[\\s\\S]*?${S}\\n?`),t):i=r.endsWith(`
10
+ `)?`${r}
11
+ ${t}`:`${r}
53
12
 
54
- ${bold('USAGE')}
13
+ ${t}`,i!==r&&await l.writeFile(n,i)}}async function R(e){try{return await l.access(e),!0}catch{return!1}}async function ke(){try{let e=await l.readFile(f(C(),".claude.json"),"utf8"),o=JSON.parse(e).oauthAccount,n=o?.organizationType??"";if(o&&ct.test(n))return{isSubscription:!0,plan:/max/i.test(n)?"Max":/pro/i.test(n)?"Pro":/enterprise/i.test(n)?"Enterprise":/team/i.test(n)?"Team":n,email:o.emailAddress??null}}catch{}return{isSubscription:!1,plan:null,email:null}}async function Ie(e={}){let t=e.endpoint?_e(e.endpoint):null,o=[],n=f(C(),".claude","settings.json");await Oe(n,t)&&o.push(n);for(let i of[".zshrc",".bashrc"]){let s=f(C(),i);await Pe(s)&&o.push(s)}let r=e.repoRoot??mt(process.cwd());if(r){let i=f(r,".claude","settings.local.json");await Oe(i,t)&&o.push(i);let s=f(r,".env");await Pe(s)&&o.push(s)}return{reverted:o}}function ee(e,t){return e?e.includes("anyforge")?!0:t?e.includes(t):!1:!1}async function Oe(e,t){if(!await R(e))return!1;if(await R(e+k))return await l.copyFile(e+k,e),await l.unlink(e+k).catch(()=>{}),!0;let o;try{o=JSON.parse(await l.readFile(e,"utf8"))}catch{return!1}let n=o.env;if(!n)return!1;let r=!1,i=[["ANTHROPIC_BASE_URL","ANTHROPIC_AUTH_TOKEN"],["OPENAI_BASE_URL","OPENAI_API_KEY"]];for(let[s,a]of i)ee(n[s],t)&&(delete n[s],delete n[a],r=!0);if(n.ANYFORGE_API_KEY&&(delete n.ANYFORGE_API_KEY,r=!0),ee(n.OTEL_EXPORTER_OTLP_ENDPOINT,t))for(let s of dt)s in n&&(delete n[s],r=!0);return r?(Object.keys(n).length===0&&delete o.env,await l.writeFile(e,JSON.stringify(o,null,2)+`
14
+ `),!0):!1}async function Pe(e){let t;try{t=await l.readFile(e,"utf8")}catch{return!1}if(!t.includes(T))return!1;let o=t.replace(new RegExp(`\\n?${be(T)}[\\s\\S]*?${be(S)}\\n?`),`
15
+ `).replace(/\n{3,}/g,`
16
+
17
+ `);return await l.writeFile(e,o),!0}function be(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function _e(e){try{return new URL(e).host}catch{return null}}function mt(e){try{return lt("git",["rev-parse","--show-toplevel"],{cwd:e,encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim()}catch{return null}}var ht=2e3,wt=900*1e3;async function $e(e){let{endpoint:t}=e;console.log(E("AnyForge Control \u2014 device authorisation")),console.log(c(` endpoint: ${t}`));let o=process.cwd(),n=Se(o),r=n?Ct(n):null,s=await(await fetch(`${t}/api/cli/init`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({repo:r})})).json();if(!s.ok||!s.data)throw new Error(`Could not start the authorisation flow: ${s.error??"unknown error"}`);let{code:a,authUrl:m,pollUrl:me}=s.data;console.log(),console.log("Open this URL in your browser to authorise:"),console.log(` ${M(m)}`),console.log(c(` Code: ${a}`)),console.log();try{await yt(m)}catch{}let Q=await Et({pollUrl:me,endpoint:t});await ve(Q),console.log($("\u2714")+" API key saved to "+c("~/.anyforge/credentials"));let j=await ke();if(j.isSubscription&&!e.force){await Rt(j,t,Q.apiKey);return}let nt=process.cwd(),ye=Se(nt),he=await vt(ye),we=await Te({endpoint:t,apiKey:Q.apiKey,scope:he,repoRoot:ye});we.length===0?(console.log(I("No agent configs were patched.")),console.log(c(" Set ANTHROPIC_BASE_URL="+t+"/v1/anthropic and")),console.log(c(" ANTHROPIC_AUTH_TOKEN=<your AnyForge key> manually if needed."))):At(we),console.log(),j.isSubscription&&e.force&&(console.log(I(` \u26A0 --force: your Claude ${j.plan??""} subscription login is now shadowed`)),console.log(I(" by the AnyForge token \u2014 Claude Code will use the BYOK proxy, which")),console.log(I(" needs an Anthropic API key on your company. Run ")+E("anyforge logout")),console.log(I(" to revert and restore your subscription."))),he==="repo"&&(console.log(c(" Re-run this command in another repo to patch it too,")),console.log(c(' or run from any directory and choose "global" to install system-wide.'))),console.log(E("Done.")+" Visit "+M(`${t}/usage`)+" to see your traffic.")}async function Et(e){let t=Date.now();process.stdout.write("Waiting for browser authorisation\u2026 ");let o=0;for(;Date.now()-t<wt;){await new Promise(i=>setTimeout(i,ht)),process.stdout.write("."),o+=1,o>40&&(process.stdout.write(`
18
+ `),o=0);let r=await(await fetch(e.pollUrl)).json();if(r.status==="authorized"&&r.data)return process.stdout.write(" "+$("done")+`
19
+ `),{apiKey:r.data.apiKey,companyId:r.data.companyId,endpoint:e.endpoint,savedAt:new Date().toISOString()};if(r.status==="expired"||r.status==="not_found")throw process.stdout.write(`
20
+ `),new Error(`Authorisation ${r.status}. Re-run \`anyforge init\`.`)}throw new Error("Authorisation timed out (15 min). Re-run `anyforge init`.")}async function Rt(e,t,o){let n=e.email?` (${e.email})`:"";console.log(),console.log(I(` \u26A0 Claude ${e.plan??""} subscription login detected${n}.`)),console.log(),console.log(" AnyForge Control\u2019s inference proxy is "+E("BYOK-only")+" \u2014 it routes"),console.log(" Claude Code through your own Anthropic API key for metering and"),console.log(" governance. Pointing Claude Code at the proxy would "+E("override and")),console.log(" "+E("disable your subscription login")+", so we did NOT patch your config."),console.log(),console.log(" Instead, use your subscription for inference and add the "+E("AnyForge")),console.log(" "+E("MCP server")+" for control (HIL approvals, work items, crews):"),console.log();let r=`claude mcp add anyforge --env ANYFORGE_API_KEY=${o} -- npx -y @anyforge/mcp-server`;if((await oe({type:"confirm",name:"go",message:"Add the AnyForge MCP server to Claude Code now?",initial:!0})).go)try{ne("claude",["mcp","add","anyforge","--env",`ANYFORGE_API_KEY=${o}`,"--","npx","-y","@anyforge/mcp-server"],{stdio:"inherit"}),console.log($(" \u2714")+" AnyForge MCP server added to Claude Code.")}catch{console.log(I(" Could not run `claude mcp add` automatically (is the `claude` CLI on PATH?).")),console.log(c(" Add it manually:")),console.log(" "+r)}else console.log(c(" Add it later with:")),console.log(" "+r);if(console.log(),(await oe({type:"confirm",name:"go",message:"Send Claude Code usage to your AnyForge dashboard? (token counts only \u2014 no prompt content)",initial:!0})).go){let a=await xe({endpoint:t,apiKey:o});a&&(console.log($(" \u2714")+" Usage telemetry enabled \u2014 view it at "+M(`${t}/usage`)),console.log(c(" (written to "+a.path+"; `anyforge logout` removes it)")))}else console.log(c(" Skipped \u2014 your subscription usage stays local to Claude Code."));console.log(),console.log(c(" Want the metered BYOK proxy instead? Re-run with ")+E("--force")+c(" using an")),console.log(c(" Anthropic API key on your company (this disables your subscription in")),console.log(c(" Claude Code; ")+E("anyforge logout")+c(" reverts it).")),console.log(),console.log(E("Done.")+" Visit "+M(`${t}/usage`)+" to see your traffic.")}function Se(e){try{return ne("git",["rev-parse","--show-toplevel"],{cwd:e,encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim()}catch{return null}}async function vt(e){return e?(await oe({type:"select",name:"scope",message:"Where should AnyForge patch Claude Code?",choices:[{title:`This repo only (${e}/.claude/settings.local.json + .env)`,value:"repo"},{title:"Globally (~/.claude/settings.json)",value:"global"}],initial:0})).scope??"repo":(console.log(c(" Not a git repo root \u2014 installing globally.")),console.log(c(" Run this from a repo root if you want a per-repo install instead.")),"global")}function At(e){console.log($("\u2714")+" Patched "+e.length+" agent config"+(e.length===1?"":"s")+":");for(let t of e)console.log(` ${c("\u2022")} ${t.label} ${c("("+t.path+")")}`)}function Ct(e){try{let t=ne("git",["remote","get-url","origin"],{cwd:e,encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim(),o=t.match(/^git@[\w.-]+:([\w.-]+\/[\w.-]+?)(?:\.git)?$/);if(o)return o[1];let n=t.match(/^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/);return n?n[1]:null}catch{return null}}import{dim as ae,yellow as Tt,bold as xt}from"kleur/colors";var O={OK:0,FAILURE:1,USAGE:2,NO_CREDENTIALS:3,API_ERROR:4},v=class extends Error{exitCode;hint;constructor(t,o=O.FAILURE,n){super(t),this.name="CliError",this.exitCode=o,this.hint=n}},d=class extends v{constructor(t,o){super(t,O.USAGE,o),this.name="UsageError"}},G=class extends v{constructor(){super("Not authenticated \u2014 no ANYFORGE_API_TOKEN and no saved credential.",O.NO_CREDENTIALS,"Run `npx @anyforge/control init`, or set ANYFORGE_API_TOKEN for unattended use."),this.name="NotAuthenticatedError"}},W=class extends v{code;status;constructor(t,o,n){super(n?`${t}: ${n}`:t,O.API_ERROR,Ot(t,o)),this.name="ControlApiError",this.code=t,this.status=o}};function Ot(e,t){if(e==="missing_api_key"||e==="invalid_api_key"||e==="invalid_token"||t===401)return"The key was rejected. Re-run `npx @anyforge/control init`.";if(e==="insufficient_scope"||t===403)return"This key lacks the scope for that command. Run `anyforge whoami` to see its scopes.";if(t===404)return"No such id in this company. Check it with the matching list command.";if(t===429)return"Rate limited. Retry after a short pause."}var Ne="https://crew.anyforge.ai";async function Y(e){let t=process.env.ANYFORGE_API_TOKEN,o=e||process.env.ANYFORGE_API_URL;if(t)return{apiKey:t,endpoint:o||Ne};let n=await b();if(n?.apiKey)return{apiKey:n.apiKey,endpoint:o||n.endpoint||Ne};throw new G}async function u(e,t,o,n){let r=`${e.endpoint.replace(/\/$/,"")}/api/v1/control${o}`,i;try{i=await fetch(r,{method:t,headers:{"Content-Type":"application/json",Authorization:`Bearer ${e.apiKey}`},...n!==void 0?{body:JSON.stringify(n)}:{}})}catch(a){throw new v(`Cannot reach ${e.endpoint}: ${a instanceof Error?a.message:String(a)}`,O.FAILURE,"Check your network, or pass --endpoint if this workspace is self-hosted.")}let s;try{s=await i.json()}catch{throw new v(`Non-JSON response from ${r} (HTTP ${i.status}).`,O.FAILURE)}if(!i.ok||s.ok===!1){let a=typeof s.error=="string"?s.error:`http_${i.status}`,m=typeof s.message=="string"?s.message:typeof s.detail=="string"?s.detail:void 0;throw new W(a,i.status,m)}return s.data}function _(e){let t=Object.entries(e).filter(([,o])=>o!==void 0&&o!=="").map(([o,n])=>`${encodeURIComponent(o)}=${encodeURIComponent(String(n))}`);return t.length?`?${t.join("&")}`:""}async function re(e){return u(e,"GET","/me")}function ie(e,t,o,n){let r=e?.entityLabels?.[t]?.[o];return typeof r=="string"&&r.trim()?r:n}async function Le(e){try{return await re(e)}catch{return null}}function Fe(e){return[...e].length}function se(e,t){if(t<=0)return"";let o=[...e];return o.length<=t?e:t===1?"\u2026":`${o.slice(0,t-1).join("")}\u2026`}function x(e,t){if(e.length===0)return"";let o=e.map(i=>t.map(s=>{let a=s.value(i)??"";return s.max?se(a,s.max):a})),n=t.map((i,s)=>Math.max(Fe(i.header),...o.map(a=>Fe(a[s])))),r=i=>i.map((s,a)=>a===i.length-1?s:s.padEnd(n[a]+2)).join("").replace(/\s+$/,"");return[r(t.map(i=>i.header)),...o.map(r)].join(`
21
+ `)}function p(e){return JSON.stringify(e,null,2)}function H(e,t=Date.now()){let o=Pt(e);if(o===null)return"\u2014";let n=Math.max(0,Math.floor((t-o)/1e3));if(n<60)return`${n}s`;let r=Math.floor(n/60);if(r<60)return`${r}m`;let i=Math.floor(r/60);return i<48?`${i}h`:`${Math.floor(i/24)}d`}function Pt(e){if(typeof e=="number")return Number.isFinite(e)?e:null;if(typeof e=="string"){let t=Date.parse(e);return Number.isNaN(t)?null:t}return null}function g(e,t,o="\u2014"){let n=e[t];return typeof n=="string"&&n.trim()?n:typeof n=="number"&&Number.isFinite(n)?String(n):o}function y(e,t,o="\u2014"){for(let n of t){let r=e[n];if(typeof r=="string"&&r.trim())return r}return o}function A(e){let t=Array.isArray(e)?e:e&&typeof e=="object"?bt(e):null;return t?t.filter(o=>!!o&&typeof o=="object"&&!Array.isArray(o)):[]}function bt(e){for(let t of["items","results","rows","data"])if(Array.isArray(e[t]))return e[t];return null}async function je(e){let t=await Y(e.endpoint),o=await re(t);if(e.json){console.log(p(o));return}console.log(`Company: ${xt(o.companyName??o.companyId)}${o.companyName?ae(` (${o.companyId})`):""}`),console.log(`Endpoint: ${t.endpoint}`),console.log(`Scopes: ${o.scopes.length?o.scopes.join(", "):Tt("none")}`),o.userId&&console.log(`User: ${o.userId}`);let n=await b();console.log(n?ae(`Key tail: \u2026${n.apiKey.slice(-4)} \xB7 saved ${new Date(n.savedAt).toLocaleString()}`):ae("Key from: ANYFORGE_API_TOKEN (no saved credential file)"))}import{green as Ue,dim as B,yellow as kt}from"kleur/colors";async function De(){let e=await b(),t=await Ae(),{reverted:o}=await Ie({endpoint:e?.endpoint??null});if(console.log(t?Ue("\u2714")+" Local credentials cleared.":kt("No credentials were saved on this machine.")),o.length){console.log(Ue("\u2714")+" Reverted patched configs:");for(let n of o)console.log(` ${B("\u2022")} ${n}`);console.log(B(" Your previous Claude Code login (if any) is restored."))}else console.log(B(" No AnyForge config patches found to revert."));console.log(B(" Revoke the key server-side at /settings \u2192 API keys."))}import{green as It,red as J,yellow as _t,dim as X}from"kleur/colors";async function Ke(e){let t=await b();if(!t){console.log(_t("Not logged in.")+" Run "+X("npx @anyforge/control init")+" first.");return}let o=e.endpoint||t.endpoint;console.log(`Endpoint: ${o}`),console.log(`Company: ${t.companyId}`);let n={model:"claude-3-5-haiku-20241022",max_tokens:1,messages:[{role:"user",content:"Hi"}]},r;try{r=await fetch(`${o}/v1/anthropic/v1/messages`,{method:"POST",headers:{"Content-Type":"application/json","anthropic-version":"2023-06-01",Authorization:`Bearer ${t.apiKey}`},body:JSON.stringify(n)})}catch(i){console.log(J("\u2717 Proxy unreachable: ")+i.message),process.exit(1)}r.status===401&&(console.log(J("\u2717 API key rejected.")+" Re-run "+X("npx @anyforge/control init")+"."),process.exit(1)),r.status===502&&(console.log(J("\u2717 No BYOK Anthropic key configured for your company.")),console.log(X(" Add one at ")+o+X("/settings \u2192 API keys.")),process.exit(1)),r.ok||(console.log(J(`\u2717 Proxy returned ${r.status}: `)+(await r.text()).slice(0,200)),process.exit(1)),console.log(It("\u2714 Proxy reachable, key valid, BYOK configured."))}import{spawn as St}from"node:child_process";import{readFile as $t}from"node:fs/promises";import{red as N,green as le,yellow as Nt,dim as L,bold as Me}from"kleur/colors";async function Ge(e){let t=await Lt(e.patchFile);t.trim()||(process.stderr.write(N(`Empty patch \u2014 nothing to apply.
22
+ `)),process.exit(1));let o=Ft(t);process.stderr.write(L(`Patch: ${o.files} ${o.files===1?"file":"files"}, `)+le(`+${o.added}`)+" "+N(`-${o.removed}`)+`
23
+ `);let n=await ce(["apply","--check"],t);if(n.code!==0){if(e.threeWay){let i=await ce(["apply","--3way"],t);i.code!==0&&(process.stderr.write(N(`3-way apply failed:
24
+ `)+i.stderr+`
25
+ `),process.exit(1)),process.stderr.write(Nt(`\u2713 Applied via 3-way merge \u2014 review conflicts before committing.
26
+ `));return}process.stderr.write(N(`Patch does not apply cleanly:
27
+ `)+n.stderr+`
28
+ `),process.stderr.write(L("Try ")+Me("--3way")+L(` to attempt a 3-way merge against the index.
29
+ `)),process.exit(1)}if(e.dryRun){process.stderr.write(le(`\u2713 Patch would apply cleanly.
30
+ `));return}let r=await ce(["apply"],t);r.code!==0&&(process.stderr.write(N(`git apply failed:
31
+ `)+r.stderr+`
32
+ `),process.exit(1)),process.stderr.write(le(`\u2713 Applied.
33
+ `)),o.files>0&&process.stderr.write(L(" Run ")+Me("git diff --stat")+L(` to review.
34
+ `))}async function Lt(e){if(e)return await $t(e,"utf8");let t=[];for await(let o of process.stdin)t.push(o);return Buffer.concat(t).toString("utf8")}function ce(e,t){return new Promise((o,n)=>{let r=St("git",e,{stdio:["pipe","pipe","pipe"]}),i="",s="";r.stdout.on("data",a=>{i+=a.toString()}),r.stderr.on("data",a=>{s+=a.toString()}),r.on("error",n),r.on("close",a=>o({code:a??0,stdout:i,stderr:s})),r.stdin.write(t),r.stdin.end()})}function Ft(e){let t=0,o=0,n=0;for(let r of e.split(`
35
+ `))if(r.startsWith("diff --git"))t+=1;else{if(r.startsWith("+++")||r.startsWith("---"))continue;r.startsWith("+")?o+=1:r.startsWith("-")&&(n+=1)}return t===0&&(o>0||n>0)&&(t=(e.match(/^\+\+\+ /gm)??[]).length),{files:t,added:o,removed:n}}import{bold as jt,dim as de,green as Ut,red as Dt}from"kleur/colors";async function We(e){let t=await u(e.creds,"GET",`/hil${_({limit:e.limit})}`);if(e.json){console.log(p(t));return}let o=A(t);if(o.length===0){console.log(de("No approvals pending."));return}console.log(x(o,[{header:"ID",value:n=>g(n,"id")},{header:"CREW",value:n=>g(n,"crewId"),max:24},{header:"AGE",value:n=>H(n.requestedAt)},{header:"SUMMARY",value:n=>y(n,["planSummary","summary","title","description"]),max:60}])),console.log(de(`
36
+ ${o.length} pending \xB7 approve with `)+jt("anyforge approve <id>"))}async function ue(e){if(!e.approvalId)throw new d(`${e.decision==="APPROVED"?"approve":"reject"} requires an approval id.`,"List them with `anyforge approvals`.");let t=await u(e.creds,"POST",`/hil/${encodeURIComponent(e.approvalId)}`,{decision:e.decision,...e.notes?{notes:e.notes}:{}});if(e.json){console.log(p(t));return}let o=e.decision==="APPROVED"?Ut("Approved"):Dt("Rejected");console.log(`${o} ${e.approvalId}`);let n=t&&typeof t=="object"?t.signature:void 0;typeof n=="string"&&n&&console.log(de(`Audit signature: ${n}`))}import{bold as Kt,dim as pe}from"kleur/colors";async function Ye(e){let t=await u(e.creds,"GET",`/crews${_({status:e.status,activeOnly:e.activeOnly,limit:e.limit})}`);if(e.json){console.log(p(t));return}let o=A(t);if(o.length===0){console.log(pe(e.activeOnly?"No active crews.":"No crews."));return}console.log(x(o,[{header:"ID",value:n=>g(n,"id")},{header:"STATUS",value:n=>g(n,"status")},{header:"AGE",value:n=>H(n.createdAt??n.startedAt)},{header:"NAME",value:n=>y(n,["name","title","taskTitle","workItemTitle"]),max:52}]))}async function He(e){if(!e.crewId)throw new d("crew requires a crew id.","List them with `anyforge crews`.");let t=await u(e.creds,"GET",`/crews/${encodeURIComponent(e.crewId)}${_({messages:e.messages})}`);if(e.json){console.log(p(t));return}let o=t?.crew&&typeof t.crew=="object"?t.crew:t;console.log(`${Kt(y(o,["name","title","taskTitle"],g(o,"id")))}`),console.log(`Status: ${g(o,"status")}`),console.log(`Repo: ${y(o,["targetGithubRepo","repoFullName","repo"])}`);let n=g(o,"url","");n&&console.log(`Console: ${n}`);let r=A(t?.messages);if(r.length>0){console.log(pe(`
37
+ Last ${r.length} messages:`));for(let i of r){let s=y(i,["role","author","agent","kind"],"?"),a=y(i,["content","body","text","message"],"");console.log(`${pe(`[${s}]`)} ${se(a.replace(/\s+/g," "),160)}`)}}}async function fe(e){if(!e.crewId)throw new d(`${e.action} requires a crew id.`,"List them with `anyforge crews`.");let t=await u(e.creds,"POST",`/crews/${encodeURIComponent(e.crewId)}/${e.action}`);if(e.json){console.log(p(t));return}console.log(`${e.action==="halt"?"Halted":"Resumed"} ${e.crewId}`)}import{dim as q}from"kleur/colors";var Je=100;function Be(e){let t=e&&typeof e=="object"?e:{};return{total:typeof t.total=="number"?t.total:null,hasMore:t.hasMore===!0,cursor:typeof t.nextCursor=="string"?t.nextCursor:null}}async function Mt(e){let t=s=>`/work${_({status:e.status,type:e.type,priority:e.priority,initiative:e.initiative,program:e.program,search:e.search,assignee:e.assignee,includeArchived:e.includeArchived,limit:e.limit,cursor:s})}`,o=await u(e.creds,"GET",t()),n=A(o),r=Be(o),i=1;if(!e.all)return{rows:n,total:r.total,hasMore:r.hasMore,raw:o,pagesFetched:i};for(;r.hasMore&&r.cursor&&i<Je;){let s=await u(e.creds,"GET",t(r.cursor));n.push(...A(s)),r=Be(s),i+=1}return{rows:n,total:r.total,hasMore:r.hasMore,raw:o,pagesFetched:i}}async function Xe(e){let t=await Mt(e);if(e.json){console.log(p(e.all?{items:t.rows,total:t.total,hasMore:t.hasMore,nextCursor:null}:t.raw));return}let o=t.rows,n=await Le(e.creds),r=ie(n,"work_item","plural","Work items");if(o.length===0){console.log(q(`No ${r.toLowerCase()} match that filter.`));return}console.log(x(o,[{header:"ID",value:m=>g(m,"id")},{header:"PRI",value:m=>g(m,"priority","")},{header:"TYPE",value:m=>g(m,"type","")},{header:"STATUS",value:m=>g(m,"status")},{header:"TITLE",value:m=>y(m,["title","name","summary"]),max:58}]));let i=(o.length===1?ie(n,"work_item","singular","work item"):r).toLowerCase(),s=t.total!==null&&t.total!==o.length?`${o.length} of ${t.total} ${r.toLowerCase()}`:`${o.length} ${i}`,a=t.hasMore?e.all?q(` \u2014 stopped after ${Je} pages; narrow the filter`):q(" \u2014 pass --all for the rest"):"";console.log(`${q(`
38
+ ${s}`)}${a}`)}import{bold as Gt,dim as V}from"kleur/colors";async function qe(e){let t=await u(e.creds,"GET","/workflows");if(e.json){console.log(p(t));return}let o=A(t);if(o.length===0){console.log(V("No workflows defined."));return}console.log(x(o,[{header:"ID",value:n=>g(n,"id")},{header:"STATUS",value:n=>y(n,["status","state"],"")},{header:"NAME",value:n=>y(n,["name","title"]),max:58}])),console.log(V(`
39
+ Dispatch with `)+Gt("anyforge run <id>"))}async function Ve(e){if(!e.workflowId)throw new d("run requires a workflow id.","List them with `anyforge workflows`.");let t;if(e.input!==void 0){try{t=JSON.parse(e.input)}catch(r){throw new d(`--input is not valid JSON: ${r instanceof Error?r.message:String(r)}`,`Example: --input '{"repo":"acme/web"}'`)}if(!t||typeof t!="object"||Array.isArray(t))throw new d("--input must be a JSON object.",`Example: --input '{"repo":"acme/web"}'`)}let o=await u(e.creds,"POST",`/workflows/${encodeURIComponent(e.workflowId)}/run`,t===void 0?void 0:{inputs:t});if(e.json){console.log(p(o));return}let n=y(o??{},["id","runId"],"");console.log(`Queued run ${n||V("(id not returned)")}`),console.log(V("Runs pause at gate steps for an operator decision."))}import{dim as Wt}from"kleur/colors";async function ze(e){let t=e.question?.trim();if(!t)throw new d("ask requires a question.",'Example: anyforge ask "which initiatives slipped this cycle?"');!e.json&&process.stderr.isTTY&&process.stderr.write(Wt(`Asking the Oracle (this can take a few minutes)\u2026
40
+ `));let o=await u(e.creds,"POST","/oracle",{question:t,...e.context?{context:e.context}:{}});if(e.json){console.log(p(o));return}let n=o&&typeof o=="object"?["answer","text","content","response"].map(r=>o[r]).find(r=>typeof r=="string"&&r.trim()):void 0;console.log(typeof n=="string"?n:p(o))}var ot="https://crew.anyforge.ai",Jt=new Set(["endpoint","limit","status","type","priority","initiative","program","search","assignee","notes","input","context","messages"]),Xt=new Set(["help","version","json","active","archived","3way","dry-run","force"]);function qt(e){let t={positional:[],flags:{}};for(let o=0;o<e.length;o+=1){let n=e[o];if(!n.startsWith("-")){t.command?t.positional.push(n):t.command=n;continue}let r=n==="-h"?"help":n==="-V"?"version":n==="-f"?"force":n.replace(/^--?/,""),[i,s]=r.includes("=")?[r.slice(0,r.indexOf("=")),r.slice(r.indexOf("=")+1)]:[r,void 0];if(Jt.has(i)){let a=s??e[++o];if(a===void 0)throw new d(`--${i} needs a value.`);t.flags[i]=a;continue}if(Xt.has(i)){t.flags[i]=s===void 0?!0:s!=="false";continue}throw new d(`Unknown option: ${n}`,"Run `anyforge --help` for the command list.")}return t}function z(e,t){if(e===void 0||typeof e=="boolean")return;let o=Number(e);if(!Number.isFinite(o)||o<=0)throw new d(`--${t} must be a positive number, got "${e}".`);return o}function h(e){return typeof e=="string"?e:void 0}function ge(e){return e===!0?!0:void 0}function Vt(){console.log(`
41
+ ${P("AnyForge Control")} \u2014 governed LLM proxy, and a scriptable AnyForge client.
42
+
43
+ ${P("USAGE")}
55
44
  npx @anyforge/control <command> [options]
56
45
 
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
46
+ ${P("SETUP")}
47
+ init Authorise this device and patch local agent configs
48
+ whoami Show the company, scopes and endpoint this key acts as
49
+ status Check connectivity to the AnyForge proxy
50
+ logout Clear the saved API key and revert patched configs
51
+ apply-patch [file] Apply a unified diff (file or stdin) via git apply
52
+
53
+ ${P("APPROVALS")} ${F("(the gate that blocks a human)")}
54
+ approvals List HIL approvals awaiting a decision
55
+ approve <id> Approve one, optionally with --notes
56
+ reject <id> Reject one, optionally with --notes
57
+
58
+ ${P("CREWS")}
59
+ crews List crews (--active for running/paused only)
60
+ crew <id> Show one crew, its status and recent output
61
+ halt <id> Stop a crew
62
+ resume <id> Resume a paused or halted crew
63
+
64
+ ${P("WORK & WORKFLOWS")}
65
+ work List work items (--status, --type, --priority, --search).
66
+ Paged: one page by default, --all walks every page
67
+ workflows List operator workflows
68
+ run <id> Dispatch a workflow run, with optional --input JSON
69
+ ask <question> Ask the Oracle one question
70
+
71
+ ${P("OPTIONS")}
72
+ --json Print the raw API payload instead of a table
73
+ --endpoint <url> Override the base URL (default: ${F(ot)})
74
+ --limit <n> Page size on list commands (work: 1-200, default 50)
75
+ --all work: follow the cursor until the backlog is exhausted
76
+ --notes <text> approve/reject: reviewer notes, recorded in the audit trail
77
+ --input <json> run: workflow inputs, e.g. '{"repo":"acme/web"}'
78
+ --context <text> ask: background for the Oracle
79
+ --help, -h Show this message
80
+ --version, -V Print CLI version
63
81
 
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
82
+ ${P("EXIT CODES")}
83
+ 0 ok 1 failure 2 usage 3 not authenticated 4 API error
70
84
 
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();
85
+ ${P("EXAMPLES")}
86
+ anyforge approvals
87
+ anyforge approve hil_123 --notes "ship it"
88
+ anyforge crews --active --json | jq -r '.[].id'
89
+ anyforge work --status IN_PROGRESS --priority P0
90
+ anyforge work --program prog_123 --all --json | jq -r '.items[].id'
91
+ anyforge run wf_nightly --input '{"repo":"acme/web"}'
92
+ anyforge ask "which initiatives slipped this cycle?"
93
+ `)}async function zt(e,t,o,n){let r=t.flags.json===!0,[i]=t.positional;switch(e){case"init":return $e({endpoint:o,force:t.flags.force===!0});case"logout":return De();case"status":return Ke({endpoint:o});case"whoami":return je({endpoint:n,json:r});case"apply-patch":return Ge({patchFile:i,threeWay:t.flags["3way"]===!0,dryRun:t.flags["dry-run"]===!0});case"proxy":console.log(Bt("Local proxy mode is coming in a future release.")),console.log(F(" Until then, your agents talk directly to the AnyForge proxy."));return}let s=await Y(n);switch(e){case"approvals":return We({creds:s,json:r,limit:z(t.flags.limit,"limit")});case"approve":return ue({creds:s,json:r,approvalId:i,decision:"APPROVED",notes:h(t.flags.notes)});case"reject":return ue({creds:s,json:r,approvalId:i,decision:"REJECTED",notes:h(t.flags.notes)});case"crews":return Ye({creds:s,json:r,status:h(t.flags.status),activeOnly:ge(t.flags.active),limit:z(t.flags.limit,"limit")});case"crew":return He({creds:s,json:r,crewId:i,messages:z(t.flags.messages,"messages")});case"halt":return fe({creds:s,json:r,crewId:i,action:"halt"});case"resume":return fe({creds:s,json:r,crewId:i,action:"resume"});case"work":return Xe({creds:s,json:r,status:h(t.flags.status),type:h(t.flags.type),priority:h(t.flags.priority),initiative:h(t.flags.initiative),program:h(t.flags.program),search:h(t.flags.search),assignee:h(t.flags.assignee),includeArchived:ge(t.flags.archived),limit:z(t.flags.limit,"limit"),all:ge(t.flags.all)});case"workflows":return qe({creds:s,json:r});case"run":return Ve({creds:s,json:r,workflowId:i,input:h(t.flags.input)});case"ask":return ze({creds:s,json:r,question:t.positional.join(" "),context:h(t.flags.context)});default:throw new d(`Unknown command: ${e}`,"Run `anyforge --help` for the command list.")}}async function Qt(){let e;try{e=qt(process.argv.slice(2))}catch(n){tt(n);return}if(e.flags.version===!0){let n=await Promise.resolve().then(()=>(Ze(),Qe));console.log(n.default.version);return}if(e.flags.help===!0||!e.command){Vt();return}let t=h(e.flags.endpoint)??process.env.ANYFORGE_ENDPOINT,o=t??ot;try{await zt(e.command,e,o,t)}catch(n){tt(n)}}function tt(e){e instanceof v&&(process.stderr.write(et("Error: ")+e.message+`
94
+ `),e.hint?process.stderr.write(F(e.hint)+`
95
+ `):e instanceof d&&process.stderr.write(F("Run `anyforge --help`.")+`
96
+ `),process.exit(e.exitCode)),process.stderr.write(et("Error: ")+(e instanceof Error?e.message:String(e))+`
97
+ `),process.exit(O.FAILURE)}Qt();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anyforge/control",
3
- "version": "0.2.3",
4
- "description": "AnyForge Control CLI — governed LLM proxy for Claude Code, Cursor, Cline, and any agent that talks to OpenAI- or Anthropic-shape APIs.",
3
+ "version": "0.3.0",
4
+ "description": "AnyForge Control CLI \u2014 governed LLM proxy for any agent, and a scriptable client for AnyForge approvals, crews, work items and workflows.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://anyforge.ai",
7
7
  "repository": {
@@ -23,14 +23,15 @@
23
23
  "README.md"
24
24
  ],
25
25
  "scripts": {
26
- "build": "tsc && chmod +x dist/cli.js",
26
+ "build": "esbuild src/cli.ts --bundle --minify --platform=node --format=esm --outfile=dist/cli.js --packages=external && chmod +x dist/cli.js",
27
+ "type-check": "tsc --noEmit",
27
28
  "dev": "tsx src/cli.ts",
28
29
  "prepublishOnly": "npm run build",
29
30
  "publish:beta": "npm publish --access public --tag beta",
30
31
  "publish:stable": "npm publish --access public"
31
32
  },
32
33
  "engines": {
33
- "node": ">=18"
34
+ "node": ">=22"
34
35
  },
35
36
  "dependencies": {
36
37
  "kleur": "^4.1.5",
@@ -38,9 +39,10 @@
38
39
  "prompts": "^2.4.2"
39
40
  },
40
41
  "devDependencies": {
41
- "@types/node": "^20.11.0",
42
+ "@types/node": "^22.20.0",
42
43
  "@types/prompts": "^2.4.9",
44
+ "esbuild": "^0.25.0",
43
45
  "tsx": "^4.7.0",
44
- "typescript": "^5.5.3"
46
+ "typescript": "^7.0.2"
45
47
  }
46
48
  }
@@ -1,92 +0,0 @@
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
- }
@@ -1,234 +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 open from 'open';
6
- import prompts from 'prompts';
7
- import { green, dim, bold, yellow, cyan } from 'kleur/colors';
8
- import { saveCredentials } from '../lib/credentials.js';
9
- import { detectAndPatchConfigs } from '../lib/configPatch.js';
10
- const POLL_INTERVAL_MS = 2_000;
11
- const POLL_TIMEOUT_MS = 15 * 60 * 1000;
12
- export async function runInit(opts) {
13
- const { endpoint } = opts;
14
- // eslint-disable-next-line no-console
15
- console.log(bold('AnyForge Control — device authorisation'));
16
- // eslint-disable-next-line no-console
17
- console.log(dim(` endpoint: ${endpoint}`));
18
- // 1. Issue a one-time code. We detect the git remote up-front when
19
- // running in a repo so it can be attached to the API key later.
20
- // Detection happens before scope-pick so the server has it
21
- // regardless of which scope the operator chooses.
22
- const cwd0 = process.cwd();
23
- const gitRoot0 = findGitRoot(cwd0);
24
- const earlyRepo = gitRoot0 ? detectGitRemoteRepo(gitRoot0) : null;
25
- const initRes = await fetch(`${endpoint}/api/cli/init`, {
26
- method: 'POST',
27
- headers: { 'content-type': 'application/json' },
28
- body: JSON.stringify({ repo: earlyRepo }),
29
- });
30
- const init = (await initRes.json());
31
- if (!init.ok || !init.data) {
32
- throw new Error(`Could not start the authorisation flow: ${init.error ?? 'unknown error'}`);
33
- }
34
- const { code, authUrl, pollUrl } = init.data;
35
- // 2. Open the browser. Some shells (CI, headless) can't open one;
36
- // the URL also prints so the user can copy it manually.
37
- // eslint-disable-next-line no-console
38
- console.log();
39
- // eslint-disable-next-line no-console
40
- console.log(`Open this URL in your browser to authorise:`);
41
- // eslint-disable-next-line no-console
42
- console.log(` ${cyan(authUrl)}`);
43
- // eslint-disable-next-line no-console
44
- console.log(dim(` Code: ${code}`));
45
- // eslint-disable-next-line no-console
46
- console.log();
47
- try {
48
- await open(authUrl);
49
- }
50
- catch {
51
- // Headless / CI — the printed URL is fine.
52
- }
53
- // 3. Poll. Keep the message live so the user sees we're alive.
54
- const credentials = await pollForToken({ pollUrl, endpoint });
55
- // 4. Save credentials with restrictive perms.
56
- await saveCredentials(credentials);
57
- // eslint-disable-next-line no-console
58
- console.log(green('✔') + ' API key saved to ' + dim('~/.anyforge/credentials'));
59
- // 5. Pick a scope. The repo option only shows when we're sitting
60
- // in a git working tree root — that matches the documented
61
- // contract and avoids accidentally writing settings into a
62
- // subdirectory the operator didn't mean to target.
63
- const cwd = process.cwd();
64
- const repoRoot = findGitRoot(cwd);
65
- const scope = await pickScope(repoRoot);
66
- // 6. Patch the chosen target. apiKey is inlined into the env map
67
- // so Claude Code can read it without a shell restart.
68
- const patches = await detectAndPatchConfigs({
69
- endpoint,
70
- apiKey: credentials.apiKey,
71
- scope,
72
- repoRoot,
73
- });
74
- if (patches.length === 0) {
75
- // eslint-disable-next-line no-console
76
- console.log(yellow('No agent configs were patched.'));
77
- // eslint-disable-next-line no-console
78
- console.log(dim(' Set ANTHROPIC_BASE_URL=' + endpoint + '/v1/anthropic and'));
79
- // eslint-disable-next-line no-console
80
- console.log(dim(' ANTHROPIC_AUTH_TOKEN=<your AnyForge key> manually if needed.'));
81
- }
82
- else {
83
- summarisePatches(patches);
84
- }
85
- // eslint-disable-next-line no-console
86
- console.log();
87
- if (await hasClaudeCodeLogin()) {
88
- // Claude Code shows an "Auth conflict" warning when both a /login
89
- // credential and an env-token are set — even though the token wins
90
- // by precedence (#2 vs #6), the warning is cosmetic-but-loud. The
91
- // only way to silence it is to stay logged out: /logout once, then
92
- // skip the login prompt on the next launch.
93
- // eslint-disable-next-line no-console
94
- console.log(yellow(' ⚠ Existing Claude Code login detected (~/.claude/.credentials.json).'));
95
- // eslint-disable-next-line no-console
96
- console.log(dim(' Claude Code will work, but it will warn that both a token and a'));
97
- // eslint-disable-next-line no-console
98
- console.log(dim(' /login key are set. To silence the warning:'));
99
- // eslint-disable-next-line no-console
100
- console.log(dim(' 1) run `claude /logout`,'));
101
- // eslint-disable-next-line no-console
102
- console.log(dim(' 2) on the next launch, skip the login prompt (Esc / Ctrl+C).'));
103
- // eslint-disable-next-line no-console
104
- console.log(dim(' The AnyForge token in settings.local.json is enough on its own.'));
105
- }
106
- if (scope === 'repo') {
107
- // eslint-disable-next-line no-console
108
- console.log(dim(' Re-run this command in another repo to patch it too,'));
109
- // eslint-disable-next-line no-console
110
- console.log(dim(' or run from any directory and choose "global" to install system-wide.'));
111
- }
112
- // eslint-disable-next-line no-console
113
- console.log(bold('Done.') + ' Visit ' + cyan(`${endpoint}/usage`) + ' to see your traffic.');
114
- }
115
- async function pollForToken(args) {
116
- const start = Date.now();
117
- // eslint-disable-next-line no-console
118
- process.stdout.write('Waiting for browser authorisation… ');
119
- let dotCount = 0;
120
- while (Date.now() - start < POLL_TIMEOUT_MS) {
121
- await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
122
- process.stdout.write('.');
123
- dotCount += 1;
124
- if (dotCount > 40) {
125
- process.stdout.write('\n');
126
- dotCount = 0;
127
- }
128
- const res = await fetch(args.pollUrl);
129
- const body = (await res.json());
130
- if (body.status === 'authorized' && body.data) {
131
- process.stdout.write(' ' + green('done') + '\n');
132
- return {
133
- apiKey: body.data.apiKey,
134
- companyId: body.data.companyId,
135
- endpoint: args.endpoint,
136
- savedAt: new Date().toISOString(),
137
- };
138
- }
139
- if (body.status === 'expired' || body.status === 'not_found') {
140
- process.stdout.write('\n');
141
- throw new Error(`Authorisation ${body.status}. Re-run \`anyforge init\`.`);
142
- }
143
- }
144
- throw new Error('Authorisation timed out (15 min). Re-run `anyforge init`.');
145
- }
146
- async function hasClaudeCodeLogin() {
147
- // Claude Code's docs document the credentials file at
148
- // ~/.claude/.credentials.json (Linux/Windows) or the macOS Keychain.
149
- // We can only see the file on Linux/Windows. On macOS the keychain
150
- // is invisible to us, so this check is best-effort — false negatives
151
- // are fine since the warning is just a hint.
152
- try {
153
- await fs.access(join(homedir(), '.claude', '.credentials.json'));
154
- return true;
155
- }
156
- catch {
157
- return false;
158
- }
159
- }
160
- function findGitRoot(cwd) {
161
- // Works from any subdirectory — git walks up the tree to find the root.
162
- try {
163
- return execFileSync('git', ['rev-parse', '--show-toplevel'], {
164
- cwd,
165
- encoding: 'utf8',
166
- stdio: ['pipe', 'pipe', 'pipe'],
167
- }).trim();
168
- }
169
- catch {
170
- return null;
171
- }
172
- }
173
- async function pickScope(repoRoot) {
174
- if (!repoRoot) {
175
- // eslint-disable-next-line no-console
176
- console.log(dim(' Not a git repo root — installing globally.'));
177
- // eslint-disable-next-line no-console
178
- console.log(dim(' Run this from a repo root if you want a per-repo install instead.'));
179
- return 'global';
180
- }
181
- const choice = await prompts({
182
- type: 'select',
183
- name: 'scope',
184
- message: 'Where should AnyForge patch Claude Code?',
185
- choices: [
186
- { title: `This repo only (${repoRoot}/.claude/settings.local.json + .env)`, value: 'repo' },
187
- { title: 'Globally (~/.claude/settings.json)', value: 'global' },
188
- ],
189
- initial: 0,
190
- });
191
- // prompts returns {} on cancel — default to repo since they were given the choice.
192
- return choice.scope ?? 'repo';
193
- }
194
- function summarisePatches(patches) {
195
- // eslint-disable-next-line no-console
196
- console.log(green('✔') + ' Patched ' + patches.length + ' agent config' + (patches.length === 1 ? '' : 's') + ':');
197
- for (const p of patches) {
198
- // eslint-disable-next-line no-console
199
- console.log(` ${dim('•')} ${p.label} ${dim('(' + p.path + ')')}`);
200
- }
201
- }
202
- /**
203
- * Read the `origin` remote URL and normalise it to `owner/repo`.
204
- * Handles both SSH (`git@github.com:owner/repo.git`) and HTTPS forms.
205
- * Returns null when there is no remote, git is unavailable, or the
206
- * URL can't be parsed into an owner/repo pair.
207
- *
208
- * Uses execFileSync (not exec/execSync) so no shell interpolation
209
- * occurs — the args array is passed directly to the git binary.
210
- */
211
- function detectGitRemoteRepo(repoRoot) {
212
- try {
213
- const raw = execFileSync('git', ['remote', 'get-url', 'origin'], {
214
- cwd: repoRoot,
215
- encoding: 'utf8',
216
- stdio: ['pipe', 'pipe', 'pipe'],
217
- }).trim();
218
- // SSH: git@github.com:owner/repo.git
219
- const sshMatch = raw.match(/^git@[\w.-]+:([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
220
- if (sshMatch)
221
- return sshMatch[1];
222
- // HTTPS: https://github.com/owner/repo[.git]
223
- const httpsMatch = raw.match(/^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
224
- if (httpsMatch)
225
- return httpsMatch[1];
226
- return null;
227
- }
228
- catch {
229
- return null;
230
- }
231
- }
232
- // Re-exported so future commands (e.g. status) can reuse the prompt
233
- // shape without duplicating the question text.
234
- export { prompts };
@@ -1,21 +0,0 @@
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
- }
@@ -1,58 +0,0 @@
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
- }
@@ -1,23 +0,0 @@
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
- }
@@ -1,216 +0,0 @@
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, yellow } from 'kleur/colors';
6
- export async function detectAndPatchConfigs(opts) {
7
- const summary = [];
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) {
20
- const found = await detector.detect();
21
- if (!found)
22
- continue;
23
- const proceed = await prompts({
24
- type: 'confirm',
25
- name: 'go',
26
- message: `Patch ${detector.label} via shell rc (${dim(found.path)}) ?`,
27
- initial: true,
28
- });
29
- if (!proceed.go)
30
- continue;
31
- try {
32
- await detector.patch(found, opts);
33
- summary.push({ label: detector.label, path: found.path });
34
- // eslint-disable-next-line no-console
35
- console.log(green(' ✔') + ' ' + detector.label);
36
- }
37
- catch (err) {
38
- // eslint-disable-next-line no-console
39
- console.warn(' ⚠ Skipped ' + detector.label + ': ' + err.message);
40
- }
41
- }
42
- return summary;
43
- }
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_AUTH_TOKEN = opts.apiKey;
69
- // Strip stale ANTHROPIC_API_KEY from prior installs — keeping
70
- // both set re-triggers the auth-conflict warning even though
71
- // ANTHROPIC_AUTH_TOKEN is what we actually want Claude Code
72
- // to use.
73
- delete env.ANTHROPIC_API_KEY;
74
- json.env = env;
75
- await fs.writeFile(path, JSON.stringify(json, null, 2) + '\n');
76
- // eslint-disable-next-line no-console
77
- console.log(green(' ✔') + ' Claude Code');
78
- return {
79
- label: opts.scope === 'repo' ? 'Claude Code (repo)' : 'Claude Code (global)',
80
- path,
81
- };
82
- }
83
- async function patchRepoEnv(opts) {
84
- if (!opts.repoRoot)
85
- return null;
86
- const envPath = join(opts.repoRoot, '.env');
87
- const proceed = await prompts({
88
- type: 'confirm',
89
- name: 'go',
90
- message: `Write AnyForge env block to ${dim(envPath)} ?`,
91
- initial: true,
92
- });
93
- if (!proceed.go)
94
- return null;
95
- const SENTINEL_BEGIN = '# === AnyForge Control — added by `anyforge init` ===';
96
- const SENTINEL_END = '# === end AnyForge Control ===';
97
- const block = [
98
- SENTINEL_BEGIN,
99
- `ANYFORGE_API_KEY=${opts.apiKey}`,
100
- `ANTHROPIC_BASE_URL=${opts.endpoint}/v1/anthropic`,
101
- `ANTHROPIC_AUTH_TOKEN=${opts.apiKey}`,
102
- `OPENAI_BASE_URL=${opts.endpoint}/v1/openai`,
103
- `OPENAI_API_KEY=${opts.apiKey}`,
104
- SENTINEL_END,
105
- '',
106
- ].join('\n');
107
- let cur = '';
108
- try {
109
- cur = await fs.readFile(envPath, 'utf8');
110
- }
111
- catch { /* missing is fine */ }
112
- let next;
113
- if (cur.includes(SENTINEL_BEGIN)) {
114
- // Idempotent: re-running replaces the existing block in place.
115
- next = cur.replace(new RegExp(`${SENTINEL_BEGIN}[\\s\\S]*?${SENTINEL_END}\\n?`), block);
116
- }
117
- else if (cur.length === 0) {
118
- next = block;
119
- }
120
- else {
121
- next = cur.endsWith('\n') ? `${cur}${block}` : `${cur}\n${block}`;
122
- }
123
- if (next !== cur)
124
- await fs.writeFile(envPath, next);
125
- // The .env contains a live API key — flag it if it isn't gitignored.
126
- await warnIfNotGitignored(opts.repoRoot);
127
- // eslint-disable-next-line no-console
128
- console.log(green(' ✔') + ' .env');
129
- return { label: '.env (repo)', path: envPath };
130
- }
131
- async function warnIfNotGitignored(repoRoot) {
132
- const gi = join(repoRoot, '.gitignore');
133
- try {
134
- const txt = await fs.readFile(gi, 'utf8');
135
- const lines = txt.split('\n').map(l => l.trim());
136
- const ignored = lines.some(l => l === '.env' || l === '/.env' || l === '*.env');
137
- if (!ignored) {
138
- // eslint-disable-next-line no-console
139
- console.log(yellow(' ⚠ .env is not in .gitignore — add it before committing.'));
140
- }
141
- }
142
- catch {
143
- // eslint-disable-next-line no-console
144
- console.log(yellow(' ⚠ No .gitignore found — make sure .env is excluded before committing.'));
145
- }
146
- }
147
- // Optional best-effort detectors for tools that don't expose a config
148
- // file we can patch directly. They just append exports to the user's
149
- // shell rc; the API key is inlined so a brand-new shell picks it up
150
- // without depending on jq or the credentials file being readable.
151
- const OPTIONAL_GLOBAL_DETECTORS = [
152
- {
153
- label: 'Cursor (shell hint)',
154
- async detect() {
155
- const path = join(homedir(), '.cursor');
156
- const exists = await fileExists(path);
157
- return exists ? { path: '.cursor (shell hint)' } : null;
158
- },
159
- async patch(_found, opts) {
160
- await ensureShellRcExports(opts);
161
- },
162
- },
163
- {
164
- label: 'Cline / Continue (shell hint)',
165
- async detect() {
166
- const a = await fileExists(join(homedir(), '.cline'));
167
- const b = await fileExists(join(homedir(), '.continue'));
168
- return a || b ? { path: '~/.cline or ~/.continue (shell hint)' } : null;
169
- },
170
- async patch(_found, opts) {
171
- await ensureShellRcExports(opts);
172
- },
173
- },
174
- ];
175
- async function ensureShellRcExports(opts) {
176
- // Idempotent: re-running the CLI shouldn't append duplicate
177
- // exports. We tag our block with a sentinel comment so we can
178
- // detect + replace cleanly on subsequent runs.
179
- const SENTINEL_BEGIN = '# === AnyForge Control — added by `anyforge init` ===';
180
- const SENTINEL_END = '# === end AnyForge Control ===';
181
- const block = [
182
- SENTINEL_BEGIN,
183
- `export ANYFORGE_API_KEY="${opts.apiKey}"`,
184
- `export ANTHROPIC_BASE_URL="${opts.endpoint}/v1/anthropic"`,
185
- `export ANTHROPIC_AUTH_TOKEN="${opts.apiKey}"`,
186
- `export OPENAI_BASE_URL="${opts.endpoint}/v1/openai"`,
187
- `export OPENAI_API_KEY="${opts.apiKey}"`,
188
- `unset ANTHROPIC_API_KEY 2>/dev/null || true`,
189
- SENTINEL_END,
190
- '',
191
- ].join('\n');
192
- for (const rc of ['.zshrc', '.bashrc']) {
193
- const path = join(homedir(), rc);
194
- if (!(await fileExists(path)))
195
- continue;
196
- const cur = await fs.readFile(path, 'utf8');
197
- let next;
198
- if (cur.includes(SENTINEL_BEGIN)) {
199
- next = cur.replace(new RegExp(`${SENTINEL_BEGIN}[\\s\\S]*?${SENTINEL_END}\\n?`), block);
200
- }
201
- else {
202
- next = cur.endsWith('\n') ? `${cur}\n${block}` : `${cur}\n\n${block}`;
203
- }
204
- if (next !== cur)
205
- await fs.writeFile(path, next);
206
- }
207
- }
208
- async function fileExists(p) {
209
- try {
210
- await fs.access(p);
211
- return true;
212
- }
213
- catch {
214
- return false;
215
- }
216
- }
@@ -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 };