@ashulab/agent-forge 0.1.0 → 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 +33 -46
- package/dist/launcher.cjs +265 -66
- package/package.json +1 -1
- package/schema/agents.example.json +0 -1
- package/schema/agents.schema.json +7 -3
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# Agent Forge
|
|
2
2
|
|
|
3
|
-
A small CLI for launching coding agents (`claude`, `codex`, `antigravity`) with a
|
|
4
|
-
**GitHub App identity** and a provider CLI chosen at runtime.
|
|
3
|
+
A small CLI for launching coding agents (`claude`, `codex`, `antigravity`) with a **GitHub App identity** and a provider CLI chosen at runtime.
|
|
5
4
|
|
|
6
5
|
On each run the launcher:
|
|
7
6
|
|
|
@@ -9,18 +8,15 @@ On each run the launcher:
|
|
|
9
8
|
- generates a GitHub App installation token
|
|
10
9
|
- detects installed provider CLIs on your PATH
|
|
11
10
|
- asks which provider to use (or takes it from a flag)
|
|
12
|
-
- starts the provider with the GitHub token and a per-agent git identity injected
|
|
13
|
-
|
|
14
|
-
- writes the agent's `systemPrompt` into the provider's startup memory file
|
|
15
|
-
(`CLAUDE.md` / `AGENTS.md`) so subagents inherit the identity
|
|
11
|
+
- starts the provider with the GitHub token and a per-agent git identity injected into the environment
|
|
12
|
+
- writes the agent's `systemPrompt` into the provider's startup memory file (`CLAUDE.md` / `AGENTS.md`) so subagents inherit the identity
|
|
16
13
|
|
|
17
14
|
Provider auth stays in the provider CLI — the launcher never handles provider API keys.
|
|
18
15
|
|
|
19
16
|
## Requirements
|
|
20
17
|
|
|
21
18
|
- Node.js 24+
|
|
22
|
-
- a GitHub App with an installation on the target account/org —
|
|
23
|
-
[how to create one](docs/github-app.md)
|
|
19
|
+
- a GitHub App with an installation on the target account/org — [how to create one](docs/github-app.md)
|
|
24
20
|
- the App private key stored locally (e.g. in `~/.ssh`)
|
|
25
21
|
- one of these provider CLIs on your PATH: `claude`, `codex`, `agy` (antigravity)
|
|
26
22
|
|
|
@@ -38,22 +34,15 @@ pnpm dlx @ashulab/agent-forge --help
|
|
|
38
34
|
|
|
39
35
|
## Configuration
|
|
40
36
|
|
|
41
|
-
Your agents live in a single machine-global registry — you launch the tool from
|
|
42
|
-
inside each target repo, but the identities are not per-repo. The launcher reads
|
|
43
|
-
`agents.json` from the first of these that exists:
|
|
37
|
+
Your agents live in a single machine-global registry — you launch the tool from inside each target repo, but the identities are not per-repo. The launcher reads `agents.json` from the first of these that exists:
|
|
44
38
|
|
|
45
39
|
1. `$AGENT_FORGE_CONFIG`
|
|
46
40
|
2. `~/.config/agent-forge/agents.json` — the global registry (`agent-forge add` writes here)
|
|
47
41
|
3. `./agents.json` — only if it already exists
|
|
48
42
|
|
|
49
|
-
The easiest path is `agent-forge add` (see below), which creates the global
|
|
50
|
-
file for you. Do **not** drop `agents.json` into a project you run the agent in:
|
|
51
|
-
it holds real App / installation IDs and private-key paths and must stay out of
|
|
52
|
-
version control.
|
|
43
|
+
The easiest path is `agent-forge add` (see below), which creates the global file for you. Do **not** drop `agents.json` into a project you run the agent in: it holds real App / installation IDs and private-key paths and must stay out of version control.
|
|
53
44
|
|
|
54
|
-
The shape, for reference — the `"$schema"` line points at the published schema so
|
|
55
|
-
any editor that understands JSON Schema gives you field descriptions,
|
|
56
|
-
required-field checks and typo detection:
|
|
45
|
+
The shape, for reference — the `"$schema"` line points at the published schema so any editor that understands JSON Schema gives you field descriptions, required-field checks and typo detection:
|
|
57
46
|
|
|
58
47
|
```json
|
|
59
48
|
{
|
|
@@ -63,7 +52,6 @@ required-field checks and typo detection:
|
|
|
63
52
|
"name": "ops-agent",
|
|
64
53
|
"label": "Ops Agent",
|
|
65
54
|
"appId": "123456",
|
|
66
|
-
"installationId": "987654",
|
|
67
55
|
"botName": "ops-agent",
|
|
68
56
|
"privateKeyPath": "~/.ssh/ops-agent.private-key.pem",
|
|
69
57
|
"systemPrompt": "You are Ops Agent, a senior DevOps engineer. Prefer safe, minimal changes and explain operational risks clearly."
|
|
@@ -72,28 +60,38 @@ required-field checks and typo detection:
|
|
|
72
60
|
}
|
|
73
61
|
```
|
|
74
62
|
|
|
75
|
-
-
|
|
76
|
-
|
|
77
|
-
|
|
63
|
+
- The **installation id is not stored** — it is resolved on each run, in order: the owner of the repo you launch in, then `account`, then the App's sole installation. So the same agent works across every org the App is installed on, and it survives an uninstall/reinstall (which rotates the id).
|
|
64
|
+
- Add `"account": "<org-or-user login>"` only when you run outside a matching repo and the App has more than one installation.
|
|
65
|
+
- `installationId` is still accepted as an explicit override — set it to pin a specific id or skip the lookup (offline, or in a git credential helper).
|
|
66
|
+
- `botId` is optional. When omitted it is resolved from the GitHub API using `botName` so the git author email links commits to the bot. Set it explicitly only to skip that lookup (e.g. offline).
|
|
78
67
|
- Do not put provider-specific config here — providers are auto-detected from PATH.
|
|
79
68
|
|
|
80
69
|
### Least-privilege tokens (optional)
|
|
81
70
|
|
|
82
|
-
By default the installation token carries the full installation scope. Narrow it
|
|
83
|
-
per agent with either or both of:
|
|
71
|
+
By default the installation token carries the full installation scope. Narrow it per agent with either or both of:
|
|
84
72
|
|
|
85
73
|
```json
|
|
86
74
|
{
|
|
87
75
|
"repositories": ["some-repo", "another-repo"],
|
|
88
|
-
"permissions": { "contents": "
|
|
76
|
+
"permissions": { "contents": "read", "pull_requests": "write" }
|
|
89
77
|
}
|
|
90
78
|
```
|
|
91
79
|
|
|
80
|
+
**`permissions` is a complete allowlist, not a patch.** GitHub grants *only* the keys you list and drops everything else — even permissions the GitHub App itself has. If you set `permissions` at all, you must list every scope the agent needs. Common cases:
|
|
81
|
+
|
|
82
|
+
| The agent needs to… | Minimum `permissions` |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| Push commits / open PRs | `{ "contents": "write", "pull_requests": "write" }` |
|
|
85
|
+
| Review PRs (incl. inline comments) | `{ "contents": "read", "pull_requests": "write" }` |
|
|
86
|
+
| Read-only (clone, read issues) | `{ "contents": "read" }` |
|
|
87
|
+
|
|
88
|
+
Omitting the `permissions` key entirely is the safe default — the token then carries whatever the App grants. `repositories` works the same way: list it and the token can only touch those repos.
|
|
89
|
+
|
|
90
|
+
On launch, `agent-forge` prints the scope GitHub actually granted (`scope` row in the summary, alongside the `account` the token is for); `agent-forge token --agent <name>` prints it to stderr. If a call fails with `403 Resource not accessible by integration`, check that row first.
|
|
91
|
+
|
|
92
92
|
## Add an agent
|
|
93
93
|
|
|
94
|
-
You need a GitHub App first — its **App ID**, a **private key**, and an
|
|
95
|
-
**installation**. If you don't have one yet, follow
|
|
96
|
-
[docs/github-app.md](docs/github-app.md).
|
|
94
|
+
You need a GitHub App first — its **App ID**, a **private key**, and an **installation**. If you don't have one yet, follow [docs/github-app.md](docs/github-app.md).
|
|
97
95
|
|
|
98
96
|
### Guided (recommended)
|
|
99
97
|
|
|
@@ -101,19 +99,11 @@ You need a GitHub App first — its **App ID**, a **private key**, and an
|
|
|
101
99
|
agent-forge add
|
|
102
100
|
```
|
|
103
101
|
|
|
104
|
-
You provide the **GitHub App ID** and the **path to its private key**. The wizard
|
|
105
|
-
derives the rest from the GitHub API — slug (`botName`), bot user id, and the
|
|
106
|
-
installation (auto-selected when there is only one) — asks for a label and an
|
|
107
|
-
optional system prompt, mints a test token to confirm it works, and writes the
|
|
108
|
-
entry. It writes to the resolved config path — the global registry
|
|
109
|
-
(`~/.config/agent-forge/agents.json`) unless `$AGENT_FORGE_CONFIG` or an
|
|
110
|
-
existing `./agents.json` redirects it.
|
|
102
|
+
You provide the **GitHub App ID** and the **path to its private key**. The wizard derives the rest from the GitHub API — slug (`botName`), bot user id, and the account (asked only when the App is installed on more than one) — asks for a label and an optional system prompt, mints a test token to confirm it works, and writes the entry. It writes to the resolved config path — the global registry (`~/.config/agent-forge/agents.json`) unless `$AGENT_FORGE_CONFIG` or an existing `./agents.json` redirects it.
|
|
111
103
|
|
|
112
104
|
### Manual
|
|
113
105
|
|
|
114
|
-
Copy one agent object inside the `agents` array and set unique values for `name`,
|
|
115
|
-
`label`, `appId`, `installationId`, `botName`, `privateKeyPath`. Make sure the
|
|
116
|
-
private key file exists and is readable.
|
|
106
|
+
Copy one agent object inside the `agents` array and set unique values for `name`, `label`, `appId`, `botName`, `privateKeyPath` — plus `account` if the App has more than one installation. Make sure the private key file exists and is readable.
|
|
117
107
|
|
|
118
108
|
## Launch
|
|
119
109
|
|
|
@@ -134,21 +124,16 @@ Other commands:
|
|
|
134
124
|
|
|
135
125
|
```bash
|
|
136
126
|
agent-forge --list
|
|
127
|
+
agent-forge --version
|
|
137
128
|
agent-forge --help
|
|
138
129
|
```
|
|
139
130
|
|
|
140
131
|
### Provider-specific prompt behavior
|
|
141
132
|
|
|
142
133
|
- `claude`: passed via `--append-system-prompt` (lands in the real system prompt)
|
|
143
|
-
- `codex` / `antigravity` (`agy`): no CLI flag — identity comes from `AGENTS.md`
|
|
144
|
-
only (`agy --prompt` is headless `--print`; a codex positional arg is a fake
|
|
145
|
-
first user turn)
|
|
134
|
+
- `codex` / `antigravity` (`agy`): no CLI flag — identity comes from `AGENTS.md` only (`agy --prompt` is headless `--print`; a codex positional arg is a fake first user turn)
|
|
146
135
|
|
|
147
|
-
The launcher also writes `systemPrompt` into the provider's startup memory file
|
|
148
|
-
in the working directory (`CLAUDE.md` for claude, `AGENTS.md` for codex and
|
|
149
|
-
antigravity), inside a managed `agent-forge:identity` block. This keeps the
|
|
150
|
-
identity in place for subagents the provider spawns, not just its main thread.
|
|
151
|
-
The block is rewritten on each run and safe to keep in version control.
|
|
136
|
+
The launcher also writes `systemPrompt` into the provider's startup memory file in the working directory (`CLAUDE.md` for claude, `AGENTS.md` for codex and antigravity), inside a managed `agent-forge:identity` block. This keeps the identity in place for subagents the provider spawns, not just its main thread. The block is rewritten on each run and safe to keep in version control.
|
|
152
137
|
|
|
153
138
|
## Token refresh
|
|
154
139
|
|
|
@@ -165,6 +150,8 @@ git config credential.https://github.com.helper \
|
|
|
165
150
|
'!f() { test "$1" = get && echo username=x-access-token && echo "password=$(agent-forge token --agent ops-agent)"; }; f'
|
|
166
151
|
```
|
|
167
152
|
|
|
153
|
+
Resolution still works here — the repo you push from is the one used. Setting `installationId` explicitly just skips the lookup on every push.
|
|
154
|
+
|
|
168
155
|
## Contributing
|
|
169
156
|
|
|
170
157
|
See [CONTRIBUTING.md](.github/CONTRIBUTING.md) and [AGENTS.md](./AGENTS.md).
|
package/dist/launcher.cjs
CHANGED
|
@@ -4015,9 +4015,9 @@ var require_jsonwebtoken = __commonJS({
|
|
|
4015
4015
|
});
|
|
4016
4016
|
|
|
4017
4017
|
// src/launcher.js
|
|
4018
|
-
var
|
|
4018
|
+
var import_node_child_process3 = require("node:child_process");
|
|
4019
4019
|
var import_node_path4 = require("node:path");
|
|
4020
|
-
var
|
|
4020
|
+
var import_node_util4 = require("node:util");
|
|
4021
4021
|
|
|
4022
4022
|
// node_modules/.pnpm/@clack+core@1.4.3/node_modules/@clack/core/dist/index.mjs
|
|
4023
4023
|
var import_node_util = require("node:util");
|
|
@@ -5219,7 +5219,7 @@ ${r2}
|
|
|
5219
5219
|
var import_node_fs = require("node:fs");
|
|
5220
5220
|
var import_node_os = require("node:os");
|
|
5221
5221
|
var import_node_path = require("node:path");
|
|
5222
|
-
var REQUIRED_FIELDS = ["name", "appId", "
|
|
5222
|
+
var REQUIRED_FIELDS = ["name", "appId", "privateKeyPath"];
|
|
5223
5223
|
var SCHEMA_URL = "https://raw.githubusercontent.com/AshuLab/agent-forge/main/schema/agents.schema.json";
|
|
5224
5224
|
var CONFIG_HOME = process.env.XDG_CONFIG_HOME || (0, import_node_path.join)((0, import_node_os.homedir)(), ".config");
|
|
5225
5225
|
var GLOBAL_CONFIG = (0, import_node_path.join)(CONFIG_HOME, "agent-forge", "agents.json");
|
|
@@ -5270,25 +5270,31 @@ function validateAgents(agents) {
|
|
|
5270
5270
|
seen.add(agent.name);
|
|
5271
5271
|
});
|
|
5272
5272
|
}
|
|
5273
|
-
function
|
|
5273
|
+
function readAgentsOrEmpty() {
|
|
5274
5274
|
const path = resolveConfigPath();
|
|
5275
|
-
if (!(0, import_node_fs.existsSync)(path))
|
|
5276
|
-
throw new Error(
|
|
5277
|
-
`No agents.json found. Run "agent-forge add" to create one at ${GLOBAL_CONFIG}, or point AGENT_FORGE_CONFIG at an existing file.`
|
|
5278
|
-
);
|
|
5279
|
-
}
|
|
5275
|
+
if (!(0, import_node_fs.existsSync)(path)) return [];
|
|
5280
5276
|
let parsed;
|
|
5281
5277
|
try {
|
|
5282
5278
|
parsed = JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
|
|
5283
5279
|
} catch (error) {
|
|
5284
5280
|
throw new Error(`agents.json is not valid JSON: ${error.message}`);
|
|
5285
5281
|
}
|
|
5286
|
-
if (!Array.isArray(parsed.agents) || parsed.agents.length === 0)
|
|
5287
|
-
throw new Error("agents.json: no agents defined");
|
|
5288
|
-
}
|
|
5282
|
+
if (!Array.isArray(parsed.agents) || parsed.agents.length === 0) return [];
|
|
5289
5283
|
validateAgents(parsed.agents);
|
|
5290
5284
|
return parsed.agents;
|
|
5291
5285
|
}
|
|
5286
|
+
function readAgents() {
|
|
5287
|
+
const agents = readAgentsOrEmpty();
|
|
5288
|
+
if (agents.length === 0) {
|
|
5289
|
+
if (!(0, import_node_fs.existsSync)(resolveConfigPath())) {
|
|
5290
|
+
throw new Error(
|
|
5291
|
+
`No agents.json found. Run "agent-forge add" to create one at ${GLOBAL_CONFIG}, or point AGENT_FORGE_CONFIG at an existing file.`
|
|
5292
|
+
);
|
|
5293
|
+
}
|
|
5294
|
+
throw new Error("agents.json: no agents defined");
|
|
5295
|
+
}
|
|
5296
|
+
return agents;
|
|
5297
|
+
}
|
|
5292
5298
|
function listAgents() {
|
|
5293
5299
|
const agents = readAgents();
|
|
5294
5300
|
const rows = agents.map(
|
|
@@ -5298,6 +5304,7 @@ function listAgents() {
|
|
|
5298
5304
|
}
|
|
5299
5305
|
|
|
5300
5306
|
// src/github.js
|
|
5307
|
+
var import_node_child_process = require("node:child_process");
|
|
5301
5308
|
var import_node_fs2 = require("node:fs");
|
|
5302
5309
|
var import_node_os2 = require("node:os");
|
|
5303
5310
|
var import_node_path2 = require("node:path");
|
|
@@ -5349,42 +5356,108 @@ async function resolveBotId(agent) {
|
|
|
5349
5356
|
}
|
|
5350
5357
|
return String(data.id);
|
|
5351
5358
|
}
|
|
5359
|
+
async function listInstallations(jwtToken) {
|
|
5360
|
+
const items = await githubApi("/app/installations", { jwtToken });
|
|
5361
|
+
return items.map((item) => ({
|
|
5362
|
+
id: String(item.id),
|
|
5363
|
+
account: item.account?.login || item.account?.slug || "unknown"
|
|
5364
|
+
}));
|
|
5365
|
+
}
|
|
5352
5366
|
async function fetchAppMetadata(appId, privateKeyPath) {
|
|
5353
5367
|
const privateKey = readPrivateKey(privateKeyPath, `app ${appId}`);
|
|
5354
5368
|
const jwtToken = signAppJwt(appId, privateKey);
|
|
5355
5369
|
const app = await githubApi("/app", { jwtToken });
|
|
5356
|
-
const installations = await githubApi("/app/installations", { jwtToken });
|
|
5357
5370
|
return {
|
|
5358
5371
|
slug: app.slug,
|
|
5359
5372
|
name: app.name || app.slug,
|
|
5360
|
-
installations:
|
|
5361
|
-
id: String(item.id),
|
|
5362
|
-
account: item.account?.login || item.account?.slug || "unknown"
|
|
5363
|
-
}))
|
|
5373
|
+
installations: await listInstallations(jwtToken)
|
|
5364
5374
|
};
|
|
5365
5375
|
}
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5376
|
+
function pickInstallation(installations, agent) {
|
|
5377
|
+
if (installations.length === 0) {
|
|
5378
|
+
throw new Error(`GitHub App ${agent.appId} has no installations. Install it on an account or org first.`);
|
|
5379
|
+
}
|
|
5380
|
+
if (agent.account) {
|
|
5381
|
+
const want = String(agent.account).toLowerCase();
|
|
5382
|
+
const match = installations.find((item) => item.account.toLowerCase() === want);
|
|
5383
|
+
if (!match) {
|
|
5384
|
+
throw new Error(
|
|
5385
|
+
`GitHub App ${agent.appId} is not installed on "${agent.account}". Installed on: ${installations.map((item) => item.account).join(", ")}.`
|
|
5386
|
+
);
|
|
5387
|
+
}
|
|
5388
|
+
return match.id;
|
|
5389
|
+
}
|
|
5390
|
+
if (installations.length === 1) return installations[0].id;
|
|
5391
|
+
throw new Error(
|
|
5392
|
+
`GitHub App ${agent.appId} is installed on ${installations.length} accounts (${installations.map((item) => item.account).join(", ")}). Run inside a repo under one of them, or add "account" to agent "${agent.name}".`
|
|
5393
|
+
);
|
|
5394
|
+
}
|
|
5395
|
+
function parseGithubRemote(url) {
|
|
5396
|
+
const match = String(url).trim().match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/);
|
|
5397
|
+
return match ? { owner: match[1], repo: match[2] } : null;
|
|
5398
|
+
}
|
|
5399
|
+
function currentRepoSlug() {
|
|
5400
|
+
try {
|
|
5401
|
+
const url = (0, import_node_child_process.execFileSync)("git", ["remote", "get-url", "origin"], {
|
|
5402
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
5403
|
+
}).toString();
|
|
5404
|
+
return parseGithubRemote(url);
|
|
5405
|
+
} catch {
|
|
5406
|
+
return null;
|
|
5407
|
+
}
|
|
5408
|
+
}
|
|
5409
|
+
async function installationForRepo(slug, jwtToken) {
|
|
5410
|
+
try {
|
|
5411
|
+
const data = await githubApi(`/repos/${slug.owner}/${slug.repo}/installation`, { jwtToken });
|
|
5412
|
+
return { id: String(data.id), account: data.account?.login || data.account?.slug || null };
|
|
5413
|
+
} catch {
|
|
5414
|
+
return null;
|
|
5415
|
+
}
|
|
5416
|
+
}
|
|
5417
|
+
async function resolveInstallation(agent, jwtToken) {
|
|
5418
|
+
if (agent.installationId) return { id: String(agent.installationId), account: null };
|
|
5419
|
+
const slug = currentRepoSlug();
|
|
5420
|
+
if (slug) {
|
|
5421
|
+
const hit = await installationForRepo(slug, jwtToken);
|
|
5422
|
+
if (hit) return hit;
|
|
5423
|
+
}
|
|
5424
|
+
const installations = await listInstallations(jwtToken);
|
|
5425
|
+
const id = pickInstallation(installations, agent);
|
|
5426
|
+
return { id, account: installations.find((item) => item.id === id)?.account ?? null };
|
|
5427
|
+
}
|
|
5428
|
+
function buildTokenScope(agent) {
|
|
5369
5429
|
const scope = {};
|
|
5370
5430
|
if (Array.isArray(agent.repositories)) scope.repositories = agent.repositories;
|
|
5371
5431
|
if (agent.permissions && typeof agent.permissions === "object") scope.permissions = agent.permissions;
|
|
5372
|
-
|
|
5432
|
+
return scope;
|
|
5433
|
+
}
|
|
5434
|
+
async function generateGithubAppToken(agent) {
|
|
5435
|
+
const privateKey = readPrivateKey(agent.privateKeyPath, agent.name || agent.appId);
|
|
5436
|
+
const jwtToken = signAppJwt(agent.appId, privateKey);
|
|
5437
|
+
const { id: installationId, account } = await resolveInstallation(agent, jwtToken);
|
|
5438
|
+
const data = await githubApi(`/app/installations/${installationId}/access_tokens`, {
|
|
5373
5439
|
jwtToken,
|
|
5374
|
-
body:
|
|
5440
|
+
body: buildTokenScope(agent)
|
|
5375
5441
|
});
|
|
5376
|
-
return
|
|
5442
|
+
return {
|
|
5443
|
+
token: data.token,
|
|
5444
|
+
account,
|
|
5445
|
+
expiresAt: data.expires_at || null,
|
|
5446
|
+
// What GitHub actually granted, after both the App's config and our scoping.
|
|
5447
|
+
permissions: data.permissions || {},
|
|
5448
|
+
repositorySelection: data.repository_selection || "all"
|
|
5449
|
+
};
|
|
5377
5450
|
}
|
|
5378
5451
|
|
|
5379
5452
|
// src/providers.js
|
|
5380
|
-
var
|
|
5453
|
+
var import_node_child_process2 = require("node:child_process");
|
|
5381
5454
|
var PROVIDER_OPTIONS = [
|
|
5382
5455
|
{ value: "claude", label: "Claude", defaultCommand: "claude" },
|
|
5383
5456
|
{ value: "codex", label: "Codex", defaultCommand: "codex" },
|
|
5384
5457
|
{ value: "antigravity", label: "Antigravity", defaultCommand: "agy", aliases: ["antigravity", "agy"] }
|
|
5385
5458
|
];
|
|
5386
5459
|
function isCommandInstalled(command) {
|
|
5387
|
-
const result = (0,
|
|
5460
|
+
const result = (0, import_node_child_process2.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
|
|
5388
5461
|
return result.status === 0;
|
|
5389
5462
|
}
|
|
5390
5463
|
function detectAvailableProviders() {
|
|
@@ -5446,10 +5519,97 @@ function syncIdentityFile(providerName, agent, cwd = process.cwd()) {
|
|
|
5446
5519
|
return file;
|
|
5447
5520
|
}
|
|
5448
5521
|
|
|
5522
|
+
// src/format.js
|
|
5523
|
+
function formatScope(permissions, full = false) {
|
|
5524
|
+
const entries = Object.entries(permissions);
|
|
5525
|
+
if (entries.length === 0) return "full installation scope";
|
|
5526
|
+
if (full) return entries.map(([name, level]) => `${name}:${level}`).join(" ");
|
|
5527
|
+
const writes = entries.filter(([, level]) => level !== "read").map(([name]) => name);
|
|
5528
|
+
if (writes.length === 0) return `read-only (${entries.length})`;
|
|
5529
|
+
const reads = entries.length - writes.length;
|
|
5530
|
+
return `write: ${writes.join(", ")}${reads ? ` \xB7 +${reads} read` : ""}`;
|
|
5531
|
+
}
|
|
5532
|
+
function formatExpiry(iso) {
|
|
5533
|
+
const ms = new Date(iso).getTime() - Date.now();
|
|
5534
|
+
if (!Number.isFinite(ms)) return String(iso);
|
|
5535
|
+
const minutes = Math.round(ms / 6e4);
|
|
5536
|
+
return minutes > 0 ? `~${minutes}m` : "expired";
|
|
5537
|
+
}
|
|
5538
|
+
|
|
5449
5539
|
// src/cli.js
|
|
5540
|
+
var import_node_util3 = require("node:util");
|
|
5541
|
+
|
|
5542
|
+
// package.json
|
|
5543
|
+
var package_default = {
|
|
5544
|
+
name: "@ashulab/agent-forge",
|
|
5545
|
+
version: "0.3.0",
|
|
5546
|
+
description: "Give claude/codex/antigravity their own GitHub identity: a bot account, a scoped token minted per run, git attribution to match",
|
|
5547
|
+
license: "MIT",
|
|
5548
|
+
author: "Diego Ghersi <ghersidl@gmail.com>",
|
|
5549
|
+
type: "module",
|
|
5550
|
+
bin: {
|
|
5551
|
+
"agent-forge": "dist/launcher.cjs"
|
|
5552
|
+
},
|
|
5553
|
+
main: "dist/launcher.cjs",
|
|
5554
|
+
files: [
|
|
5555
|
+
"dist",
|
|
5556
|
+
"schema",
|
|
5557
|
+
"LICENSE",
|
|
5558
|
+
"README.md"
|
|
5559
|
+
],
|
|
5560
|
+
engines: {
|
|
5561
|
+
node: ">=24"
|
|
5562
|
+
},
|
|
5563
|
+
packageManager: "pnpm@11.20.0",
|
|
5564
|
+
repository: {
|
|
5565
|
+
type: "git",
|
|
5566
|
+
url: "git+https://github.com/AshuLab/agent-forge.git"
|
|
5567
|
+
},
|
|
5568
|
+
bugs: {
|
|
5569
|
+
url: "https://github.com/AshuLab/agent-forge/issues"
|
|
5570
|
+
},
|
|
5571
|
+
homepage: "https://github.com/AshuLab/agent-forge#readme",
|
|
5572
|
+
keywords: [
|
|
5573
|
+
"github-app",
|
|
5574
|
+
"cli",
|
|
5575
|
+
"coding-agent",
|
|
5576
|
+
"claude",
|
|
5577
|
+
"codex",
|
|
5578
|
+
"antigravity",
|
|
5579
|
+
"installation-token",
|
|
5580
|
+
"git-identity"
|
|
5581
|
+
],
|
|
5582
|
+
publishConfig: {
|
|
5583
|
+
access: "public"
|
|
5584
|
+
},
|
|
5585
|
+
scripts: {
|
|
5586
|
+
dev: "node src/launcher.js",
|
|
5587
|
+
test: "node --test",
|
|
5588
|
+
start: "node dist/launcher.cjs",
|
|
5589
|
+
build: "esbuild src/launcher.js --bundle --platform=node --format=cjs --target=node24 --outfile=dist/launcher.cjs --banner:js='#!/usr/bin/env node' && chmod +x dist/launcher.cjs",
|
|
5590
|
+
prepublishOnly: "pnpm build && pnpm test"
|
|
5591
|
+
},
|
|
5592
|
+
dependencies: {
|
|
5593
|
+
"@clack/prompts": "^1.7.0",
|
|
5594
|
+
jsonwebtoken: "^9.0.3"
|
|
5595
|
+
},
|
|
5596
|
+
devDependencies: {
|
|
5597
|
+
esbuild: "^0.28.2"
|
|
5598
|
+
}
|
|
5599
|
+
};
|
|
5600
|
+
|
|
5601
|
+
// src/cli.js
|
|
5602
|
+
var LOGO = `
|
|
5603
|
+
\u2584\u2584 \u2584\u2584\u2584 \u2584\u2584\u2584 \u2584\u2584 \u2584\u2584\u2584 \u2584\u2584\u2584 \u2584\u2584 \u2584\u2584 \u2584\u2584\u2584 \u2584\u2584\u2584
|
|
5604
|
+
\u2588\u2584\u2588 \u2588 \u2584 \u2588\u2584 \u2588 \u2588 \u2588 \u2588\u2584 \u2588 \u2588 \u2588\u2584\u2580 \u2588 \u2584 \u2588\u2584
|
|
5605
|
+
\u2588 \u2588 \u2580\u2584\u2588 \u2588\u2584\u2584 \u2588 \u2588 \u2588 \u2588 \u2580\u2584\u2580 \u2588 \u2588 \u2580\u2584\u2588 \u2588\u2584\u2584
|
|
5606
|
+
`;
|
|
5607
|
+
function printVersion() {
|
|
5608
|
+
console.log(package_default.version);
|
|
5609
|
+
}
|
|
5450
5610
|
function printHelp() {
|
|
5451
5611
|
console.log(`
|
|
5452
|
-
Agent Forge
|
|
5612
|
+
Agent Forge v${package_default.version}
|
|
5453
5613
|
|
|
5454
5614
|
Usage:
|
|
5455
5615
|
agent-forge
|
|
@@ -5457,6 +5617,7 @@ Usage:
|
|
|
5457
5617
|
agent-forge add guided setup for a new agent
|
|
5458
5618
|
agent-forge token --agent <name> print a fresh GitHub App token (for refresh)
|
|
5459
5619
|
agent-forge --list
|
|
5620
|
+
agent-forge --version
|
|
5460
5621
|
agent-forge --help
|
|
5461
5622
|
|
|
5462
5623
|
Requirements:
|
|
@@ -5470,15 +5631,18 @@ Config resolution (first match wins):
|
|
|
5470
5631
|
- ./agents.json (only if it already exists)
|
|
5471
5632
|
`);
|
|
5472
5633
|
}
|
|
5634
|
+
function bail(message = "Cancelled") {
|
|
5635
|
+
cancel(`\u2716 ${message}`);
|
|
5636
|
+
process.exit(0);
|
|
5637
|
+
}
|
|
5473
5638
|
function keep(value) {
|
|
5474
5639
|
if (isCancel(value)) {
|
|
5475
|
-
|
|
5476
|
-
process.exit(0);
|
|
5640
|
+
bail();
|
|
5477
5641
|
}
|
|
5478
5642
|
return value;
|
|
5479
5643
|
}
|
|
5480
|
-
async function addAgentWizard() {
|
|
5481
|
-
intro("Add agent");
|
|
5644
|
+
async function addAgentWizard({ embedded = false } = {}) {
|
|
5645
|
+
if (!embedded) intro("Add agent");
|
|
5482
5646
|
const appId = keep(
|
|
5483
5647
|
await text({
|
|
5484
5648
|
message: "GitHub App ID",
|
|
@@ -5500,14 +5664,14 @@ async function addAgentWizard() {
|
|
|
5500
5664
|
if (meta.installations.length === 0) {
|
|
5501
5665
|
throw new Error("This App has no installations. Install it on an account or org first.");
|
|
5502
5666
|
}
|
|
5503
|
-
let
|
|
5667
|
+
let account;
|
|
5504
5668
|
if (meta.installations.length === 1) {
|
|
5505
|
-
note(`${meta.installations[0].account}
|
|
5669
|
+
note(`${meta.installations[0].account} \u2014 resolved automatically each run`, "Installation");
|
|
5506
5670
|
} else {
|
|
5507
|
-
|
|
5671
|
+
account = keep(
|
|
5508
5672
|
await select({
|
|
5509
|
-
message: "
|
|
5510
|
-
options: meta.installations.map((i2) => ({ value: i2.
|
|
5673
|
+
message: "Which account is this agent for?",
|
|
5674
|
+
options: meta.installations.map((i2) => ({ value: i2.account, label: `${i2.account} (${i2.id})` }))
|
|
5511
5675
|
})
|
|
5512
5676
|
);
|
|
5513
5677
|
}
|
|
@@ -5518,51 +5682,66 @@ async function addAgentWizard() {
|
|
|
5518
5682
|
name,
|
|
5519
5683
|
label,
|
|
5520
5684
|
appId,
|
|
5521
|
-
installationId,
|
|
5522
5685
|
botName: meta.slug,
|
|
5523
5686
|
privateKeyPath,
|
|
5687
|
+
...account ? { account } : {},
|
|
5524
5688
|
...systemPrompt ? { systemPrompt } : {}
|
|
5525
5689
|
};
|
|
5526
5690
|
const check = spinner();
|
|
5527
5691
|
check.start("Minting a test token");
|
|
5528
|
-
await generateGithubAppToken(agent);
|
|
5529
|
-
|
|
5692
|
+
const { permissions } = await generateGithubAppToken(agent);
|
|
5693
|
+
const scope = Object.entries(permissions).map(([name2, level]) => `${name2}:${level}`).join(" ");
|
|
5694
|
+
check.stop(scope ? `Test token ok \xB7 ${scope}` : "Test token ok");
|
|
5530
5695
|
const path = addAgent(agent);
|
|
5531
|
-
|
|
5696
|
+
if (embedded) {
|
|
5697
|
+
note(`Wrote ${path}`, "Agent added");
|
|
5698
|
+
} else {
|
|
5699
|
+
outro(`Wrote ${path}`);
|
|
5700
|
+
}
|
|
5701
|
+
return agent.name;
|
|
5532
5702
|
}
|
|
5533
5703
|
async function interactiveSelection(defaults = {}) {
|
|
5534
5704
|
const availableProviders = detectAvailableProviders();
|
|
5535
5705
|
if (availableProviders.length === 0) {
|
|
5536
5706
|
throw new Error("No installed provider was detected. Install claude, codex, or antigravity before running the launcher.");
|
|
5537
5707
|
}
|
|
5538
|
-
let
|
|
5539
|
-
if (
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
})
|
|
5548
|
-
);
|
|
5708
|
+
let agents = readAgentsOrEmpty();
|
|
5709
|
+
if (agents.length === 0 && !defaults.agent) {
|
|
5710
|
+
note("No agents configured yet. Each agent is a GitHub App identity the launcher runs under.", "First run");
|
|
5711
|
+
if (!keep(await confirm({ message: "Add one now?" }))) {
|
|
5712
|
+
outro(`Run ${(0, import_node_util3.styleText)("cyan", "agent-forge add")} when you're ready.`);
|
|
5713
|
+
process.exit(0);
|
|
5714
|
+
}
|
|
5715
|
+
await addAgentWizard({ embedded: true });
|
|
5716
|
+
agents = readAgents();
|
|
5549
5717
|
}
|
|
5550
|
-
const agents = readAgents();
|
|
5551
5718
|
let agentChoice = defaults.agent;
|
|
5552
5719
|
if (agentChoice && !agents.some((agent) => agent.name === agentChoice)) {
|
|
5553
5720
|
throw new Error(`Agent not found: ${agentChoice}`);
|
|
5554
5721
|
}
|
|
5555
5722
|
if (!agentChoice) {
|
|
5556
|
-
agentChoice = keep(
|
|
5723
|
+
agentChoice = agents.length === 1 ? agents[0].name : keep(
|
|
5557
5724
|
await select({
|
|
5558
5725
|
message: "Which agent do you want to launch?",
|
|
5559
5726
|
options: agents.map((agent) => ({ value: agent.name, label: agent.label || agent.name }))
|
|
5560
5727
|
})
|
|
5561
5728
|
);
|
|
5562
5729
|
}
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5730
|
+
let providerChoice = defaults.provider;
|
|
5731
|
+
if (providerChoice && !availableProviders.some((provider) => provider.value === providerChoice)) {
|
|
5732
|
+
throw new Error(`The provider ${providerChoice} is not installed or not available in PATH.`);
|
|
5733
|
+
}
|
|
5734
|
+
if (!providerChoice) {
|
|
5735
|
+
providerChoice = availableProviders.length === 1 ? availableProviders[0].value : keep(
|
|
5736
|
+
await select({
|
|
5737
|
+
message: "Which provider do you want to use?",
|
|
5738
|
+
options: availableProviders.map((provider) => ({ value: provider.value, label: provider.label }))
|
|
5739
|
+
})
|
|
5740
|
+
);
|
|
5741
|
+
}
|
|
5742
|
+
const launchPrompt = `Launch ${(0, import_node_util3.styleText)("green", agentChoice)} with ${(0, import_node_util3.styleText)(["cyan", "bold"], providerChoice)}?`;
|
|
5743
|
+
if (!keep(await confirm({ message: launchPrompt }))) {
|
|
5744
|
+
bail("Cancelled \u2014 nothing launched");
|
|
5566
5745
|
}
|
|
5567
5746
|
return { agentName: agentChoice, providerName: providerChoice };
|
|
5568
5747
|
}
|
|
@@ -5571,6 +5750,7 @@ function parseArgs() {
|
|
|
5571
5750
|
const result = {
|
|
5572
5751
|
command: args[0] && !args[0].startsWith("-") ? args[0] : void 0,
|
|
5573
5752
|
help: false,
|
|
5753
|
+
version: false,
|
|
5574
5754
|
list: false,
|
|
5575
5755
|
agent: void 0,
|
|
5576
5756
|
provider: void 0
|
|
@@ -5580,6 +5760,7 @@ function parseArgs() {
|
|
|
5580
5760
|
const arg = args[i2];
|
|
5581
5761
|
const next = args[i2 + 1];
|
|
5582
5762
|
if (arg === "--help" || arg === "-h") result.help = true;
|
|
5763
|
+
if (arg === "--version" || arg === "-v") result.version = true;
|
|
5583
5764
|
if (arg === "--list" || arg === "-l") result.list = true;
|
|
5584
5765
|
if (arg === "--agent" || arg === "-a") {
|
|
5585
5766
|
result.agent = value(next);
|
|
@@ -5593,7 +5774,8 @@ function parseArgs() {
|
|
|
5593
5774
|
return result;
|
|
5594
5775
|
}
|
|
5595
5776
|
function showIntro() {
|
|
5596
|
-
|
|
5777
|
+
console.log((0, import_node_util3.styleText)("cyan", LOGO));
|
|
5778
|
+
intro(`Agent Forge ${(0, import_node_util3.styleText)("dim", `v${package_default.version}`)}`);
|
|
5597
5779
|
}
|
|
5598
5780
|
|
|
5599
5781
|
// src/launcher.js
|
|
@@ -5619,10 +5801,10 @@ async function launchAgent(agentName, providerName) {
|
|
|
5619
5801
|
const providerInfo = getProviderInfo(providerName, agent);
|
|
5620
5802
|
const s = spinner();
|
|
5621
5803
|
s.start("Minting installation token");
|
|
5622
|
-
const githubToken = await generateGithubAppToken(agent);
|
|
5804
|
+
const { token: githubToken, account, expiresAt, permissions, repositorySelection } = await generateGithubAppToken(agent);
|
|
5623
5805
|
const botId = await resolveBotId(agent);
|
|
5624
5806
|
const gitIdentity = buildGitIdentity(agent, botId);
|
|
5625
|
-
s.stop(`Token ready for ${(0,
|
|
5807
|
+
s.stop(`Token ready for ${(0, import_node_util4.styleText)("green", gitIdentity.GIT_AUTHOR_NAME)}`);
|
|
5626
5808
|
const identityFile = syncIdentityFile(providerName, agent);
|
|
5627
5809
|
const runtimeEnv = {
|
|
5628
5810
|
...process.env,
|
|
@@ -5630,17 +5812,26 @@ async function launchAgent(agentName, providerName) {
|
|
|
5630
5812
|
GH_TOKEN: githubToken,
|
|
5631
5813
|
...gitIdentity
|
|
5632
5814
|
};
|
|
5633
|
-
const
|
|
5815
|
+
const narrowed = Boolean(agent.permissions) || Boolean(agent.repositories);
|
|
5816
|
+
const scopeText = formatScope(permissions, narrowed);
|
|
5817
|
+
const repoSlug = currentRepoSlug();
|
|
5818
|
+
const row = (label, value) => `${(0, import_node_util4.styleText)("dim", label.padEnd(10))}${value}`;
|
|
5634
5819
|
const summary = [
|
|
5635
|
-
row("provider", (0,
|
|
5636
|
-
row("identity", (0,
|
|
5820
|
+
row("provider", (0, import_node_util4.styleText)(["cyan", "bold"], providerName)),
|
|
5821
|
+
row("identity", (0, import_node_util4.styleText)("green", gitIdentity.GIT_AUTHOR_NAME))
|
|
5637
5822
|
];
|
|
5823
|
+
if (account) summary.push(row("account", (0, import_node_util4.styleText)("yellow", account)));
|
|
5824
|
+
if (repoSlug) summary.push(row("repo", (0, import_node_util4.styleText)("dim", `${repoSlug.owner}/${repoSlug.repo}`)));
|
|
5825
|
+
summary.push(
|
|
5826
|
+
row("scope", (0, import_node_util4.styleText)("dim", repositorySelection === "selected" ? `${scopeText} \xB7 selected repos` : scopeText))
|
|
5827
|
+
);
|
|
5828
|
+
if (expiresAt) summary.push(row("expires", (0, import_node_util4.styleText)("dim", formatExpiry(expiresAt))));
|
|
5638
5829
|
if (identityFile) {
|
|
5639
|
-
summary.push(row("memory", (0,
|
|
5830
|
+
summary.push(row("memory", (0, import_node_util4.styleText)("dim", (0, import_node_path4.relative)(process.cwd(), identityFile) || identityFile)));
|
|
5640
5831
|
}
|
|
5641
|
-
note(summary.join("\n"), (0,
|
|
5832
|
+
note(summary.join("\n"), (0, import_node_util4.styleText)("bold", agent.label || agent.name));
|
|
5642
5833
|
const providerArgs = getProviderPromptArgs(providerName, agent);
|
|
5643
|
-
const child = (0,
|
|
5834
|
+
const child = (0, import_node_child_process3.spawn)(providerInfo.command, providerArgs, {
|
|
5644
5835
|
stdio: "inherit",
|
|
5645
5836
|
env: runtimeEnv,
|
|
5646
5837
|
shell: false
|
|
@@ -5652,7 +5843,7 @@ async function launchAgent(agentName, providerName) {
|
|
|
5652
5843
|
cancel(`Could not execute ${providerInfo.command}: ${error.message}`);
|
|
5653
5844
|
process.exit(1);
|
|
5654
5845
|
});
|
|
5655
|
-
outro(`Running ${(0,
|
|
5846
|
+
outro(`Running ${(0, import_node_util4.styleText)("cyan", providerName)}\u2026`);
|
|
5656
5847
|
}
|
|
5657
5848
|
async function main() {
|
|
5658
5849
|
const flags = parseArgs();
|
|
@@ -5660,6 +5851,10 @@ async function main() {
|
|
|
5660
5851
|
printHelp();
|
|
5661
5852
|
return;
|
|
5662
5853
|
}
|
|
5854
|
+
if (flags.version) {
|
|
5855
|
+
printVersion();
|
|
5856
|
+
return;
|
|
5857
|
+
}
|
|
5663
5858
|
if (flags.list) {
|
|
5664
5859
|
listAgents();
|
|
5665
5860
|
return;
|
|
@@ -5672,7 +5867,11 @@ async function main() {
|
|
|
5672
5867
|
if (!flags.agent) {
|
|
5673
5868
|
throw new Error("Usage: agent-forge token --agent <name>");
|
|
5674
5869
|
}
|
|
5675
|
-
|
|
5870
|
+
const minted = await generateGithubAppToken(findAgent(flags.agent));
|
|
5871
|
+
const scope = Object.entries(minted.permissions).map(([name, level]) => `${name}:${level}`).join(" ");
|
|
5872
|
+
if (scope) process.stderr.write(`scope: ${scope}${minted.repositorySelection === "selected" ? " (selected repos)" : ""}
|
|
5873
|
+
`);
|
|
5874
|
+
process.stdout.write(minted.token);
|
|
5676
5875
|
return;
|
|
5677
5876
|
}
|
|
5678
5877
|
showIntro();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashulab/agent-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Give claude/codex/antigravity their own GitHub identity: a bot account, a scoped token minted per run, git attribution to match",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Diego Ghersi <ghersidl@gmail.com>",
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
"name": "ops-agent",
|
|
6
6
|
"label": "Ops Agent",
|
|
7
7
|
"appId": "000000",
|
|
8
|
-
"installationId": "00000000",
|
|
9
8
|
"botName": "ops-agent",
|
|
10
9
|
"privateKeyPath": "~/.ssh/ops-agent.private-key.pem",
|
|
11
10
|
"systemPrompt": "You are Ops Agent, a senior DevOps engineer. Prefer safe, minimal changes and explain operational risks clearly."
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"items": {
|
|
12
12
|
"type": "object",
|
|
13
13
|
"additionalProperties": false,
|
|
14
|
-
"required": ["name", "label", "appId", "
|
|
14
|
+
"required": ["name", "label", "appId", "botName", "privateKeyPath"],
|
|
15
15
|
"properties": {
|
|
16
16
|
"name": {
|
|
17
17
|
"type": "string",
|
|
@@ -25,9 +25,13 @@
|
|
|
25
25
|
"type": "string",
|
|
26
26
|
"description": "GitHub App ID (numeric, stored as a string)"
|
|
27
27
|
},
|
|
28
|
+
"account": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Account/org login where the App is installed. Fallback for installation-id resolution when you run outside a matching repo. Needed only when the App has more than one installation. Resolution order: repo owner, then account, then the sole installation."
|
|
31
|
+
},
|
|
28
32
|
"installationId": {
|
|
29
33
|
"type": "string",
|
|
30
|
-
"description": "
|
|
34
|
+
"description": "Optional override. Normally the installation id is resolved (repo owner, then account, then the sole installation). Set this to pin a specific id or skip the lookup (offline, or in a git credential helper)."
|
|
31
35
|
},
|
|
32
36
|
"botName": {
|
|
33
37
|
"type": "string",
|
|
@@ -61,7 +65,7 @@
|
|
|
61
65
|
"permissions": {
|
|
62
66
|
"type": "object",
|
|
63
67
|
"additionalProperties": { "type": "string" },
|
|
64
|
-
"description": "Least-privilege:
|
|
68
|
+
"description": "Least-privilege: complete allowlist of installation token permissions. GitHub grants ONLY these keys and drops the rest, even ones the App has. Reviewing PRs needs { \"contents\": \"read\", \"pull_requests\": \"write\" }. Omit this key to inherit the App's full grant."
|
|
65
69
|
},
|
|
66
70
|
"providers": {
|
|
67
71
|
"type": "object",
|