@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 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
- into the environment
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
- - `botId` is optional. When omitted it is resolved from the GitHub API using
76
- `botName` so the git author email links commits to the bot. Set it explicitly
77
- only to skip that lookup (e.g. offline).
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": "write", "pull_requests": "write" }
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 import_node_child_process2 = require("node:child_process");
4018
+ var import_node_child_process3 = require("node:child_process");
4019
4019
  var import_node_path4 = require("node:path");
4020
- var import_node_util3 = require("node:util");
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", "installationId", "privateKeyPath"];
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 readAgents() {
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: installations.map((item) => ({
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
- async function generateGithubAppToken(agent) {
5367
- const privateKey = readPrivateKey(agent.privateKeyPath, agent.name || agent.appId);
5368
- const jwtToken = signAppJwt(agent.appId, privateKey);
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
- const data = await githubApi(`/app/installations/${agent.installationId}/access_tokens`, {
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: Object.keys(scope).length > 0 ? scope : {}
5440
+ body: buildTokenScope(agent)
5375
5441
  });
5376
- return data.token;
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 import_node_child_process = require("node:child_process");
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, import_node_child_process.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
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
- cancel("Cancelled.");
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 installationId = meta.installations[0].id;
5667
+ let account;
5504
5668
  if (meta.installations.length === 1) {
5505
- note(`${meta.installations[0].account} (${installationId})`, "Installation");
5669
+ note(`${meta.installations[0].account} \u2014 resolved automatically each run`, "Installation");
5506
5670
  } else {
5507
- installationId = keep(
5671
+ account = keep(
5508
5672
  await select({
5509
- message: "Installation",
5510
- options: meta.installations.map((i2) => ({ value: i2.id, label: `${i2.account} (${i2.id})` }))
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
- check.stop("Test token ok");
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
- outro(`Wrote ${path}`);
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 providerChoice = defaults.provider;
5539
- if (providerChoice && !availableProviders.some((provider) => provider.value === providerChoice)) {
5540
- throw new Error(`The provider ${providerChoice} is not installed or not available in PATH.`);
5541
- }
5542
- if (!providerChoice) {
5543
- providerChoice = keep(
5544
- await select({
5545
- message: "Which provider do you want to use?",
5546
- options: availableProviders.map((provider) => ({ value: provider.value, label: provider.label }))
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
- if (!keep(await confirm({ message: `Launch ${agentChoice} with ${providerChoice}?` }))) {
5564
- cancel("Cancelled.");
5565
- process.exit(0);
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
- intro("Agent Forge");
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, import_node_util3.styleText)("green", gitIdentity.GIT_AUTHOR_NAME)}`);
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 row = (label, value) => `${(0, import_node_util3.styleText)("dim", label.padEnd(10))}${value}`;
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, import_node_util3.styleText)(["cyan", "bold"], providerName)),
5636
- row("identity", (0, import_node_util3.styleText)("green", gitIdentity.GIT_AUTHOR_NAME))
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, import_node_util3.styleText)("dim", (0, import_node_path4.relative)(process.cwd(), identityFile) || identityFile)));
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, import_node_util3.styleText)("bold", agent.label || agent.name));
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, import_node_child_process2.spawn)(providerInfo.command, providerArgs, {
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, import_node_util3.styleText)("cyan", providerName)}\u2026`);
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
- process.stdout.write(await generateGithubAppToken(findAgent(flags.agent)));
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.1.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", "installationId", "botName", "privateKeyPath"],
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": "GitHub App installation ID for the target account/org"
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: limit the installation token permissions, e.g. { \"contents\": \"write\" }"
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",