# CLI: Categories and Boards — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Extend `@atbb/cli` with `atbb category add` and `atbb board add` commands, plus a seeding step in `atbb init` that optionally creates a starter category and board. **Architecture:** Two new step modules (`create-category.ts`, `create-board.ts`) follow the exact pattern of `create-forum.ts` — check idempotency, write PDS record, insert DB row. Two new command files (`category.ts`, `board.ts`) expose `atbb category add` and `atbb board add` using citty's nested subcommand structure. `init.ts` gains a Step 4 that calls these step functions with interactive prompts. **Tech Stack:** TypeScript, citty (CLI routing), consola (output), @inquirer/prompts (interactive input), Drizzle ORM (DB), @atproto/api (PDS writes) --- ## Task 1: `create-category.ts` step module (TDD) **Files:** - Create: `packages/cli/src/__tests__/create-category.test.ts` - Create: `packages/cli/src/lib/steps/create-category.ts` ### Step 1: Write the failing tests Create `packages/cli/src/__tests__/create-category.test.ts`: ```typescript import { describe, it, expect, vi } from "vitest"; import { createCategory } from "../lib/steps/create-category.js"; describe("createCategory", () => { const forumDid = "did:plc:testforum"; // Builds a mock DB. If existingCategory is set, the first select() returns it. // The second select() (forum lookup) always returns a mock forum row. function mockDb(options: { existingCategory?: any } = {}) { let callCount = 0; return { select: vi.fn().mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => { callCount++; if (callCount === 1) { // First select: category idempotency check return options.existingCategory ? [options.existingCategory] : []; } // Second select: forum lookup for forumId return [{ id: BigInt(1) }]; }), }), }), })), insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined), }), } as any; } function mockAgent(overrides: Record = {}) { return { com: { atproto: { repo: { createRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${forumDid}/space.atbb.forum.category/tid123`, cid: "bafytest", }, }), ...overrides, }, }, }, } as any; } it("creates category on PDS and inserts into DB", async () => { const db = mockDb(); const agent = mockAgent(); const result = await createCategory(db, agent, forumDid, { name: "General", description: "General discussion", }); expect(result.created).toBe(true); expect(result.skipped).toBe(false); expect(result.uri).toContain("space.atbb.forum.category/tid123"); expect(result.cid).toBe("bafytest"); expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( expect.objectContaining({ repo: forumDid, collection: "space.atbb.forum.category", record: expect.objectContaining({ $type: "space.atbb.forum.category", name: "General", description: "General discussion", }), }) ); expect(db.insert).toHaveBeenCalled(); }); it("derives slug from name when not provided", async () => { const db = mockDb(); const agent = mockAgent(); await createCategory(db, agent, forumDid, { name: "My Cool Category" }); expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( expect.objectContaining({ record: expect.objectContaining({ slug: "my-cool-category" }), }) ); }); it("uses provided slug instead of deriving one", async () => { const db = mockDb(); const agent = mockAgent(); await createCategory(db, agent, forumDid, { name: "General", slug: "gen" }); expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( expect.objectContaining({ record: expect.objectContaining({ slug: "gen" }), }) ); }); it("skips when category with same name already exists", async () => { const db = mockDb({ existingCategory: { did: forumDid, rkey: "existingtid", cid: "bafyexisting", name: "General", }, }); const agent = mockAgent(); const result = await createCategory(db, agent, forumDid, { name: "General" }); expect(result.created).toBe(false); expect(result.skipped).toBe(true); expect(result.existingName).toBe("General"); expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); expect(db.insert).not.toHaveBeenCalled(); }); it("throws when PDS write fails", async () => { const db = mockDb(); const agent = mockAgent({ createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")), }); await expect( createCategory(db, agent, forumDid, { name: "General" }) ).rejects.toThrow("PDS write failed"); }); }); ``` ### Step 2: Run tests to verify they fail ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm --filter @atbb/cli exec vitest run src/__tests__/create-category.test.ts ``` Expected: FAIL with "Cannot find module '../lib/steps/create-category.js'" ### Step 3: Implement `create-category.ts` Create `packages/cli/src/lib/steps/create-category.ts`: ```typescript import type { AtpAgent } from "@atproto/api"; import type { Database } from "@atbb/db"; import { categories, forums } from "@atbb/db"; import { eq, and } from "drizzle-orm"; import { isProgrammingError } from "@atbb/atproto"; interface CreateCategoryInput { name: string; description?: string; slug?: string; sortOrder?: number; } interface CreateCategoryResult { created: boolean; skipped: boolean; uri?: string; cid?: string; existingName?: string; } function deriveSlug(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); } /** * Create a space.atbb.forum.category record on the Forum DID's PDS * and insert it into the database. * Idempotent: skips if a category with the same name already exists. */ export async function createCategory( db: Database, agent: AtpAgent, forumDid: string, input: CreateCategoryInput ): Promise { // Check if category with this name already exists const [existing] = await db .select() .from(categories) .where(and(eq(categories.did, forumDid), eq(categories.name, input.name))) .limit(1); if (existing) { return { created: false, skipped: true, uri: `at://${existing.did}/space.atbb.forum.category/${existing.rkey}`, cid: existing.cid, existingName: existing.name, }; } // Look up forum row for FK reference (optional — null if forum not yet in DB) const [forum] = await db .select() .from(forums) .where(and(eq(forums.did, forumDid), eq(forums.rkey, "self"))) .limit(1); const slug = input.slug ?? deriveSlug(input.name); const now = new Date(); let response; try { response = await agent.com.atproto.repo.createRecord({ repo: forumDid, collection: "space.atbb.forum.category", record: { $type: "space.atbb.forum.category", name: input.name, ...(input.description && { description: input.description }), slug, ...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }), createdAt: now.toISOString(), }, }); } catch (error) { if (isProgrammingError(error)) throw error; throw error; // PDS errors bubble up to command handler } const rkey = response.data.uri.split("/").pop()!; await db.insert(categories).values({ did: forumDid, rkey, cid: response.data.cid, name: input.name, description: input.description ?? null, slug, sortOrder: input.sortOrder ?? null, forumId: forum?.id ?? null, createdAt: now, indexedAt: now, }); return { created: true, skipped: false, uri: response.data.uri, cid: response.data.cid, }; } ``` ### Step 4: Run tests to verify they pass ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm --filter @atbb/cli exec vitest run src/__tests__/create-category.test.ts ``` Expected: All 5 tests PASS ### Step 5: Commit ```sh git add packages/cli/src/__tests__/create-category.test.ts packages/cli/src/lib/steps/create-category.ts git commit -m "feat: add createCategory step module (ATB-28)" ``` --- ## Task 2: `create-board.ts` step module (TDD) **Files:** - Create: `packages/cli/src/__tests__/create-board.test.ts` - Create: `packages/cli/src/lib/steps/create-board.ts` ### Step 1: Write the failing tests Create `packages/cli/src/__tests__/create-board.test.ts`: ```typescript import { describe, it, expect, vi } from "vitest"; import { createBoard } from "../lib/steps/create-board.js"; describe("createBoard", () => { const forumDid = "did:plc:testforum"; const categoryUri = `at://${forumDid}/space.atbb.forum.category/cattid`; const categoryId = BigInt(42); const categoryCid = "bafycategory"; function mockDb(options: { existingBoard?: any } = {}) { return { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue( options.existingBoard ? [options.existingBoard] : [] ), }), }), }), insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined), }), } as any; } function mockAgent(overrides: Record = {}) { return { com: { atproto: { repo: { createRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${forumDid}/space.atbb.forum.board/tid456`, cid: "bafyboard", }, }), ...overrides, }, }, }, } as any; } const baseInput = { name: "General Discussion", categoryUri, categoryId, categoryCid, }; it("creates board on PDS and inserts into DB", async () => { const db = mockDb(); const agent = mockAgent(); const result = await createBoard(db, agent, forumDid, baseInput); expect(result.created).toBe(true); expect(result.skipped).toBe(false); expect(result.uri).toContain("space.atbb.forum.board/tid456"); expect(result.cid).toBe("bafyboard"); expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( expect.objectContaining({ repo: forumDid, collection: "space.atbb.forum.board", record: expect.objectContaining({ $type: "space.atbb.forum.board", name: "General Discussion", // Board record includes the category ref nested under "category" category: { category: { uri: categoryUri, cid: categoryCid }, }, }), }) ); expect(db.insert).toHaveBeenCalled(); }); it("derives slug from name", async () => { const db = mockDb(); const agent = mockAgent(); await createBoard(db, agent, forumDid, { ...baseInput, name: "Off Topic Chat", }); expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( expect.objectContaining({ record: expect.objectContaining({ slug: "off-topic-chat" }), }) ); }); it("skips when board with same name exists in the same category", async () => { const db = mockDb({ existingBoard: { did: forumDid, rkey: "existingtid", cid: "bafyexisting", name: "General Discussion", }, }); const agent = mockAgent(); const result = await createBoard(db, agent, forumDid, baseInput); expect(result.created).toBe(false); expect(result.skipped).toBe(true); expect(result.existingName).toBe("General Discussion"); expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); expect(db.insert).not.toHaveBeenCalled(); }); it("throws when PDS write fails", async () => { const db = mockDb(); const agent = mockAgent({ createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")), }); await expect(createBoard(db, agent, forumDid, baseInput)).rejects.toThrow( "PDS write failed" ); }); }); ``` ### Step 2: Run tests to verify they fail ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm --filter @atbb/cli exec vitest run src/__tests__/create-board.test.ts ``` Expected: FAIL with "Cannot find module '../lib/steps/create-board.js'" ### Step 3: Implement `create-board.ts` Create `packages/cli/src/lib/steps/create-board.ts`: ```typescript import type { AtpAgent } from "@atproto/api"; import type { Database } from "@atbb/db"; import { boards } from "@atbb/db"; import { eq, and } from "drizzle-orm"; import { isProgrammingError } from "@atbb/atproto"; interface CreateBoardInput { name: string; description?: string; slug?: string; sortOrder?: number; categoryUri: string; // AT URI: at://did/space.atbb.forum.category/rkey categoryId: bigint; // DB FK categoryCid: string; // CID for the category strongRef } interface CreateBoardResult { created: boolean; skipped: boolean; uri?: string; cid?: string; existingName?: string; } function deriveSlug(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); } /** * Create a space.atbb.forum.board record on the Forum DID's PDS * and insert it into the database. * Idempotent: skips if a board with the same name in the same category exists. */ export async function createBoard( db: Database, agent: AtpAgent, forumDid: string, input: CreateBoardInput ): Promise { // Check if board with this name already exists in the category const [existing] = await db .select() .from(boards) .where( and( eq(boards.did, forumDid), eq(boards.name, input.name), eq(boards.categoryUri, input.categoryUri) ) ) .limit(1); if (existing) { return { created: false, skipped: true, uri: `at://${existing.did}/space.atbb.forum.board/${existing.rkey}`, cid: existing.cid, existingName: existing.name, }; } const slug = input.slug ?? deriveSlug(input.name); const now = new Date(); let response; try { response = await agent.com.atproto.repo.createRecord({ repo: forumDid, collection: "space.atbb.forum.board", record: { $type: "space.atbb.forum.board", name: input.name, ...(input.description && { description: input.description }), slug, ...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }), // categoryRef shape: { category: strongRef } category: { category: { uri: input.categoryUri, cid: input.categoryCid, }, }, createdAt: now.toISOString(), }, }); } catch (error) { if (isProgrammingError(error)) throw error; throw error; } const rkey = response.data.uri.split("/").pop()!; await db.insert(boards).values({ did: forumDid, rkey, cid: response.data.cid, name: input.name, description: input.description ?? null, slug, sortOrder: input.sortOrder ?? null, categoryId: input.categoryId, categoryUri: input.categoryUri, createdAt: now, indexedAt: now, }); return { created: true, skipped: false, uri: response.data.uri, cid: response.data.cid, }; } ``` ### Step 4: Run tests to verify they pass ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm --filter @atbb/cli exec vitest run src/__tests__/create-board.test.ts ``` Expected: All 4 tests PASS ### Step 5: Commit ```sh git add packages/cli/src/__tests__/create-board.test.ts packages/cli/src/lib/steps/create-board.ts git commit -m "feat: add createBoard step module (ATB-28)" ``` --- ## Task 3: `atbb category add` command **Files:** - Create: `packages/cli/src/commands/category.ts` ### Step 1: Implement `category.ts` Create `packages/cli/src/commands/category.ts`: ```typescript import { defineCommand } from "citty"; import consola from "consola"; import { input } from "@inquirer/prompts"; import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import * as schema from "@atbb/db"; import { ForumAgent } from "@atbb/atproto"; import { loadCliConfig } from "../lib/config.js"; import { checkEnvironment } from "../lib/preflight.js"; import { createCategory } from "../lib/steps/create-category.js"; const categoryAddCommand = defineCommand({ meta: { name: "add", description: "Add a new category to the forum", }, args: { name: { type: "string", description: "Category name", }, description: { type: "string", description: "Category description (optional)", }, slug: { type: "string", description: "URL-friendly identifier (auto-derived from name if omitted)", }, "sort-order": { type: "string", description: "Numeric sort position — lower values appear first", }, }, async run({ args }) { consola.box("atBB — Add Category"); const config = loadCliConfig(); const envCheck = checkEnvironment(config); if (!envCheck.ok) { consola.error("Missing required environment variables:"); for (const name of envCheck.errors) { consola.error(` - ${name}`); } consola.info("Set these in your .env file or environment, then re-run."); process.exit(1); } const sql = postgres(config.databaseUrl); const db = drizzle(sql, { schema }); async function cleanup() { await sql.end(); } try { await sql`SELECT 1`; consola.success("Database connection successful"); } catch (error) { consola.error( "Failed to connect to database:", error instanceof Error ? error.message : String(error) ); await cleanup(); process.exit(1); } consola.start("Authenticating as Forum DID..."); const forumAgent = new ForumAgent( config.pdsUrl, config.forumHandle, config.forumPassword ); await forumAgent.initialize(); if (!forumAgent.isAuthenticated()) { const status = forumAgent.getStatus(); consola.error(`Failed to authenticate: ${status.error}`); await forumAgent.shutdown(); await cleanup(); process.exit(1); } const agent = forumAgent.getAgent()!; consola.success(`Authenticated as ${config.forumHandle}`); const name = args.name ?? (await input({ message: "Category name:", default: "General" })); const description = args.description ?? (await input({ message: "Category description (optional):" })); const sortOrderRaw = args["sort-order"]; const sortOrder = sortOrderRaw !== undefined ? parseInt(sortOrderRaw, 10) : undefined; try { const result = await createCategory(db, agent, config.forumDid, { name, ...(description && { description }), ...(args.slug && { slug: args.slug }), ...(sortOrder !== undefined && !isNaN(sortOrder) && { sortOrder }), }); if (result.skipped) { consola.warn(`Category "${result.existingName}" already exists: ${result.uri}`); } else { consola.success(`Created category "${name}"`); consola.info(`URI: ${result.uri}`); } } catch (error) { consola.error( "Failed to create category:", error instanceof Error ? error.message : String(error) ); await forumAgent.shutdown(); await cleanup(); process.exit(1); } await forumAgent.shutdown(); await cleanup(); }, }); export const categoryCommand = defineCommand({ meta: { name: "category", description: "Manage forum categories", }, subCommands: { add: categoryAddCommand, }, }); ``` ### Step 2: Register `categoryCommand` in `index.ts` Open `packages/cli/src/index.ts` and add the import + subcommand entry: ```typescript // Add this import (after existing imports): import { categoryCommand } from "./commands/category.js"; // Update subCommands: subCommands: { init: initCommand, category: categoryCommand, // ← add this line }, ``` ### Step 3: Build to verify no TypeScript errors ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm --filter @atbb/cli lint ``` Expected: No errors ### Step 4: Commit ```sh git add packages/cli/src/commands/category.ts packages/cli/src/index.ts git commit -m "feat: add atbb category add command (ATB-28)" ``` --- ## Task 4: `atbb board add` command **Files:** - Create: `packages/cli/src/commands/board.ts` - Modify: `packages/cli/src/index.ts` ### Step 1: Implement `board.ts` Create `packages/cli/src/commands/board.ts`: ```typescript import { defineCommand } from "citty"; import consola from "consola"; import { input, select } from "@inquirer/prompts"; import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import * as schema from "@atbb/db"; import { categories } from "@atbb/db"; import { eq, and } from "drizzle-orm"; import { ForumAgent } from "@atbb/atproto"; import { loadCliConfig } from "../lib/config.js"; import { checkEnvironment } from "../lib/preflight.js"; import { createBoard } from "../lib/steps/create-board.js"; const boardAddCommand = defineCommand({ meta: { name: "add", description: "Add a new board within a category", }, args: { "category-uri": { type: "string", description: "AT URI of the parent category (e.g. at://did/space.atbb.forum.category/rkey)", }, name: { type: "string", description: "Board name", }, description: { type: "string", description: "Board description (optional)", }, slug: { type: "string", description: "URL-friendly identifier (auto-derived from name if omitted)", }, "sort-order": { type: "string", description: "Numeric sort position — lower values appear first", }, }, async run({ args }) { consola.box("atBB — Add Board"); const config = loadCliConfig(); const envCheck = checkEnvironment(config); if (!envCheck.ok) { consola.error("Missing required environment variables:"); for (const name of envCheck.errors) { consola.error(` - ${name}`); } consola.info("Set these in your .env file or environment, then re-run."); process.exit(1); } const sql = postgres(config.databaseUrl); const db = drizzle(sql, { schema }); async function cleanup() { await sql.end(); } try { await sql`SELECT 1`; consola.success("Database connection successful"); } catch (error) { consola.error( "Failed to connect to database:", error instanceof Error ? error.message : String(error) ); await cleanup(); process.exit(1); } consola.start("Authenticating as Forum DID..."); const forumAgent = new ForumAgent( config.pdsUrl, config.forumHandle, config.forumPassword ); await forumAgent.initialize(); if (!forumAgent.isAuthenticated()) { const status = forumAgent.getStatus(); consola.error(`Failed to authenticate: ${status.error}`); await forumAgent.shutdown(); await cleanup(); process.exit(1); } const agent = forumAgent.getAgent()!; consola.success(`Authenticated as ${config.forumHandle}`); // Resolve parent category let categoryUri: string; let categoryId: bigint; let categoryCid: string; if (args["category-uri"]) { // Validate by looking it up in the DB // Parse AT URI: at://{did}/{collection}/{rkey} const parts = args["category-uri"].split("/"); const did = parts[2]; const rkey = parts[parts.length - 1]; const [found] = await db .select() .from(categories) .where(and(eq(categories.did, did), eq(categories.rkey, rkey))) .limit(1); if (!found) { consola.error(`Category not found: ${args["category-uri"]}`); consola.info("Create it first with: atbb category add"); await forumAgent.shutdown(); await cleanup(); process.exit(1); } categoryUri = args["category-uri"]; categoryId = found.id; categoryCid = found.cid; } else { // Interactive selection from all categories in the forum const allCategories = await db .select() .from(categories) .where(eq(categories.did, config.forumDid)) .limit(100); if (allCategories.length === 0) { consola.error("No categories found in the database."); consola.info("Create one first with: atbb category add"); await forumAgent.shutdown(); await cleanup(); process.exit(1); } const chosen = await select({ message: "Select parent category:", choices: allCategories.map((c) => ({ name: c.description ? `${c.name} — ${c.description}` : c.name, value: c, })), }); categoryUri = `at://${chosen.did}/space.atbb.forum.category/${chosen.rkey}`; categoryId = chosen.id; categoryCid = chosen.cid; } const name = args.name ?? (await input({ message: "Board name:", default: "General Discussion" })); const description = args.description ?? (await input({ message: "Board description (optional):" })); const sortOrderRaw = args["sort-order"]; const sortOrder = sortOrderRaw !== undefined ? parseInt(sortOrderRaw, 10) : undefined; try { const result = await createBoard(db, agent, config.forumDid, { name, ...(description && { description }), ...(args.slug && { slug: args.slug }), ...(sortOrder !== undefined && !isNaN(sortOrder) && { sortOrder }), categoryUri, categoryId, categoryCid, }); if (result.skipped) { consola.warn(`Board "${result.existingName}" already exists: ${result.uri}`); } else { consola.success(`Created board "${name}"`); consola.info(`URI: ${result.uri}`); } } catch (error) { consola.error( "Failed to create board:", error instanceof Error ? error.message : String(error) ); await forumAgent.shutdown(); await cleanup(); process.exit(1); } await forumAgent.shutdown(); await cleanup(); }, }); export const boardCommand = defineCommand({ meta: { name: "board", description: "Manage forum boards", }, subCommands: { add: boardAddCommand, }, }); ``` ### Step 2: Register `boardCommand` in `index.ts` Add to `packages/cli/src/index.ts`: ```typescript // Add import (after categoryCommand import): import { boardCommand } from "./commands/board.js"; // Update subCommands: subCommands: { init: initCommand, category: categoryCommand, board: boardCommand, // ← add this line }, ``` ### Step 3: Build to verify no TypeScript errors ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm --filter @atbb/cli lint ``` Expected: No errors ### Step 4: Commit ```sh git add packages/cli/src/commands/board.ts packages/cli/src/index.ts git commit -m "feat: add atbb board add command (ATB-28)" ``` --- ## Task 5: Extend `init` with Step 4 — seed initial structure **Files:** - Modify: `packages/cli/src/commands/init.ts` ### Step 1: Add imports for new step functions and confirm prompt At the top of `packages/cli/src/commands/init.ts`, add: ```typescript import { confirm } from "@inquirer/prompts"; import { createCategory } from "../lib/steps/create-category.js"; import { createBoard } from "../lib/steps/create-board.js"; ``` ### Step 2: Add Step 4 to the run() function Locate the end of Step 3 (the `assignOwnerRole` try-catch block that ends around line 176), then add before the cleanup/success box block: ```typescript // Step 6: Seed initial categories and boards (optional) consola.log(""); consola.info("Step 4: Seed Initial Structure"); const shouldSeed = await confirm({ message: "Seed an initial category and board?", default: true, }); if (shouldSeed) { const categoryName = await input({ message: "Category name:", default: "General", }); const categoryDescription = await input({ message: "Category description (optional):", }); let categoryUri: string | undefined; let categoryId: bigint | undefined; let categoryCid: string | undefined; try { const categoryResult = await createCategory(db, agent, config.forumDid, { name: categoryName, ...(categoryDescription && { description: categoryDescription }), }); if (categoryResult.skipped) { consola.warn(`Category "${categoryResult.existingName}" already exists`); } else { consola.success(`Created category "${categoryName}": ${categoryResult.uri}`); } categoryUri = categoryResult.uri; categoryCid = categoryResult.cid; // Look up the categoryId from DB (needed for board FK) const { categories } = await import("@atbb/db"); const { eq, and } = await import("drizzle-orm"); const parts = categoryUri!.split("/"); const rkey = parts[parts.length - 1]; const [cat] = await db .select() .from(categories) .where(and(eq(categories.did, config.forumDid), eq(categories.rkey, rkey))) .limit(1); categoryId = cat?.id; } catch (error) { consola.error("Failed to create category:", error instanceof Error ? error.message : String(error)); await forumAgent.shutdown(); await cleanup(); process.exit(1); } if (categoryUri && categoryId && categoryCid) { const boardName = await input({ message: "Board name:", default: "General Discussion", }); const boardDescription = await input({ message: "Board description (optional):", }); try { const boardResult = await createBoard(db, agent, config.forumDid, { name: boardName, ...(boardDescription && { description: boardDescription }), categoryUri, categoryId, categoryCid, }); if (boardResult.skipped) { consola.warn(`Board "${boardResult.existingName}" already exists`); } else { consola.success(`Created board "${boardName}": ${boardResult.uri}`); } } catch (error) { consola.error("Failed to create board:", error instanceof Error ? error.message : String(error)); await forumAgent.shutdown(); await cleanup(); process.exit(1); } } } else { consola.info("Skipped. Add categories later with: atbb category add"); } ``` **Note on the dynamic imports above:** The cleaner approach is to move the `categories` and `drizzle-orm` imports to the top of the file alongside the existing imports. Specifically add to the top of `init.ts`: ```typescript import { categories } from "@atbb/db"; import { eq, and } from "drizzle-orm"; import { confirm } from "@inquirer/prompts"; import { createCategory } from "../lib/steps/create-category.js"; import { createBoard } from "../lib/steps/create-board.js"; ``` And replace the dynamic import block in Step 4 with direct references to the top-level imports. ### Step 3: Update the success message Find the `consola.box` success message at the end of `init.ts`. Update the "Next steps" message to remove the "Create categories and boards" note (they've been created): Replace the `message` array with: ```typescript message: [ "Next steps:", " 1. Start the appview: pnpm --filter @atbb/appview dev", " 2. Start the web UI: pnpm --filter @atbb/web dev", ` 3. Log in as ${ownerInput} to access admin features`, " 4. Add more boards: atbb board add", " 5. Add more categories: atbb category add", ].join("\n"), ``` ### Step 4: Build to verify no TypeScript errors ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm --filter @atbb/cli lint ``` Expected: No errors ### Step 5: Run all CLI tests ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm --filter @atbb/cli exec vitest run ``` Expected: All tests PASS (existing tests unaffected, new tests pass) ### Step 6: Commit ```sh git add packages/cli/src/commands/init.ts git commit -m "feat: extend init with optional category/board seeding step (ATB-28)" ``` --- ## Task 6: Final verification ### Step 1: Run all tests across the monorepo ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test ``` Expected: All tests PASS ### Step 2: Typecheck all packages ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm turbo lint ``` Expected: No TypeScript errors ### Step 3: Build ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm build ``` Expected: Build succeeds ### Step 4: Smoke test the CLI (optional, requires running database) ```sh export PATH="/Users/malpercio/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" # Verify new commands appear in help pnpm --filter @atbb/cli dev -- category --help pnpm --filter @atbb/cli dev -- board --help pnpm --filter @atbb/cli dev -- category add --help pnpm --filter @atbb/cli dev -- board add --help ``` Expected: Help text showing flags for each command --- ## Summary of New Files | File | Purpose | |------|---------| | `packages/cli/src/lib/steps/create-category.ts` | Step module — idempotent PDS + DB write for categories | | `packages/cli/src/lib/steps/create-board.ts` | Step module — idempotent PDS + DB write for boards | | `packages/cli/src/commands/category.ts` | `atbb category add` command | | `packages/cli/src/commands/board.ts` | `atbb board add` command with interactive category selection | | `packages/cli/src/__tests__/create-category.test.ts` | Tests for createCategory step | | `packages/cli/src/__tests__/create-board.test.ts` | Tests for createBoard step | ## Modified Files | File | Change | |------|--------| | `packages/cli/src/index.ts` | Register `category` and `board` subcommands | | `packages/cli/src/commands/init.ts` | Add Step 4: optional category/board seeding |