@ashulab/agent-forge 0.1.0 → 0.2.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 (`token` row in the summary); `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
 
@@ -140,15 +130,9 @@ agent-forge --help
140
130
  ### Provider-specific prompt behavior
141
131
 
142
132
  - `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)
133
+ - `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
134
 
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.
135
+ 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
136
 
153
137
  ## Token refresh
154
138
 
@@ -165,6 +149,8 @@ git config credential.https://github.com.helper \
165
149
  '!f() { test "$1" = get && echo username=x-access-token && echo "password=$(agent-forge token --agent ops-agent)"; }; f'
166
150
  ```
167
151
 
152
+ Resolution still works here — the repo you push from is the one used. Setting `installationId` explicitly just skips the lookup on every push.
153
+
168
154
  ## Contributing
169
155
 
170
156
  See [CONTRIBUTING.md](.github/CONTRIBUTING.md) and [AGENTS.md](./AGENTS.md).
package/dist/launcher.cjs CHANGED
@@ -4015,7 +4015,7 @@ 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
4020
  var import_node_util3 = require("node:util");
4021
4021
 
@@ -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,104 @@ 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 String(data.id);
5413
+ } catch {
5414
+ return null;
5415
+ }
5416
+ }
5417
+ async function resolveInstallationId(agent, jwtToken) {
5418
+ if (agent.installationId) return String(agent.installationId);
5419
+ const slug = currentRepoSlug();
5420
+ if (slug) {
5421
+ const id = await installationForRepo(slug, jwtToken);
5422
+ if (id) return id;
5423
+ }
5424
+ return pickInstallation(await listInstallations(jwtToken), agent);
5425
+ }
5426
+ function buildTokenScope(agent) {
5369
5427
  const scope = {};
5370
5428
  if (Array.isArray(agent.repositories)) scope.repositories = agent.repositories;
5371
5429
  if (agent.permissions && typeof agent.permissions === "object") scope.permissions = agent.permissions;
5372
- const data = await githubApi(`/app/installations/${agent.installationId}/access_tokens`, {
5430
+ return scope;
5431
+ }
5432
+ async function generateGithubAppToken(agent) {
5433
+ const privateKey = readPrivateKey(agent.privateKeyPath, agent.name || agent.appId);
5434
+ const jwtToken = signAppJwt(agent.appId, privateKey);
5435
+ const installationId = await resolveInstallationId(agent, jwtToken);
5436
+ const data = await githubApi(`/app/installations/${installationId}/access_tokens`, {
5373
5437
  jwtToken,
5374
- body: Object.keys(scope).length > 0 ? scope : {}
5438
+ body: buildTokenScope(agent)
5375
5439
  });
5376
- return data.token;
5440
+ return {
5441
+ token: data.token,
5442
+ // What GitHub actually granted, after both the App's config and our scoping.
5443
+ permissions: data.permissions || {},
5444
+ repositorySelection: data.repository_selection || "all"
5445
+ };
5377
5446
  }
5378
5447
 
5379
5448
  // src/providers.js
5380
- var import_node_child_process = require("node:child_process");
5449
+ var import_node_child_process2 = require("node:child_process");
5381
5450
  var PROVIDER_OPTIONS = [
5382
5451
  { value: "claude", label: "Claude", defaultCommand: "claude" },
5383
5452
  { value: "codex", label: "Codex", defaultCommand: "codex" },
5384
5453
  { value: "antigravity", label: "Antigravity", defaultCommand: "agy", aliases: ["antigravity", "agy"] }
5385
5454
  ];
5386
5455
  function isCommandInstalled(command) {
5387
- const result = (0, import_node_child_process.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
5456
+ const result = (0, import_node_child_process2.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
5388
5457
  return result.status === 0;
5389
5458
  }
5390
5459
  function detectAvailableProviders() {
@@ -5477,8 +5546,8 @@ function keep(value) {
5477
5546
  }
5478
5547
  return value;
5479
5548
  }
5480
- async function addAgentWizard() {
5481
- intro("Add agent");
5549
+ async function addAgentWizard({ embedded = false } = {}) {
5550
+ if (!embedded) intro("Add agent");
5482
5551
  const appId = keep(
5483
5552
  await text({
5484
5553
  message: "GitHub App ID",
@@ -5500,14 +5569,14 @@ async function addAgentWizard() {
5500
5569
  if (meta.installations.length === 0) {
5501
5570
  throw new Error("This App has no installations. Install it on an account or org first.");
5502
5571
  }
5503
- let installationId = meta.installations[0].id;
5572
+ let account;
5504
5573
  if (meta.installations.length === 1) {
5505
- note(`${meta.installations[0].account} (${installationId})`, "Installation");
5574
+ note(`${meta.installations[0].account} \u2014 resolved automatically each run`, "Installation");
5506
5575
  } else {
5507
- installationId = keep(
5576
+ account = keep(
5508
5577
  await select({
5509
- message: "Installation",
5510
- options: meta.installations.map((i2) => ({ value: i2.id, label: `${i2.account} (${i2.id})` }))
5578
+ message: "Which account is this agent for?",
5579
+ options: meta.installations.map((i2) => ({ value: i2.account, label: `${i2.account} (${i2.id})` }))
5511
5580
  })
5512
5581
  );
5513
5582
  }
@@ -5518,48 +5587,63 @@ async function addAgentWizard() {
5518
5587
  name,
5519
5588
  label,
5520
5589
  appId,
5521
- installationId,
5522
5590
  botName: meta.slug,
5523
5591
  privateKeyPath,
5592
+ ...account ? { account } : {},
5524
5593
  ...systemPrompt ? { systemPrompt } : {}
5525
5594
  };
5526
5595
  const check = spinner();
5527
5596
  check.start("Minting a test token");
5528
- await generateGithubAppToken(agent);
5529
- check.stop("Test token ok");
5597
+ const { permissions } = await generateGithubAppToken(agent);
5598
+ const scope = Object.entries(permissions).map(([name2, level]) => `${name2}:${level}`).join(" ");
5599
+ check.stop(scope ? `Test token ok \xB7 ${scope}` : "Test token ok");
5530
5600
  const path = addAgent(agent);
5531
- outro(`Wrote ${path}`);
5601
+ if (embedded) {
5602
+ note(`Wrote ${path}`, "Agent added");
5603
+ } else {
5604
+ outro(`Wrote ${path}`);
5605
+ }
5606
+ return agent.name;
5532
5607
  }
5533
5608
  async function interactiveSelection(defaults = {}) {
5534
5609
  const availableProviders = detectAvailableProviders();
5535
5610
  if (availableProviders.length === 0) {
5536
5611
  throw new Error("No installed provider was detected. Install claude, codex, or antigravity before running the launcher.");
5537
5612
  }
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
- );
5613
+ let agents = readAgentsOrEmpty();
5614
+ if (agents.length === 0 && !defaults.agent) {
5615
+ note("No agents configured yet. Each agent is a GitHub App identity the launcher runs under.", "First run");
5616
+ if (!keep(await confirm({ message: "Add one now?" }))) {
5617
+ cancel(`Run "agent-forge add" when you're ready.`);
5618
+ process.exit(0);
5619
+ }
5620
+ await addAgentWizard({ embedded: true });
5621
+ agents = readAgents();
5549
5622
  }
5550
- const agents = readAgents();
5551
5623
  let agentChoice = defaults.agent;
5552
5624
  if (agentChoice && !agents.some((agent) => agent.name === agentChoice)) {
5553
5625
  throw new Error(`Agent not found: ${agentChoice}`);
5554
5626
  }
5555
5627
  if (!agentChoice) {
5556
- agentChoice = keep(
5628
+ agentChoice = agents.length === 1 ? agents[0].name : keep(
5557
5629
  await select({
5558
5630
  message: "Which agent do you want to launch?",
5559
5631
  options: agents.map((agent) => ({ value: agent.name, label: agent.label || agent.name }))
5560
5632
  })
5561
5633
  );
5562
5634
  }
5635
+ let providerChoice = defaults.provider;
5636
+ if (providerChoice && !availableProviders.some((provider) => provider.value === providerChoice)) {
5637
+ throw new Error(`The provider ${providerChoice} is not installed or not available in PATH.`);
5638
+ }
5639
+ if (!providerChoice) {
5640
+ providerChoice = availableProviders.length === 1 ? availableProviders[0].value : keep(
5641
+ await select({
5642
+ message: "Which provider do you want to use?",
5643
+ options: availableProviders.map((provider) => ({ value: provider.value, label: provider.label }))
5644
+ })
5645
+ );
5646
+ }
5563
5647
  if (!keep(await confirm({ message: `Launch ${agentChoice} with ${providerChoice}?` }))) {
5564
5648
  cancel("Cancelled.");
5565
5649
  process.exit(0);
@@ -5619,7 +5703,7 @@ async function launchAgent(agentName, providerName) {
5619
5703
  const providerInfo = getProviderInfo(providerName, agent);
5620
5704
  const s = spinner();
5621
5705
  s.start("Minting installation token");
5622
- const githubToken = await generateGithubAppToken(agent);
5706
+ const { token: githubToken, permissions, repositorySelection } = await generateGithubAppToken(agent);
5623
5707
  const botId = await resolveBotId(agent);
5624
5708
  const gitIdentity = buildGitIdentity(agent, botId);
5625
5709
  s.stop(`Token ready for ${(0, import_node_util3.styleText)("green", gitIdentity.GIT_AUTHOR_NAME)}`);
@@ -5630,17 +5714,20 @@ async function launchAgent(agentName, providerName) {
5630
5714
  GH_TOKEN: githubToken,
5631
5715
  ...gitIdentity
5632
5716
  };
5717
+ const scopeEntries = Object.entries(permissions);
5718
+ const scopeText = scopeEntries.length ? scopeEntries.map(([name, level]) => `${name}:${level}`).join(" ") : "full installation scope";
5633
5719
  const row = (label, value) => `${(0, import_node_util3.styleText)("dim", label.padEnd(10))}${value}`;
5634
5720
  const summary = [
5635
5721
  row("provider", (0, import_node_util3.styleText)(["cyan", "bold"], providerName)),
5636
- row("identity", (0, import_node_util3.styleText)("green", gitIdentity.GIT_AUTHOR_NAME))
5722
+ row("identity", (0, import_node_util3.styleText)("green", gitIdentity.GIT_AUTHOR_NAME)),
5723
+ row("token", (0, import_node_util3.styleText)("dim", repositorySelection === "selected" ? `${scopeText} \xB7 selected repos` : scopeText))
5637
5724
  ];
5638
5725
  if (identityFile) {
5639
5726
  summary.push(row("memory", (0, import_node_util3.styleText)("dim", (0, import_node_path4.relative)(process.cwd(), identityFile) || identityFile)));
5640
5727
  }
5641
5728
  note(summary.join("\n"), (0, import_node_util3.styleText)("bold", agent.label || agent.name));
5642
5729
  const providerArgs = getProviderPromptArgs(providerName, agent);
5643
- const child = (0, import_node_child_process2.spawn)(providerInfo.command, providerArgs, {
5730
+ const child = (0, import_node_child_process3.spawn)(providerInfo.command, providerArgs, {
5644
5731
  stdio: "inherit",
5645
5732
  env: runtimeEnv,
5646
5733
  shell: false
@@ -5672,7 +5759,11 @@ async function main() {
5672
5759
  if (!flags.agent) {
5673
5760
  throw new Error("Usage: agent-forge token --agent <name>");
5674
5761
  }
5675
- process.stdout.write(await generateGithubAppToken(findAgent(flags.agent)));
5762
+ const minted = await generateGithubAppToken(findAgent(flags.agent));
5763
+ const scope = Object.entries(minted.permissions).map(([name, level]) => `${name}:${level}`).join(" ");
5764
+ if (scope) process.stderr.write(`scope: ${scope}${minted.repositorySelection === "selected" ? " (selected repos)" : ""}
5765
+ `);
5766
+ process.stdout.write(minted.token);
5676
5767
  return;
5677
5768
  }
5678
5769
  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.2.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",