WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

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:

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<string, any> = {}) {
    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#

export PATH="/Users/jacob.zweifel/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:

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<CreateCategoryResult> {
  // 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#

export PATH="/Users/jacob.zweifel/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#

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:

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<string, any> = {}) {
    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#

export PATH="/Users/jacob.zweifel/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:

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<CreateBoardResult> {
  // 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#

export PATH="/Users/jacob.zweifel/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#

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:

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:

// 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#

export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli lint

Expected: No errors

Step 4: Commit#

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:

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:

// 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#

export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli lint

Expected: No errors

Step 4: Commit#

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:

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:

// 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:

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:

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#

export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli lint

Expected: No errors

Step 5: Run all CLI tests#

export PATH="/Users/jacob.zweifel/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#

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#

export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm test

Expected: All tests PASS

Step 2: Typecheck all packages#

export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm turbo lint

Expected: No TypeScript errors

Step 3: Build#

export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm build

Expected: Build succeeds

Step 4: Smoke test the CLI (optional, requires running database)#

export PATH="/Users/jacob.zweifel/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