forgedock 1.0.5 → 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
  }
@@ -1862,6 +1854,8 @@ async function init() {
1862
1854
  });
1863
1855
  console.log(` ${GREEN}Created${RESET}: forge.yaml`);
1864
1856
  console.log("");
1857
+ await injectClaudeMd(cwd);
1858
+ console.log("");
1865
1859
  _printNextSteps({ remoteDetected });
1866
1860
  await validate(outputPath);
1867
1861
  return;
@@ -2160,12 +2154,187 @@ async function init() {
2160
2154
  );
2161
2155
  }
2162
2156
  console.log("");
2157
+ await injectClaudeMd(cwd);
2158
+ console.log("");
2163
2159
  _printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
2164
2160
  await validate(outputPath);
2165
2161
  }
2166
2162
  }
2167
2163
  }
2168
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
+
2169
2338
  // ---------------------------------------------------------------------------
2170
2339
  // Helpers for init()
2171
2340
  // ---------------------------------------------------------------------------
@@ -2731,21 +2900,6 @@ ${reviewSection}
2731
2900
  # =============================================================================
2732
2901
 
2733
2902
  ${verificationSection}
2734
-
2735
- # =============================================================================
2736
- # BOT (OPTIONAL)
2737
- # GitHub App bot identity for pipeline operations.
2738
- # Credentials are stored separately in ~/.forgedock/credentials.json — not here.
2739
- # Run: npx forgedock bot setup to configure interactively.
2740
- # Run: npx forgedock bot status to verify.
2741
- # =============================================================================
2742
-
2743
- # bot:
2744
- # app_id: 12345
2745
- # installation_id: 67890
2746
- # private_key_path: "~/.forgedock/forgedock-bot.private-key.pem"
2747
- # # When configured, pipeline commands use the app token instead of personal token.
2748
- # # Bot-authored comments include <!-- forgedock-bot --> for audit filtering.
2749
2903
  `;
2750
2904
  }
2751
2905
 
@@ -3210,388 +3364,276 @@ function _renderValidationSummary(checks) {
3210
3364
  }
3211
3365
 
3212
3366
  // ---------------------------------------------------------------------------
3213
- // Bot credentials — store in ~/.forgedock/credentials.json (NOT forge.yaml)
3367
+ // Labels Bootstrap — idempotently create/update all ForgeDock-managed labels
3214
3368
  // ---------------------------------------------------------------------------
3215
3369
 
3216
- const FORGEDOCK_HOME = join(HOME, ".forgedock");
3217
- const CREDENTIALS_FILE = join(FORGEDOCK_HOME, "credentials.json");
3218
-
3219
3370
  /**
3220
- * Save bot credentials to ~/.forgedock/credentials.json.
3221
- * The private key itself is NOT stored — only the path.
3371
+ * Resolve the target repo for the labels command.
3222
3372
  *
3223
- * @param {{ appId: string, installationId: string, privateKeyPath: string }} creds
3224
- */
3225
- async function saveBotCredentials(creds) {
3226
- await mkdir(FORGEDOCK_HOME, { recursive: true, mode: 0o700 });
3227
- // Tighten permissions on pre-existing installs: fs.mkdir({ recursive: true }) ignores
3228
- // mode when the directory already exists (Linux kernel behaviour). chmodSync enforces
3229
- // 0o700 unconditionally, matching the intent of the mode option above.
3230
- chmodSync(FORGEDOCK_HOME, 0o700);
3231
- const existing = loadBotCredentials() ?? {};
3232
- const updated = { ...existing, bot: creds };
3233
- writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", {
3234
- encoding: "utf-8",
3235
- mode: 0o600,
3236
- });
3237
- // Tighten permissions on files created before this fix (mode is only applied at creation time)
3238
- chmodSync(CREDENTIALS_FILE, 0o600);
3239
- }
3240
-
3241
- /**
3242
- * Load bot credentials from ~/.forgedock/credentials.json.
3243
- * 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)
3244
3377
  *
3245
- * @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
3246
3380
  */
3247
- function loadBotCredentials() {
3248
- try {
3249
- const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
3250
- const parsed = JSON.parse(raw);
3251
- return parsed.bot ?? null;
3252
- } catch {
3253
- 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];
3254
3386
  }
3255
- }
3256
3387
 
3257
- // ---------------------------------------------------------------------------
3258
- // GitHub App JWT — generates a short-lived RS256 JWT for App-level API calls
3259
- // ---------------------------------------------------------------------------
3260
-
3261
- /**
3262
- * Generate a GitHub App JWT (valid for 10 minutes).
3263
- *
3264
- * @param {string} appId - GitHub App ID (numeric string)
3265
- * @param {string} privateKey - PEM-encoded PKCS#8 or PKCS#1 RSA private key content
3266
- * @returns {string} Signed JWT
3267
- */
3268
- function generateAppJwt(appId, privateKey) {
3269
- const now = Math.floor(Date.now() / 1000);
3270
- const payload = {
3271
- iat: now - 60, // issued 60s ago to account for clock skew
3272
- exp: now + 600, // expires in 10 minutes (GitHub max)
3273
- iss: appId,
3274
- };
3275
-
3276
- const header = Buffer.from(
3277
- JSON.stringify({ alg: "RS256", typ: "JWT" }),
3278
- ).toString("base64url");
3279
- const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
3280
- const unsigned = `${header}.${body}`;
3281
-
3282
- const sign = createSign("RSA-SHA256");
3283
- sign.update(unsigned);
3284
- 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
+ }
3285
3402
 
3286
- return `${unsigned}.${signature}`;
3403
+ return null;
3287
3404
  }
3288
3405
 
3289
- // ---------------------------------------------------------------------------
3290
- // Bot — connect existing GitHub App
3291
- // ---------------------------------------------------------------------------
3292
-
3293
3406
  /**
3294
- * Interactive flow: prompt for App ID, private key path, installation ID.
3295
- * 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[] }}
3296
3419
  */
3297
- async function connectExistingBot() {
3298
- console.log("");
3299
- console.log(
3300
- box(
3301
- [
3302
- `${bold("Connect an existing GitHub App")}`,
3303
- "",
3304
- "You'll need:",
3305
- ` • App ID (GitHub → Settings → Developer settings → GitHub Apps → your app)`,
3306
- ` • Private key file (.pem downloaded from app settings)`,
3307
- ` • Installation ID (GitHub → your org → Settings → Installed Apps → your app → configure → URL)`,
3308
- ].join("\n"),
3309
- { title: "Connect GitHub App" },
3310
- ),
3311
- );
3312
- console.log("");
3420
+ async function labelsSetup(repo) {
3421
+ const manifestPath = join(__dirname, "labels.json");
3313
3422
 
3314
- const appId = await input("App ID (numeric):", "");
3315
- if (!appId || !/^\d+$/.test(appId.trim())) {
3316
- console.log(` ${RED}Invalid App ID — must be a numeric string.${RESET}`);
3317
- return false;
3318
- }
3319
-
3320
- const privateKeyPath = await input("Private key path (.pem):", "");
3321
- const trimmedKeyPath = privateKeyPath.trim();
3322
- const home = os.homedir();
3323
- const expanded = trimmedKeyPath.startsWith("~")
3324
- ? join(home, trimmedKeyPath.slice(1))
3325
- : trimmedKeyPath;
3326
- const resolvedKeyPath = resolve(expanded);
3327
- if (trimmedKeyPath.startsWith("~")) {
3328
- const rel = relative(home, resolvedKeyPath);
3329
- if (rel.startsWith("..") || isAbsolute(rel)) {
3330
- console.log(
3331
- ` ${RED}Invalid path: cannot traverse outside home directory.${RESET}`,
3332
- );
3333
- return false;
3334
- }
3335
- }
3336
- if (!existsSync(resolvedKeyPath)) {
3337
- console.log(` ${RED}File not found: ${resolvedKeyPath}${RESET}`);
3338
- return false;
3339
- }
3340
-
3341
- const installationId = await input("Installation ID (numeric):", "");
3342
- if (!installationId || !/^\d+$/.test(installationId.trim())) {
3423
+ if (!existsSync(manifestPath)) {
3343
3424
  console.log(
3344
- ` ${RED}Invalid Installation ID — must be a numeric string.${RESET}`,
3425
+ `${RED}Label manifest not found: ${manifestPath}${RESET}`,
3345
3426
  );
3346
- return false;
3427
+ process.exit(1);
3347
3428
  }
3348
3429
 
3349
- // Validate credentials with GitHub API
3350
- console.log("");
3351
- console.log(` Validating credentials…`);
3352
- let privateKey;
3430
+ /** @type {Array<{name: string, color: string, description: string}>} */
3431
+ let labels;
3353
3432
  try {
3354
- privateKey = readFileSync(resolvedKeyPath, "utf-8");
3433
+ labels = JSON.parse(readFileSync(manifestPath, "utf-8"));
3355
3434
  } catch (err) {
3356
- console.log(` ${RED}Cannot read private key: ${err.message}${RESET}`);
3357
- return false;
3435
+ console.log(`${RED}Failed to parse labels.json: ${err.message}${RESET}`);
3436
+ process.exit(1);
3358
3437
  }
3359
3438
 
3360
- try {
3361
- const jwt = generateAppJwt(appId.trim(), privateKey);
3362
- const resp = await fetch("https://api.github.com/app", {
3363
- headers: {
3364
- Authorization: `Bearer ${jwt}`,
3365
- Accept: "application/vnd.github+json",
3366
- "X-GitHub-Api-Version": "2022-11-28",
3367
- },
3368
- });
3369
- if (!resp.ok) {
3370
- const body = await resp.json().catch(() => ({}));
3371
- console.log(
3372
- ` ${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"] },
3373
3470
  );
3374
- 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}`);
3375
3489
  }
3376
- const appData = await resp.json();
3377
3490
  console.log(
3378
- ` ${GREEN}Connected!${RESET} App: ${bold(appData.name)} (id: ${appId.trim()})`,
3491
+ `\n Run ${cyan("gh auth status")} to verify GitHub authentication.`,
3379
3492
  );
3380
- } catch (err) {
3381
- console.log(` ${RED}Validation failed: ${err.message}${RESET}`);
3382
- return false;
3383
3493
  }
3384
3494
 
3385
- await saveBotCredentials({
3386
- appId: appId.trim(),
3387
- installationId: installationId.trim(),
3388
- privateKeyPath: resolvedKeyPath,
3389
- });
3390
-
3391
- console.log(` Credentials saved to ${cyan(CREDENTIALS_FILE)}`);
3392
- return true;
3495
+ console.log("");
3496
+ return { created, failed };
3393
3497
  }
3394
3498
 
3395
3499
  // ---------------------------------------------------------------------------
3396
- // Bot — status command
3500
+ // docs init — scaffold devdocs knowledge tree into a project
3397
3501
  // ---------------------------------------------------------------------------
3398
3502
 
3399
3503
  /**
3400
- * Show bot health, identity, and rate limit usage.
3401
- * Gracefully handles missing credentials.
3504
+ * Walk a directory tree and return all file paths (any extension).
3505
+ * @param {string} dir
3506
+ * @returns {Promise<string[]>}
3402
3507
  */
3403
- async function botStatus() {
3404
- console.log("");
3405
- console.log(`${BOLD}ForgeDock Bot Status${RESET}`);
3406
- 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
+ }
3407
3521
 
3408
- const creds = loadBotCredentials();
3409
- if (!creds) {
3410
- console.log(
3411
- ` ${YELLOW}No bot credentials found.${RESET}\n` +
3412
- ` Run ${cyan("npx forgedock bot setup")} to configure a GitHub App, or\n` +
3413
- ` add credentials to ${cyan(CREDENTIALS_FILE)}.`,
3414
- );
3415
- console.log("");
3416
- 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
+ }
3417
3538
  }
3418
3539
 
3419
- console.log(` App ID: ${cyan(creds.appId)}`);
3420
- console.log(` Installation ID: ${cyan(creds.installationId)}`);
3421
- console.log(` Private key: ${cyan(creds.privateKeyPath)}`);
3422
- console.log("");
3540
+ const targetDir = isAbsolute(devdocsRelPath)
3541
+ ? devdocsRelPath
3542
+ : join(cwd, devdocsRelPath);
3423
3543
 
3424
- // Check private key file
3425
- 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)) {
3426
3556
  console.log(
3427
- ` ${RED}Private key file not found: ${creds.privateKeyPath}${RESET}`,
3557
+ `${RED}Error: devdocs.path must be inside the project directory.${RESET}`,
3428
3558
  );
3429
- console.log(` Run ${cyan("npx forgedock bot setup")} to reconfigure.`);
3430
- console.log("");
3431
- 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);
3432
3565
  }
3433
3566
 
3434
- console.log(" Verifying with GitHub API…");
3435
- let privateKey;
3436
- try {
3437
- privateKey = readFileSync(creds.privateKeyPath, "utf-8");
3438
- } catch (err) {
3439
- console.log(` ${RED}Cannot read private key: ${err.message}${RESET}`);
3440
- console.log("");
3441
- return;
3442
- }
3567
+ // Template source: templates/devdocs/ inside the ForgeDock package
3568
+ const templatesDir = join(FORGE_HOME, "templates", "devdocs");
3443
3569
 
3444
- try {
3445
- const jwt = generateAppJwt(creds.appId, privateKey);
3446
-
3447
- // App identity
3448
- const appResp = await fetch("https://api.github.com/app", {
3449
- headers: {
3450
- Authorization: `Bearer ${jwt}`,
3451
- Accept: "application/vnd.github+json",
3452
- "X-GitHub-Api-Version": "2022-11-28",
3453
- },
3454
- });
3455
- if (!appResp.ok) {
3456
- const body = await appResp.json().catch(() => ({}));
3457
- console.log(
3458
- ` ${RED}GitHub API error ${appResp.status}: ${body.message ?? "unknown"}${RESET}`,
3459
- );
3460
- console.log("");
3461
- return;
3462
- }
3463
- const appData = await appResp.json();
3570
+ if (!existsSync(templatesDir)) {
3464
3571
  console.log(
3465
- ` ${GREEN}Connected${RESET} — App: ${bold(appData.name)} (slug: @${appData.slug})`,
3572
+ `${RED}DevDocs templates not found: ${templatesDir}${RESET}`,
3466
3573
  );
3467
-
3468
- // Rate limit for app
3469
- const rlResp = await fetch("https://api.github.com/rate_limit", {
3470
- headers: {
3471
- Authorization: `Bearer ${jwt}`,
3472
- Accept: "application/vnd.github+json",
3473
- "X-GitHub-Api-Version": "2022-11-28",
3474
- },
3475
- });
3476
- if (rlResp.ok) {
3477
- const rl = await rlResp.json();
3478
- const core = rl.resources?.core;
3479
- if (core) {
3480
- const used = core.limit - core.remaining;
3481
- const resetTime = new Date(core.reset * 1000).toLocaleTimeString();
3482
- console.log(
3483
- ` Rate limit: ${cyan(core.remaining)}/${core.limit} remaining (${used} used, resets ${resetTime})`,
3484
- );
3485
- }
3486
- }
3487
- } catch (err) {
3488
- 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);
3489
3581
  }
3490
3582
 
3491
- console.log("");
3492
- }
3583
+ const templateFiles = await _findAllFiles(templatesDir);
3493
3584
 
3494
- // ---------------------------------------------------------------------------
3495
- // Bot — setup TUI step (called from StepOrchestrator or directly)
3496
- // ---------------------------------------------------------------------------
3497
-
3498
- /**
3499
- * Interactive bot setup step.
3500
- * Shows options: Create new app / Connect existing / Skip.
3501
- * Called as an optional step in tuiOnboarding() or via `npx forgedock bot setup`.
3502
- */
3503
- async function botSetup() {
3504
3585
  console.log("");
3505
- console.log(
3506
- box(
3507
- [
3508
- `${bold("GitHub Bot Setup")} ${dim("(Optional)")}`,
3509
- "",
3510
- "A GitHub App gives ForgeDock its own identity",
3511
- "for pipeline operations. Benefits:",
3512
- ` ${cyan("•")} Separate audit trail for bot actions`,
3513
- ` ${cyan("•")} Higher API rate limits`,
3514
- ` ${cyan("•")} Webhook-driven automation (future)`,
3515
- ` ${cyan("•")} Multi-user team support`,
3516
- ].join("\n"),
3517
- { title: "GitHub Bot" },
3518
- ),
3519
- );
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`);
3520
3590
  console.log("");
3521
3591
 
3522
- const action = await select("How would you like to set up the bot?", [
3523
- { label: "Create new GitHub App (opens browser)", value: "create" },
3524
- { label: "Connect existing GitHub App", value: "connect" },
3525
- { label: "Skip — use personal token (current default)", value: "skip" },
3526
- ]);
3592
+ await mkdir(targetDir, { recursive: true });
3527
3593
 
3528
- if (action === "skip") {
3529
- console.log(
3530
- ` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity.`,
3531
- );
3532
- return;
3533
- }
3534
-
3535
- if (action === "create") {
3536
- // Build the GitHub App manifest URL — opens the browser-based creation flow
3537
- const manifestPath = join(dirname(__filename), "github-app-manifest.json");
3538
- let manifest;
3539
- try {
3540
- manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
3541
- } catch {
3542
- console.log(
3543
- ` ${RED}Could not read github-app-manifest.json — skipping.${RESET}`,
3544
- );
3545
- return;
3546
- }
3594
+ let copied = 0;
3595
+ let skipped = 0;
3547
3596
 
3548
- const encodedManifest = encodeURIComponent(JSON.stringify(manifest));
3549
- 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);
3550
3601
 
3551
- console.log("");
3552
- console.log(
3553
- ` ${bold("Opening GitHub App registration in your browser…")}`,
3554
- );
3555
- console.log(` URL: ${cyan(createUrl.substring(0, 80))}…`);
3556
- console.log("");
3602
+ await mkdir(destDir, { recursive: true });
3557
3603
 
3558
- // Try to open the browser
3559
- try {
3560
- const opener =
3561
- process.platform === "win32"
3562
- ? "start"
3563
- : process.platform === "darwin"
3564
- ? "open"
3565
- : "xdg-open";
3566
- // On Windows, `start "url"` treats the first quoted arg as the window title.
3567
- // Pass an empty title placeholder so the URL is treated as the target: `start "" "url"`.
3568
- const cmd =
3569
- process.platform === "win32"
3570
- ? `start "" "${createUrl}"`
3571
- : `${opener} "${createUrl}"`;
3572
- execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
3573
- } catch {
3574
- console.log(` ${YELLOW}Could not open browser automatically.${RESET}`);
3575
- console.log(` Please open this URL manually:`);
3576
- 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}`);
3577
3611
  }
3612
+ }
3578
3613
 
3579
- console.log("");
3580
- console.log(" After creating the app in your browser:");
3581
- console.log(` 1. Download the private key (.pem) from the app settings`);
3582
- console.log(` 2. Note your App ID and Installation ID`);
3614
+ console.log("");
3615
+ if (copied > 0 && skipped === 0) {
3583
3616
  console.log(
3584
- ` 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.`,
3585
3626
  );
3586
- console.log("");
3587
- return;
3588
3627
  }
3589
3628
 
3590
- // action === "connect"
3591
- const connected = await connectExistingBot();
3592
- if (!connected) {
3593
- throw new Error("Bot connection failed — credentials were not saved.");
3594
- }
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("");
3595
3637
  }
3596
3638
 
3597
3639
  function help() {
@@ -3621,13 +3663,19 @@ function help() {
3621
3663
  ` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`,
3622
3664
  );
3623
3665
  console.log(
3624
- ` ${CYAN}npx forgedock bot${RESET} Manage GitHub App bot identity`,
3666
+ ` ${CYAN}npx forgedock labels${RESET} Bootstrap ForgeDock-managed labels on a GitHub repo`,
3667
+ );
3668
+ console.log(
3669
+ ` ${CYAN}npx forgedock labels setup${RESET} Create/update all managed labels (idempotent)`,
3670
+ );
3671
+ console.log(
3672
+ ` ${CYAN}npx forgedock labels setup --repo owner/repo${RESET} Target a specific repo`,
3625
3673
  );
3626
3674
  console.log(
3627
- ` ${CYAN}npx forgedock bot status${RESET} Show bot health and rate limit usage`,
3675
+ ` ${CYAN}npx forgedock integrate${RESET} Inject/update ForgeDock usage block in project CLAUDE.md`,
3628
3676
  );
3629
3677
  console.log(
3630
- ` ${CYAN}npx forgedock bot setup${RESET} Configure GitHub App bot (interactive)`,
3678
+ ` ${CYAN}npx forgedock docs init${RESET} Scaffold devdocs knowledge tree into current project`,
3631
3679
  );
3632
3680
  console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
3633
3681
  console.log("");
@@ -3984,13 +4032,6 @@ async function tuiOnboarding() {
3984
4032
  await validate(forgeYamlPath);
3985
4033
  },
3986
4034
  },
3987
- {
3988
- name: "GitHub Bot Setup",
3989
- optional: true,
3990
- run: async () => {
3991
- await botSetup();
3992
- },
3993
- },
3994
4035
  ];
3995
4036
 
3996
4037
  const orchestrator = new StepOrchestrator(steps);
@@ -4040,15 +4081,40 @@ if (!command) {
4040
4081
  case "update":
4041
4082
  await update();
4042
4083
  break;
4043
- case "bot": {
4084
+ case "labels": {
4044
4085
  const subcommand = args[1];
4045
- if (!subcommand || subcommand === "status") {
4046
- await botStatus();
4047
- } else if (subcommand === "setup") {
4048
- 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);
4049
4097
  } else {
4050
- console.log(`${RED}Unknown bot subcommand: ${subcommand}${RESET}`);
4051
- 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
+ );
4052
4118
  process.exit(1);
4053
4119
  }
4054
4120
  break;