forgedock 1.0.4 → 1.0.6

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/bin/forgedock.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import os from "os";
4
4
  import { fileURLToPath } from "url";
5
- import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
6
6
  import {
7
7
  mkdir,
8
8
  symlink,
@@ -10,6 +10,7 @@ import {
10
10
  readlink,
11
11
  lstat,
12
12
  readdir,
13
+ realpath,
13
14
  stat,
14
15
  writeFile,
15
16
  unlink as fsUnlink,
@@ -17,14 +18,12 @@ import {
17
18
  import {
18
19
  existsSync,
19
20
  appendFileSync,
20
- chmodSync,
21
21
  readFileSync,
22
22
  writeFileSync,
23
23
  renameSync,
24
24
  unlinkSync,
25
25
  } from "fs";
26
26
  import { execSync, execFileSync } from "child_process";
27
- import { createSign } from "crypto";
28
27
  import {
29
28
  BOLD,
30
29
  GREEN,
@@ -1221,15 +1220,8 @@ function queryNpmRegistry(pkg) {
1221
1220
 
1222
1221
  /**
1223
1222
  * Path to the update-check cache file.
1224
- * Stored in ~/.forgedock/ alongside credentials.json.
1225
- * Populated lazily — FORGEDOCK_HOME is defined later in the file, but this
1226
- * constant is only resolved at module evaluation time so the ordering is fine.
1223
+ * Stored in ~/.forgedock/update-check.json.
1227
1224
  */
1228
- // NOTE: FORGEDOCK_HOME is declared later in the file (line ~2974). This const
1229
- // is a forward reference that is only evaluated when the module finishes loading,
1230
- // which is after FORGEDOCK_HOME is defined. Node ESM top-level await ensures
1231
- // the module is fully evaluated before any exported binding is read.
1232
- // We defer the join() call to avoid the forward-reference issue:
1233
1225
  function _getUpdateCheckCachePath() {
1234
1226
  return join(HOME, ".forgedock", "update-check.json");
1235
1227
  }
@@ -1336,8 +1328,35 @@ async function checkForRemoteUpdate(force = false) {
1336
1328
  timeout: 5000,
1337
1329
  }).trim();
1338
1330
 
1331
+ // An update is only "available" when local HEAD is strictly BEHIND
1332
+ // origin/main. Plain SHA inequality is direction-agnostic: a checkout
1333
+ // that is ahead of or diverged from origin/main (e.g. a contributor with
1334
+ // local commits) would otherwise show a spurious "Update available"
1335
+ // notice. `git merge-base --is-ancestor HEAD origin/main` exits 0 when
1336
+ // HEAD is an ancestor of origin/main (behind or equal); combined with the
1337
+ // inequality below this means strictly behind. Any non-zero exit ("not an
1338
+ // ancestor") or error throws under execFileSync — treat all such cases as
1339
+ // "not behind" so we never show a false-positive notice.
1340
+ let headIsAncestorOfRemote = false;
1341
+ try {
1342
+ execFileSync(
1343
+ "git",
1344
+ ["merge-base", "--is-ancestor", "HEAD", "origin/main"],
1345
+ {
1346
+ cwd: FORGE_HOME,
1347
+ stdio: ["pipe", "pipe", "pipe"],
1348
+ timeout: 5000,
1349
+ },
1350
+ );
1351
+ headIsAncestorOfRemote = true;
1352
+ } catch {
1353
+ // HEAD is ahead of, diverged from, or unrelated to origin/main — or git
1354
+ // failed. In every case, do not report an update as available.
1355
+ headIsAncestorOfRemote = false;
1356
+ }
1357
+
1339
1358
  result = {
1340
- updateAvailable: localHead !== remoteHead,
1359
+ updateAvailable: localHead !== remoteHead && headIsAncestorOfRemote,
1341
1360
  latestVersion: null,
1342
1361
  remoteHead,
1343
1362
  };
@@ -1835,6 +1854,8 @@ async function init() {
1835
1854
  });
1836
1855
  console.log(` ${GREEN}Created${RESET}: forge.yaml`);
1837
1856
  console.log("");
1857
+ await injectClaudeMd(cwd);
1858
+ console.log("");
1838
1859
  _printNextSteps({ remoteDetected });
1839
1860
  await validate(outputPath);
1840
1861
  return;
@@ -2133,12 +2154,187 @@ async function init() {
2133
2154
  );
2134
2155
  }
2135
2156
  console.log("");
2157
+ await injectClaudeMd(cwd);
2158
+ console.log("");
2136
2159
  _printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
2137
2160
  await validate(outputPath);
2138
2161
  }
2139
2162
  }
2140
2163
  }
2141
2164
 
2165
+ // ---------------------------------------------------------------------------
2166
+ // CLAUDE.md Integration — injectClaudeMd()
2167
+ // ---------------------------------------------------------------------------
2168
+
2169
+ const CLAUDE_BLOCK_BEGIN = "<!-- BEGIN FORGEDOCK -->";
2170
+ const CLAUDE_BLOCK_END = "<!-- END FORGEDOCK -->";
2171
+
2172
+ /**
2173
+ * Extract the `description:` value from a command file's YAML frontmatter.
2174
+ * Returns null if no frontmatter or no description is found.
2175
+ *
2176
+ * @param {string} content - Full file content
2177
+ * @returns {string|null}
2178
+ */
2179
+ function _extractFrontmatterDescription(content) {
2180
+ // Match YAML frontmatter block: starts with ---, ends with ---
2181
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2182
+ if (!fmMatch) return null;
2183
+ const fm = fmMatch[1];
2184
+ // Extract description: value (may span the rest of the line)
2185
+ const descMatch = fm.match(/^description:\s*(.+)$/m);
2186
+ if (!descMatch) return null;
2187
+ return descMatch[1].trim();
2188
+ }
2189
+
2190
+ /**
2191
+ * Build the managed ForgeDock block content (between markers, not including them).
2192
+ * Reads all commands/*.md files, extracts frontmatter descriptions, and generates
2193
+ * a concise command index.
2194
+ *
2195
+ * @returns {Promise<string>} Block content
2196
+ */
2197
+ async function _buildForgeDockBlock() {
2198
+ const files = await findMarkdownFiles(COMMANDS_DIR);
2199
+
2200
+ /** @type {Array<{name: string, description: string}>} */
2201
+ const commands = [];
2202
+
2203
+ for (const file of files) {
2204
+ let content = "";
2205
+ try {
2206
+ content = readFileSync(file, "utf-8");
2207
+ } catch {
2208
+ continue; // skip unreadable files
2209
+ }
2210
+ const description = _extractFrontmatterDescription(content);
2211
+ if (!description) continue;
2212
+
2213
+ // Derive display name: relative path from COMMANDS_DIR, remove .md extension
2214
+ const rel = relative(COMMANDS_DIR, file).replace(/\\/g, "/").replace(/\.md$/, "");
2215
+ commands.push({ name: rel, description });
2216
+ }
2217
+
2218
+ // Sort: top-level commands first, then sub-commands alphabetically
2219
+ commands.sort((a, b) => {
2220
+ const aDepth = a.name.split("/").length;
2221
+ const bDepth = b.name.split("/").length;
2222
+ if (aDepth !== bDepth) return aDepth - bDepth;
2223
+ return a.name.localeCompare(b.name);
2224
+ });
2225
+
2226
+ const indexLines = commands
2227
+ .map(({ name, description }) => `- \`/${name}\` — ${description}`)
2228
+ .join("\n");
2229
+
2230
+ return `## ForgeDock — Autonomous Development Pipeline
2231
+
2232
+ This project is driven by **ForgeDock**. GitHub issues are the knowledge graph.
2233
+ Core loop: **issue in → PR out** (PRs target \`staging\` fast-lane or \`milestone/{slug}\`).
2234
+
2235
+ ### When to Use What
2236
+
2237
+ - \`/issue\` — file a pipeline-ready issue
2238
+ - \`/work-on N\` — full investigate→build→review→merge for issue N
2239
+ - \`/milestone\` + \`/orchestrate\` — plan and parallelize milestone work
2240
+ - \`/review-pr\` — review a PR with domain agents
2241
+ - \`/quality-gate\` — pre-commit quality check
2242
+ - \`/validate\` — validate forge.yaml configuration
2243
+
2244
+ ### Command Index
2245
+
2246
+ ${indexLines}
2247
+
2248
+ ### Conventions
2249
+
2250
+ - Fast-lane PRs target \`staging\`; milestone PRs target \`milestone/{slug}\`
2251
+ - Review findings become separate issues (not merge blockers)
2252
+ - \`forge.yaml\` is the project config — see \`docs/CONFIG.md\`
2253
+ - Re-generate this block: \`npx forgedock integrate\``;
2254
+ }
2255
+
2256
+ /**
2257
+ * Idempotently inject (or update) a managed ForgeDock usage block into a file.
2258
+ * The block is bounded by BEGIN FORGEDOCK / END FORGEDOCK HTML comment markers.
2259
+ * Content outside the markers is never modified.
2260
+ *
2261
+ * @param {string} filePath - Absolute path to the target file (CLAUDE.md or AGENTS.md)
2262
+ * @param {string} blockContent - Content to place between the markers
2263
+ * @param {boolean} createIfMissing - Create the file if it does not exist
2264
+ * @returns {'created'|'updated'|'skipped'} Result status
2265
+ */
2266
+ function _injectManagedBlock(filePath, blockContent, createIfMissing) {
2267
+ const managed = `${CLAUDE_BLOCK_BEGIN}\n${blockContent}\n${CLAUDE_BLOCK_END}`;
2268
+
2269
+ if (!existsSync(filePath)) {
2270
+ if (!createIfMissing) return "skipped";
2271
+ writeFileSync(filePath, `${managed}\n`, "utf-8");
2272
+ return "created";
2273
+ }
2274
+
2275
+ let existing = readFileSync(filePath, "utf-8");
2276
+
2277
+ if (existing.includes(CLAUDE_BLOCK_BEGIN) && existing.includes(CLAUDE_BLOCK_END)) {
2278
+ // Both markers present — replace existing block in place
2279
+ const escaped_begin = CLAUDE_BLOCK_BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2280
+ const escaped_end = CLAUDE_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2281
+ const blockRegex = new RegExp(
2282
+ `${escaped_begin}[\\s\\S]*?${escaped_end}`,
2283
+ "g",
2284
+ );
2285
+ existing = existing.replace(blockRegex, managed);
2286
+ writeFileSync(filePath, existing, "utf-8");
2287
+ return "updated";
2288
+ }
2289
+
2290
+ if (existing.includes(CLAUDE_BLOCK_BEGIN)) {
2291
+ // BEGIN present but END missing — file is truncated or malformed.
2292
+ // Strip the orphaned marker and everything after it, then fall through
2293
+ // to the append path below so the complete block is written correctly.
2294
+ existing = existing.slice(0, existing.indexOf(CLAUDE_BLOCK_BEGIN)).trimEnd();
2295
+ }
2296
+
2297
+ // Append block to existing content, separated by a blank line
2298
+ const separator = existing.length === 0 || existing.endsWith("\n") ? "\n" : "\n\n";
2299
+ writeFileSync(filePath, `${existing}${separator}${managed}\n`, "utf-8");
2300
+ return "updated";
2301
+ }
2302
+
2303
+ /**
2304
+ * Inject or update the managed ForgeDock block into the project CLAUDE.md.
2305
+ * Also mirrors the block into AGENTS.md if that file already exists.
2306
+ * Creates CLAUDE.md if absent; never creates AGENTS.md.
2307
+ *
2308
+ * @param {string} cwd - Project root directory
2309
+ * @returns {Promise<void>}
2310
+ */
2311
+ async function injectClaudeMd(cwd) {
2312
+ const claudePath = join(cwd, "CLAUDE.md");
2313
+ const agentsPath = join(cwd, "AGENTS.md");
2314
+
2315
+ let blockContent;
2316
+ try {
2317
+ blockContent = await _buildForgeDockBlock();
2318
+ } catch (err) {
2319
+ console.log(
2320
+ ` ${YELLOW}!${RESET} Could not generate command index — skipping CLAUDE.md update (${err.message})`,
2321
+ );
2322
+ return;
2323
+ }
2324
+
2325
+ const claudeResult = _injectManagedBlock(claudePath, blockContent, true);
2326
+ if (claudeResult === "created") {
2327
+ console.log(` ${GREEN}✔${RESET} CLAUDE.md created with ForgeDock integration block`);
2328
+ } else {
2329
+ console.log(` ${GREEN}✔${RESET} CLAUDE.md updated — ForgeDock block refreshed`);
2330
+ }
2331
+
2332
+ if (existsSync(agentsPath)) {
2333
+ _injectManagedBlock(agentsPath, blockContent, false);
2334
+ console.log(` ${GREEN}✔${RESET} AGENTS.md mirrored`);
2335
+ }
2336
+ }
2337
+
2142
2338
  // ---------------------------------------------------------------------------
2143
2339
  // Helpers for init()
2144
2340
  // ---------------------------------------------------------------------------
@@ -2704,21 +2900,6 @@ ${reviewSection}
2704
2900
  # =============================================================================
2705
2901
 
2706
2902
  ${verificationSection}
2707
-
2708
- # =============================================================================
2709
- # BOT (OPTIONAL)
2710
- # GitHub App bot identity for pipeline operations.
2711
- # Credentials are stored separately in ~/.forgedock/credentials.json — not here.
2712
- # Run: npx forgedock bot setup to configure interactively.
2713
- # Run: npx forgedock bot status to verify.
2714
- # =============================================================================
2715
-
2716
- # bot:
2717
- # app_id: 12345
2718
- # installation_id: 67890
2719
- # private_key_path: "~/.forgedock/forgedock-bot.private-key.pem"
2720
- # # When configured, pipeline commands use the app token instead of personal token.
2721
- # # Bot-authored comments include <!-- forgedock-bot --> for audit filtering.
2722
2903
  `;
2723
2904
  }
2724
2905
 
@@ -3183,388 +3364,276 @@ function _renderValidationSummary(checks) {
3183
3364
  }
3184
3365
 
3185
3366
  // ---------------------------------------------------------------------------
3186
- // Bot credentials — store in ~/.forgedock/credentials.json (NOT forge.yaml)
3367
+ // Labels Bootstrap — idempotently create/update all ForgeDock-managed labels
3187
3368
  // ---------------------------------------------------------------------------
3188
3369
 
3189
- const FORGEDOCK_HOME = join(HOME, ".forgedock");
3190
- const CREDENTIALS_FILE = join(FORGEDOCK_HOME, "credentials.json");
3191
-
3192
3370
  /**
3193
- * Save bot credentials to ~/.forgedock/credentials.json.
3194
- * The private key itself is NOT stored — only the path.
3371
+ * Resolve the target repo for the labels command.
3195
3372
  *
3196
- * @param {{ appId: string, installationId: string, privateKeyPath: string }} creds
3197
- */
3198
- async function saveBotCredentials(creds) {
3199
- await mkdir(FORGEDOCK_HOME, { recursive: true, mode: 0o700 });
3200
- // Tighten permissions on pre-existing installs: fs.mkdir({ recursive: true }) ignores
3201
- // mode when the directory already exists (Linux kernel behaviour). chmodSync enforces
3202
- // 0o700 unconditionally, matching the intent of the mode option above.
3203
- chmodSync(FORGEDOCK_HOME, 0o700);
3204
- const existing = loadBotCredentials() ?? {};
3205
- const updated = { ...existing, bot: creds };
3206
- writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", {
3207
- encoding: "utf-8",
3208
- mode: 0o600,
3209
- });
3210
- // Tighten permissions on files created before this fix (mode is only applied at creation time)
3211
- chmodSync(CREDENTIALS_FILE, 0o600);
3212
- }
3213
-
3214
- /**
3215
- * Load bot credentials from ~/.forgedock/credentials.json.
3216
- * Returns null if the file is absent or malformed.
3373
+ * Priority order:
3374
+ * 1. --repo <owner/repo> passed on the CLI
3375
+ * 2. forge.yaml → project.owner + project.repo in the current working directory
3376
+ * 3. Returns null if neither is available (caller prints an error)
3217
3377
  *
3218
- * @returns {{ appId: string, installationId: string, privateKeyPath: string } | null}
3378
+ * @param {string[]} subArgs - CLI args after "labels [setup]"
3379
+ * @returns {string|null} "owner/repo" string or null
3219
3380
  */
3220
- function loadBotCredentials() {
3221
- try {
3222
- const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
3223
- const parsed = JSON.parse(raw);
3224
- return parsed.bot ?? null;
3225
- } catch {
3226
- return null;
3381
+ function resolveLabelsRepo(subArgs) {
3382
+ // 1. Explicit --repo flag
3383
+ const repoFlagIdx = subArgs.indexOf("--repo");
3384
+ if (repoFlagIdx !== -1 && subArgs[repoFlagIdx + 1]) {
3385
+ return subArgs[repoFlagIdx + 1];
3227
3386
  }
3228
- }
3229
-
3230
- // ---------------------------------------------------------------------------
3231
- // GitHub App JWT — generates a short-lived RS256 JWT for App-level API calls
3232
- // ---------------------------------------------------------------------------
3233
-
3234
- /**
3235
- * Generate a GitHub App JWT (valid for 10 minutes).
3236
- *
3237
- * @param {string} appId - GitHub App ID (numeric string)
3238
- * @param {string} privateKey - PEM-encoded PKCS#8 or PKCS#1 RSA private key content
3239
- * @returns {string} Signed JWT
3240
- */
3241
- function generateAppJwt(appId, privateKey) {
3242
- const now = Math.floor(Date.now() / 1000);
3243
- const payload = {
3244
- iat: now - 60, // issued 60s ago to account for clock skew
3245
- exp: now + 600, // expires in 10 minutes (GitHub max)
3246
- iss: appId,
3247
- };
3248
-
3249
- const header = Buffer.from(
3250
- JSON.stringify({ alg: "RS256", typ: "JWT" }),
3251
- ).toString("base64url");
3252
- const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
3253
- const unsigned = `${header}.${body}`;
3254
3387
 
3255
- const sign = createSign("RSA-SHA256");
3256
- sign.update(unsigned);
3257
- const signature = sign.sign(privateKey, "base64url");
3388
+ // 2. forge.yaml in cwd
3389
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
3390
+ if (existsSync(forgeYamlPath)) {
3391
+ try {
3392
+ const raw = readFileSync(forgeYamlPath, "utf-8");
3393
+ const ownerMatch = raw.match(/^\s*owner:\s*["']?([^\s"'#]+)["']?/m);
3394
+ const repoMatch = raw.match(/^\s*repo:\s*["']?([^\s"'#]+)["']?/m);
3395
+ if (ownerMatch && repoMatch) {
3396
+ return `${ownerMatch[1]}/${repoMatch[1]}`;
3397
+ }
3398
+ } catch {
3399
+ // fall through
3400
+ }
3401
+ }
3258
3402
 
3259
- return `${unsigned}.${signature}`;
3403
+ return null;
3260
3404
  }
3261
3405
 
3262
- // ---------------------------------------------------------------------------
3263
- // Bot — connect existing GitHub App
3264
- // ---------------------------------------------------------------------------
3265
-
3266
3406
  /**
3267
- * Interactive flow: prompt for App ID, private key path, installation ID.
3268
- * Validates credentials against the GitHub API before saving.
3407
+ * Idempotently create or update all ForgeDock-managed labels on a GitHub repo.
3408
+ *
3409
+ * Reads the canonical manifest from bin/labels.json (co-located with this
3410
+ * script). For each label entry, calls:
3411
+ *
3412
+ * gh label create <name> --color <hex> --description <desc> --force --repo <repo>
3413
+ *
3414
+ * --force makes the call idempotent: it creates the label if absent, or updates
3415
+ * its color and description if it already exists. Safe to re-run at any time.
3416
+ *
3417
+ * @param {string} repo - "owner/repo" string
3418
+ * @returns {{ created: number, failed: string[] }}
3269
3419
  */
3270
- async function connectExistingBot() {
3271
- console.log("");
3272
- console.log(
3273
- box(
3274
- [
3275
- `${bold("Connect an existing GitHub App")}`,
3276
- "",
3277
- "You'll need:",
3278
- ` • App ID (GitHub → Settings → Developer settings → GitHub Apps → your app)`,
3279
- ` • Private key file (.pem downloaded from app settings)`,
3280
- ` • Installation ID (GitHub → your org → Settings → Installed Apps → your app → configure → URL)`,
3281
- ].join("\n"),
3282
- { title: "Connect GitHub App" },
3283
- ),
3284
- );
3285
- console.log("");
3420
+ async function labelsSetup(repo) {
3421
+ const manifestPath = join(__dirname, "labels.json");
3286
3422
 
3287
- const appId = await input("App ID (numeric):", "");
3288
- if (!appId || !/^\d+$/.test(appId.trim())) {
3289
- console.log(` ${RED}Invalid App ID — must be a numeric string.${RESET}`);
3290
- return false;
3291
- }
3292
-
3293
- const privateKeyPath = await input("Private key path (.pem):", "");
3294
- const trimmedKeyPath = privateKeyPath.trim();
3295
- const home = os.homedir();
3296
- const expanded = trimmedKeyPath.startsWith("~")
3297
- ? join(home, trimmedKeyPath.slice(1))
3298
- : trimmedKeyPath;
3299
- const resolvedKeyPath = resolve(expanded);
3300
- if (trimmedKeyPath.startsWith("~")) {
3301
- const rel = relative(home, resolvedKeyPath);
3302
- if (rel.startsWith("..") || isAbsolute(rel)) {
3303
- console.log(
3304
- ` ${RED}Invalid path: cannot traverse outside home directory.${RESET}`,
3305
- );
3306
- return false;
3307
- }
3308
- }
3309
- if (!existsSync(resolvedKeyPath)) {
3310
- console.log(` ${RED}File not found: ${resolvedKeyPath}${RESET}`);
3311
- return false;
3312
- }
3313
-
3314
- const installationId = await input("Installation ID (numeric):", "");
3315
- if (!installationId || !/^\d+$/.test(installationId.trim())) {
3423
+ if (!existsSync(manifestPath)) {
3316
3424
  console.log(
3317
- ` ${RED}Invalid Installation ID — must be a numeric string.${RESET}`,
3425
+ `${RED}Label manifest not found: ${manifestPath}${RESET}`,
3318
3426
  );
3319
- return false;
3427
+ process.exit(1);
3320
3428
  }
3321
3429
 
3322
- // Validate credentials with GitHub API
3323
- console.log("");
3324
- console.log(` Validating credentials…`);
3325
- let privateKey;
3430
+ /** @type {Array<{name: string, color: string, description: string}>} */
3431
+ let labels;
3326
3432
  try {
3327
- privateKey = readFileSync(resolvedKeyPath, "utf-8");
3433
+ labels = JSON.parse(readFileSync(manifestPath, "utf-8"));
3328
3434
  } catch (err) {
3329
- console.log(` ${RED}Cannot read private key: ${err.message}${RESET}`);
3330
- return false;
3435
+ console.log(`${RED}Failed to parse labels.json: ${err.message}${RESET}`);
3436
+ process.exit(1);
3331
3437
  }
3332
3438
 
3333
- try {
3334
- const jwt = generateAppJwt(appId.trim(), privateKey);
3335
- const resp = await fetch("https://api.github.com/app", {
3336
- headers: {
3337
- Authorization: `Bearer ${jwt}`,
3338
- Accept: "application/vnd.github+json",
3339
- "X-GitHub-Api-Version": "2022-11-28",
3340
- },
3341
- });
3342
- if (!resp.ok) {
3343
- const body = await resp.json().catch(() => ({}));
3344
- console.log(
3345
- ` ${RED}GitHub API error ${resp.status}: ${body.message ?? "unknown"}${RESET}`,
3439
+ console.log("");
3440
+ console.log(`${BOLD}ForgeDock Label Bootstrap${RESET}`);
3441
+ console.log(` Repository: ${cyan(repo)}`);
3442
+ console.log(` Labels: ${labels.length} managed labels`);
3443
+ console.log("");
3444
+
3445
+ const bar = createProgressBar(labels.length, {
3446
+ label: " Bootstrapping labels...",
3447
+ });
3448
+
3449
+ let created = 0;
3450
+ const failed = [];
3451
+
3452
+ for (const { name, color, description } of labels) {
3453
+ bar.tick(1, dim(name));
3454
+ try {
3455
+ execFileSync(
3456
+ "gh",
3457
+ [
3458
+ "label",
3459
+ "create",
3460
+ name,
3461
+ "--color",
3462
+ color,
3463
+ "--description",
3464
+ description,
3465
+ "--force",
3466
+ "--repo",
3467
+ repo,
3468
+ ],
3469
+ { stdio: ["pipe", "pipe", "pipe"] },
3346
3470
  );
3347
- return false;
3471
+ created++;
3472
+ } catch (err) {
3473
+ failed.push(name);
3474
+ // Continue — don't abort the whole bootstrap for one failure
3475
+ }
3476
+ }
3477
+
3478
+ bar.done(
3479
+ failed.length === 0
3480
+ ? `${green("✔")} Bootstrapped ${created}/${labels.length} labels on ${cyan(repo)}`
3481
+ : `${yellow("⚠")} Bootstrapped ${created}/${labels.length} labels — ${failed.length} failed`,
3482
+ );
3483
+
3484
+ if (failed.length > 0) {
3485
+ console.log("");
3486
+ console.log(` ${RED}Failed labels:${RESET}`);
3487
+ for (const name of failed) {
3488
+ console.log(` ${dim("•")} ${name}`);
3348
3489
  }
3349
- const appData = await resp.json();
3350
3490
  console.log(
3351
- ` ${GREEN}Connected!${RESET} App: ${bold(appData.name)} (id: ${appId.trim()})`,
3491
+ `\n Run ${cyan("gh auth status")} to verify GitHub authentication.`,
3352
3492
  );
3353
- } catch (err) {
3354
- console.log(` ${RED}Validation failed: ${err.message}${RESET}`);
3355
- return false;
3356
3493
  }
3357
3494
 
3358
- await saveBotCredentials({
3359
- appId: appId.trim(),
3360
- installationId: installationId.trim(),
3361
- privateKeyPath: resolvedKeyPath,
3362
- });
3363
-
3364
- console.log(` Credentials saved to ${cyan(CREDENTIALS_FILE)}`);
3365
- return true;
3495
+ console.log("");
3496
+ return { created, failed };
3366
3497
  }
3367
3498
 
3368
3499
  // ---------------------------------------------------------------------------
3369
- // Bot — status command
3500
+ // docs init — scaffold devdocs knowledge tree into a project
3370
3501
  // ---------------------------------------------------------------------------
3371
3502
 
3372
3503
  /**
3373
- * Show bot health, identity, and rate limit usage.
3374
- * Gracefully handles missing credentials.
3504
+ * Walk a directory tree and return all file paths (any extension).
3505
+ * @param {string} dir
3506
+ * @returns {Promise<string[]>}
3375
3507
  */
3376
- async function botStatus() {
3377
- console.log("");
3378
- console.log(`${BOLD}ForgeDock Bot Status${RESET}`);
3379
- console.log("");
3508
+ async function _findAllFiles(dir) {
3509
+ const results = [];
3510
+ const entries = await readdir(dir, { withFileTypes: true });
3511
+ for (const entry of entries) {
3512
+ const full = join(dir, entry.name);
3513
+ if (entry.isDirectory()) {
3514
+ results.push(...(await _findAllFiles(full)));
3515
+ } else {
3516
+ results.push(full);
3517
+ }
3518
+ }
3519
+ return results.sort();
3520
+ }
3380
3521
 
3381
- const creds = loadBotCredentials();
3382
- if (!creds) {
3383
- console.log(
3384
- ` ${YELLOW}No bot credentials found.${RESET}\n` +
3385
- ` Run ${cyan("npx forgedock bot setup")} to configure a GitHub App, or\n` +
3386
- ` add credentials to ${cyan(CREDENTIALS_FILE)}.`,
3387
- );
3388
- console.log("");
3389
- return;
3522
+ async function docsInit() {
3523
+ const cwd = process.cwd();
3524
+
3525
+ // Resolve target devdocs path: forge.yaml → devdocs.path, default "devdocs/"
3526
+ let devdocsRelPath = "devdocs";
3527
+ const forgeYamlPath = join(cwd, "forge.yaml");
3528
+ if (existsSync(forgeYamlPath)) {
3529
+ try {
3530
+ const raw = readFileSync(forgeYamlPath, "utf-8");
3531
+ const parsed = _parseYamlKey(raw, "devdocs.path");
3532
+ if (parsed && parsed.trim()) {
3533
+ devdocsRelPath = _sanitizePathValue(parsed.trim().replace(/\/$/, ""));
3534
+ }
3535
+ } catch {
3536
+ // forge.yaml unreadable — use default
3537
+ }
3390
3538
  }
3391
3539
 
3392
- console.log(` App ID: ${cyan(creds.appId)}`);
3393
- console.log(` Installation ID: ${cyan(creds.installationId)}`);
3394
- console.log(` Private key: ${cyan(creds.privateKeyPath)}`);
3395
- console.log("");
3540
+ const targetDir = isAbsolute(devdocsRelPath)
3541
+ ? devdocsRelPath
3542
+ : join(cwd, devdocsRelPath);
3396
3543
 
3397
- // Check private key file
3398
- if (!existsSync(creds.privateKeyPath)) {
3544
+ // Security: assert targetDir is confined to the project directory.
3545
+ // Prevents path traversal via ../.. sequences, absolute paths, and symlinks in devdocs.path.
3546
+ // Use realpath() to dereference symlinks — resolve() does not follow them, so a symlink
3547
+ // pre-placed inside the project (e.g. devdocs -> /etc) would bypass a resolve()-only check.
3548
+ // Fall back to resolve() when targetDir does not yet exist (no symlink to dereference).
3549
+ let resolvedTarget;
3550
+ try {
3551
+ resolvedTarget = await realpath(targetDir);
3552
+ } catch {
3553
+ resolvedTarget = resolve(targetDir);
3554
+ }
3555
+ if (resolvedTarget !== cwd && !resolvedTarget.startsWith(cwd + sep)) {
3399
3556
  console.log(
3400
- ` ${RED}Private key file not found: ${creds.privateKeyPath}${RESET}`,
3557
+ `${RED}Error: devdocs.path must be inside the project directory.${RESET}`,
3401
3558
  );
3402
- console.log(` Run ${cyan("npx forgedock bot setup")} to reconfigure.`);
3403
- console.log("");
3404
- return;
3559
+ console.log(` Resolved: ${resolvedTarget}`);
3560
+ console.log(` Project: ${cwd}`);
3561
+ console.log(
3562
+ ` Fix: set ${CYAN}devdocs.path${RESET} to a relative path inside your project (e.g. "devdocs" or "docs/knowledge").`,
3563
+ );
3564
+ process.exit(1);
3405
3565
  }
3406
3566
 
3407
- console.log(" Verifying with GitHub API…");
3408
- let privateKey;
3409
- try {
3410
- privateKey = readFileSync(creds.privateKeyPath, "utf-8");
3411
- } catch (err) {
3412
- console.log(` ${RED}Cannot read private key: ${err.message}${RESET}`);
3413
- console.log("");
3414
- return;
3415
- }
3567
+ // Template source: templates/devdocs/ inside the ForgeDock package
3568
+ const templatesDir = join(FORGE_HOME, "templates", "devdocs");
3416
3569
 
3417
- try {
3418
- const jwt = generateAppJwt(creds.appId, privateKey);
3419
-
3420
- // App identity
3421
- const appResp = await fetch("https://api.github.com/app", {
3422
- headers: {
3423
- Authorization: `Bearer ${jwt}`,
3424
- Accept: "application/vnd.github+json",
3425
- "X-GitHub-Api-Version": "2022-11-28",
3426
- },
3427
- });
3428
- if (!appResp.ok) {
3429
- const body = await appResp.json().catch(() => ({}));
3430
- console.log(
3431
- ` ${RED}GitHub API error ${appResp.status}: ${body.message ?? "unknown"}${RESET}`,
3432
- );
3433
- console.log("");
3434
- return;
3435
- }
3436
- const appData = await appResp.json();
3570
+ if (!existsSync(templatesDir)) {
3437
3571
  console.log(
3438
- ` ${GREEN}Connected${RESET} — App: ${bold(appData.name)} (slug: @${appData.slug})`,
3572
+ `${RED}DevDocs templates not found: ${templatesDir}${RESET}`,
3439
3573
  );
3440
-
3441
- // Rate limit for app
3442
- const rlResp = await fetch("https://api.github.com/rate_limit", {
3443
- headers: {
3444
- Authorization: `Bearer ${jwt}`,
3445
- Accept: "application/vnd.github+json",
3446
- "X-GitHub-Api-Version": "2022-11-28",
3447
- },
3448
- });
3449
- if (rlResp.ok) {
3450
- const rl = await rlResp.json();
3451
- const core = rl.resources?.core;
3452
- if (core) {
3453
- const used = core.limit - core.remaining;
3454
- const resetTime = new Date(core.reset * 1000).toLocaleTimeString();
3455
- console.log(
3456
- ` Rate limit: ${cyan(core.remaining)}/${core.limit} remaining (${used} used, resets ${resetTime})`,
3457
- );
3458
- }
3459
- }
3460
- } catch (err) {
3461
- console.log(` ${RED}Error: ${err.message}${RESET}`);
3574
+ console.log(
3575
+ ` This usually means ForgeDock is not installed correctly.`,
3576
+ );
3577
+ console.log(
3578
+ ` Try: ${CYAN}npm install -g forgedock${RESET} or ${CYAN}npx forgedock@latest docs init${RESET}`,
3579
+ );
3580
+ process.exit(1);
3462
3581
  }
3463
3582
 
3464
- console.log("");
3465
- }
3466
-
3467
- // ---------------------------------------------------------------------------
3468
- // Bot — setup TUI step (called from StepOrchestrator or directly)
3469
- // ---------------------------------------------------------------------------
3583
+ const templateFiles = await _findAllFiles(templatesDir);
3470
3584
 
3471
- /**
3472
- * Interactive bot setup step.
3473
- * Shows options: Create new app / Connect existing / Skip.
3474
- * Called as an optional step in tuiOnboarding() or via `npx forgedock bot setup`.
3475
- */
3476
- async function botSetup() {
3477
3585
  console.log("");
3478
- console.log(
3479
- box(
3480
- [
3481
- `${bold("GitHub Bot Setup")} ${dim("(Optional)")}`,
3482
- "",
3483
- "A GitHub App gives ForgeDock its own identity",
3484
- "for pipeline operations. Benefits:",
3485
- ` ${cyan("•")} Separate audit trail for bot actions`,
3486
- ` ${cyan("•")} Higher API rate limits`,
3487
- ` ${cyan("•")} Webhook-driven automation (future)`,
3488
- ` ${cyan("•")} Multi-user team support`,
3489
- ].join("\n"),
3490
- { title: "GitHub Bot" },
3491
- ),
3492
- );
3586
+ console.log(`${BOLD}ForgeDock${RESET} — Scaffold DevDocs`);
3587
+ console.log(` Source: ${dim(templatesDir + "/")}`);
3588
+ console.log(` Target: ${dim(targetDir + "/")}`);
3589
+ console.log(` Files: ${templateFiles.length} seed files`);
3493
3590
  console.log("");
3494
3591
 
3495
- const action = await select("How would you like to set up the bot?", [
3496
- { label: "Create new GitHub App (opens browser)", value: "create" },
3497
- { label: "Connect existing GitHub App", value: "connect" },
3498
- { label: "Skip — use personal token (current default)", value: "skip" },
3499
- ]);
3592
+ await mkdir(targetDir, { recursive: true });
3500
3593
 
3501
- if (action === "skip") {
3502
- console.log(
3503
- ` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity.`,
3504
- );
3505
- return;
3506
- }
3507
-
3508
- if (action === "create") {
3509
- // Build the GitHub App manifest URL — opens the browser-based creation flow
3510
- const manifestPath = join(dirname(__filename), "github-app-manifest.json");
3511
- let manifest;
3512
- try {
3513
- manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
3514
- } catch {
3515
- console.log(
3516
- ` ${RED}Could not read github-app-manifest.json — skipping.${RESET}`,
3517
- );
3518
- return;
3519
- }
3594
+ let copied = 0;
3595
+ let skipped = 0;
3520
3596
 
3521
- const encodedManifest = encodeURIComponent(JSON.stringify(manifest));
3522
- const createUrl = `https://github.com/settings/apps/new?manifest=${encodedManifest}`;
3597
+ for (const srcFile of templateFiles) {
3598
+ const rel = relative(templatesDir, srcFile);
3599
+ const destFile = join(targetDir, rel);
3600
+ const destDir = dirname(destFile);
3523
3601
 
3524
- console.log("");
3525
- console.log(
3526
- ` ${bold("Opening GitHub App registration in your browser…")}`,
3527
- );
3528
- console.log(` URL: ${cyan(createUrl.substring(0, 80))}…`);
3529
- console.log("");
3602
+ await mkdir(destDir, { recursive: true });
3530
3603
 
3531
- // Try to open the browser
3532
- try {
3533
- const opener =
3534
- process.platform === "win32"
3535
- ? "start"
3536
- : process.platform === "darwin"
3537
- ? "open"
3538
- : "xdg-open";
3539
- // On Windows, `start "url"` treats the first quoted arg as the window title.
3540
- // Pass an empty title placeholder so the URL is treated as the target: `start "" "url"`.
3541
- const cmd =
3542
- process.platform === "win32"
3543
- ? `start "" "${createUrl}"`
3544
- : `${opener} "${createUrl}"`;
3545
- execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
3546
- } catch {
3547
- console.log(` ${YELLOW}Could not open browser automatically.${RESET}`);
3548
- console.log(` Please open this URL manually:`);
3549
- console.log(` ${cyan(createUrl)}`);
3604
+ if (existsSync(destFile)) {
3605
+ skipped++;
3606
+ console.log(` ${dim("skip")} ${dim(rel)}`);
3607
+ } else {
3608
+ await copyFile(srcFile, destFile);
3609
+ copied++;
3610
+ console.log(` ${green("+")} ${rel}`);
3550
3611
  }
3612
+ }
3551
3613
 
3552
- console.log("");
3553
- console.log(" After creating the app in your browser:");
3554
- console.log(` 1. Download the private key (.pem) from the app settings`);
3555
- console.log(` 2. Note your App ID and Installation ID`);
3614
+ console.log("");
3615
+ if (copied > 0 && skipped === 0) {
3556
3616
  console.log(
3557
- ` 3. Run ${cyan("npx forgedock bot setup")} again and choose \"Connect existing\"`,
3617
+ `${green("DevDocs scaffolded!")} ${copied} file${copied !== 1 ? "s" : ""} created in ${cyan(devdocsRelPath + "/")}.`,
3618
+ );
3619
+ } else if (copied > 0) {
3620
+ console.log(
3621
+ `${green("DevDocs scaffolded!")} ${copied} file${copied !== 1 ? "s" : ""} created, ${skipped} already existed (skipped).`,
3622
+ );
3623
+ } else {
3624
+ console.log(
3625
+ `${yellow("DevDocs already up to date.")} All ${skipped} file${skipped !== 1 ? "s" : ""} already exist — nothing was overwritten.`,
3558
3626
  );
3559
- console.log("");
3560
- return;
3561
3627
  }
3562
3628
 
3563
- // action === "connect"
3564
- const connected = await connectExistingBot();
3565
- if (!connected) {
3566
- throw new Error("Bot connection failed — credentials were not saved.");
3567
- }
3629
+ console.log("");
3630
+ console.log(
3631
+ ` ${dim("Tip:")} Customise the files in ${cyan(devdocsRelPath + "/")} for your project.`,
3632
+ );
3633
+ console.log(
3634
+ ` ${dim("Agents read these files as authoritative project knowledge.")}`,
3635
+ );
3636
+ console.log("");
3568
3637
  }
3569
3638
 
3570
3639
  function help() {
@@ -3594,13 +3663,19 @@ function help() {
3594
3663
  ` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`,
3595
3664
  );
3596
3665
  console.log(
3597
- ` ${CYAN}npx forgedock bot${RESET} Manage GitHub App bot identity`,
3666
+ ` ${CYAN}npx forgedock labels${RESET} Bootstrap ForgeDock-managed labels on a GitHub repo`,
3598
3667
  );
3599
3668
  console.log(
3600
- ` ${CYAN}npx forgedock bot status${RESET} Show bot health and rate limit usage`,
3669
+ ` ${CYAN}npx forgedock labels setup${RESET} Create/update all managed labels (idempotent)`,
3601
3670
  );
3602
3671
  console.log(
3603
- ` ${CYAN}npx forgedock bot setup${RESET} Configure GitHub App bot (interactive)`,
3672
+ ` ${CYAN}npx forgedock labels setup --repo owner/repo${RESET} Target a specific repo`,
3673
+ );
3674
+ console.log(
3675
+ ` ${CYAN}npx forgedock integrate${RESET} Inject/update ForgeDock usage block in project CLAUDE.md`,
3676
+ );
3677
+ console.log(
3678
+ ` ${CYAN}npx forgedock docs init${RESET} Scaffold devdocs knowledge tree into current project`,
3604
3679
  );
3605
3680
  console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
3606
3681
  console.log("");
@@ -3957,13 +4032,6 @@ async function tuiOnboarding() {
3957
4032
  await validate(forgeYamlPath);
3958
4033
  },
3959
4034
  },
3960
- {
3961
- name: "GitHub Bot Setup",
3962
- optional: true,
3963
- run: async () => {
3964
- await botSetup();
3965
- },
3966
- },
3967
4035
  ];
3968
4036
 
3969
4037
  const orchestrator = new StepOrchestrator(steps);
@@ -4013,15 +4081,40 @@ if (!command) {
4013
4081
  case "update":
4014
4082
  await update();
4015
4083
  break;
4016
- case "bot": {
4084
+ case "labels": {
4017
4085
  const subcommand = args[1];
4018
- if (!subcommand || subcommand === "status") {
4019
- await botStatus();
4020
- } else if (subcommand === "setup") {
4021
- await botSetup();
4086
+ if (!subcommand || subcommand === "setup" || subcommand.startsWith("--")) {
4087
+ const subArgs = args.slice(1);
4088
+ const repo = resolveLabelsRepo(subArgs);
4089
+ if (!repo) {
4090
+ console.log(
4091
+ `${RED}No repository specified.${RESET}\n` +
4092
+ ` Pass ${cyan("--repo owner/repo")} or run from a directory with ${cyan("forge.yaml")}.`,
4093
+ );
4094
+ process.exit(1);
4095
+ }
4096
+ await labelsSetup(repo);
4022
4097
  } else {
4023
- console.log(`${RED}Unknown bot subcommand: ${subcommand}${RESET}`);
4024
- console.log(`Usage: ${CYAN}npx forgedock bot [status|setup]${RESET}`);
4098
+ console.log(`${RED}Unknown labels subcommand: ${subcommand}${RESET}`);
4099
+ console.log(
4100
+ `Usage: ${CYAN}npx forgedock labels [setup] [--repo owner/repo]${RESET}`,
4101
+ );
4102
+ process.exit(1);
4103
+ }
4104
+ break;
4105
+ }
4106
+ case "integrate":
4107
+ await injectClaudeMd(process.cwd());
4108
+ break;
4109
+ case "docs": {
4110
+ const docsSubcommand = args[1];
4111
+ if (!docsSubcommand || docsSubcommand === "init") {
4112
+ await docsInit();
4113
+ } else {
4114
+ console.log(`${RED}Unknown docs subcommand: ${docsSubcommand}${RESET}`);
4115
+ console.log(
4116
+ `Usage: ${CYAN}npx forgedock docs init${RESET}`,
4117
+ );
4025
4118
  process.exit(1);
4026
4119
  }
4027
4120
  break;