prdforge-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,9 @@
1
+ # PRDForge CLI — Environment Variables
2
+ # Copy to .env and fill in your values.
3
+ # Alternatively, run 'prdforge auth login' to store your key interactively.
4
+
5
+ # Your PRDForge API key (get this from Dashboard → Settings → API Keys)
6
+ PRDFORGE_API_KEY=
7
+
8
+ # Override the default API base URL (optional — only for self-hosted or staging)
9
+ # PRDFORGE_API_URL=https://jnlkzcmeiksqljnbtfhb.supabase.co/functions/v1
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # PRDForge CLI
2
+
3
+ The official command-line interface for [PRDForge](https://prdforge.dev) — generate, manage, and export AI-powered Product Requirements Documents from your terminal, CI pipeline, or AI agents.
4
+
5
+ **PRDForge** turns a product idea into a full blueprint in minutes: structured PRD, architecture, guardrails, and task breakdowns—ready for Cursor, Claude Code, Lovable, or any tool that speaks MCP. This CLI gives you the same power from the command line.
6
+
7
+ **[→ prdforge.dev](https://prdforge.dev)** · [Web app](https://prdforge.dev) (dashboard, API keys, export)
8
+
9
+ ---
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install -g prdforge-cli
15
+ # or run directly
16
+ npx prdforge-cli
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ # 1. Authenticate with your API key
23
+ prdforge auth login
24
+
25
+ # 2. Create a new PRD from a prompt
26
+ prdforge prd create --name "My App" --prompt "A task manager for remote teams"
27
+
28
+ # 3. Export to Markdown for Claude Code / Cursor
29
+ prdforge export <projectId> --format markdown
30
+ ```
31
+
32
+ ## Authentication
33
+
34
+ Get your API key from the PRDForge dashboard: **Dashboard → Settings → API Keys**
35
+
36
+ Requires an active **Pro** or **Starter** subscription.
37
+
38
+ ```bash
39
+ prdforge auth login # Set your API key interactively
40
+ prdforge auth status # Check authentication status
41
+ prdforge auth logout # Remove stored key
42
+ ```
43
+
44
+ You can also set `PRDFORGE_API_KEY` as an environment variable (useful for CI/CD and agents).
45
+
46
+ ## Commands
47
+
48
+ ### `prdforge prd`
49
+
50
+ ```bash
51
+ prdforge prd list # List all projects
52
+ prdforge prd get <id> # Show all PRD sections
53
+ prdforge prd get <id> --section feature_list # Show a specific section
54
+ prdforge prd create --name "App" --prompt "..." # Generate a new PRD
55
+ prdforge prd update <id> --prompt "..." # Smart-merge an update
56
+ prdforge prd refresh <id> # Regenerate stale stages
57
+ ```
58
+
59
+ ### `prdforge export`
60
+
61
+ ```bash
62
+ prdforge export <id> # Export as Markdown (default)
63
+ prdforge export <id> --format json # Export as JSON
64
+ prdforge export <id> --output ./CLAUDE.md # Custom output path
65
+ prdforge export <id> --stdout # Print to stdout (pipe to files/agents)
66
+ ```
67
+
68
+ ### `prdforge watch`
69
+
70
+ ```bash
71
+ prdforge watch <projectId> # Poll project and re-export PRD.md when it changes
72
+ prdforge watch <id> -o ./PRD.md -i 60 # Custom output path and 60s interval
73
+ ```
74
+
75
+ ### `prdforge mcp`
76
+
77
+ ```bash
78
+ prdforge mcp serve # Run MCP server on stdio (for Cursor / MCP clients)
79
+ ```
80
+
81
+ Exposes tools: `prdforge_list_projects`, `prdforge_get_prd`, `prdforge_get_sections`.
82
+
83
+ ### `prdforge bulk`
84
+
85
+ ```bash
86
+ prdforge bulk ideas.csv # Generate PRDs from CSV (name, prompt columns)
87
+ prdforge bulk ideas.json --dry-run # JSON array of { name, prompt }; --dry-run to preview
88
+ prdforge bulk ideas.csv --delay 5000 # Delay between each generation (ms)
89
+ ```
90
+
91
+ ### `prdforge config`
92
+
93
+ ```bash
94
+ prdforge config list # Show all config values
95
+ prdforge config set outputDir ./exports # Change export directory
96
+ prdforge config set defaultFormat json # Change default export format
97
+ prdforge config get outputDir
98
+ ```
99
+
100
+ ## Agent / MCP Usage
101
+
102
+ The CLI is designed to work with AI agents and automation workflows:
103
+
104
+ ```bash
105
+ # Example: Agent generates a PRD, pipes markdown to Claude Code
106
+ prdforge prd create --name "Scheduler" --prompt "$PROMPT" --json | \
107
+ jq -r '.project.id' | \
108
+ xargs -I{} prdforge export {} --stdout > CLAUDE.md
109
+
110
+ # Example: Bulk PRD generation from a list of ideas
111
+ cat ideas.txt | while read idea; do
112
+ prdforge prd create --name "$(echo $idea | cut -c1-30)" --prompt "$idea"
113
+ done
114
+ ```
115
+
116
+ ## Environment Variables
117
+
118
+ | Variable | Description |
119
+ |----------|-------------|
120
+ | `PRDFORGE_API_KEY` | Your API key (overrides stored key) |
121
+ | `PRDFORGE_API_URL` | Custom API base URL (for self-hosted) |
122
+
123
+ ## Sync with Main App
124
+
125
+ See [`SYNC.md`](./SYNC.md) for notes on how this CLI stays in sync with the PRDForge web app and API.
126
+
127
+ ## License
128
+
129
+ MIT
package/bin/prdforge ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../src/index.js";
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "prdforge-cli",
3
+ "version": "0.1.0",
4
+ "description": "Official CLI for PRDForge — generate and manage PRDs from your terminal or AI agents",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "prdforge": "bin/prdforge"
8
+ },
9
+ "type": "module",
10
+ "scripts": {
11
+ "start": "node src/index.js",
12
+ "dev": "node --watch src/index.js",
13
+ "test": "node --test src/**/*.test.js",
14
+ "link": "npm link"
15
+ },
16
+ "keywords": [
17
+ "prdforge",
18
+ "prd",
19
+ "product",
20
+ "requirements",
21
+ "ai",
22
+ "cli",
23
+ "agent",
24
+ "mcp"
25
+ ],
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "files": [
31
+ "bin",
32
+ "src",
33
+ "README.md",
34
+ ".env.example"
35
+ ],
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.27.1",
38
+ "chalk": "^5.3.0",
39
+ "cli-table3": "^0.6.5",
40
+ "commander": "^12.1.0",
41
+ "conf": "^13.0.0",
42
+ "dotenv": "^16.4.5",
43
+ "ink": "^5.2.1",
44
+ "ink-spinner": "^5.0.0",
45
+ "ink-text-input": "^6.0.0",
46
+ "inquirer": "^10.2.2",
47
+ "marked": "^14.1.2",
48
+ "marked-terminal": "^7.1.0",
49
+ "node-fetch": "^3.3.2",
50
+ "ora": "^8.1.1",
51
+ "react": "^18.3.1"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^22.0.0",
55
+ "@types/react": "^18.3.0"
56
+ }
57
+ }
@@ -0,0 +1,164 @@
1
+ import { getApiUrl, requireApiKey } from "../utils/config.js";
2
+
3
+ /** User-facing messages for HTTP status codes (centralized for CLI and agents). */
4
+ function messageForStatus(status, bodyMessage) {
5
+ if (bodyMessage && typeof bodyMessage === "string") return bodyMessage;
6
+ switch (status) {
7
+ case 401:
8
+ return "Invalid or expired API key. Run `prdforge auth login` or check your key at https://prdforge.app (Settings → CLI Keys).";
9
+ case 402:
10
+ return "Credits exhausted. Upgrade your plan or wait until next month. See https://prdforge.app/subscription";
11
+ case 403:
12
+ return "Access denied. This action requires a paid plan (Starter or Pro).";
13
+ case 404:
14
+ return "Project or resource not found.";
15
+ case 429:
16
+ return "Rate limit exceeded. Try again in a moment.";
17
+ default:
18
+ if (status >= 500) return "PRDForge service error. Try again later.";
19
+ return `Request failed (HTTP ${status}).`;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Make an authenticated request to the PRDForge API (Supabase Edge Functions).
25
+ * Throws an Error with user-facing message and .httpStatus for exit code handling.
26
+ */
27
+ async function request(path, { method = "GET", body } = {}) {
28
+ const apiKey = requireApiKey();
29
+ const baseUrl = getApiUrl();
30
+ const url = `${baseUrl}${path}`;
31
+
32
+ const headers = {
33
+ "Content-Type": "application/json",
34
+ "Authorization": `Bearer ${apiKey}`,
35
+ "x-prdforge-cli": "1",
36
+ };
37
+
38
+ let res;
39
+ try {
40
+ res = await fetch(url, {
41
+ method,
42
+ headers,
43
+ body: body ? JSON.stringify(body) : undefined,
44
+ });
45
+ } catch (e) {
46
+ const err = new Error("Could not reach PRDForge. Check your connection and API URL.");
47
+ err.httpStatus = 0;
48
+ throw err;
49
+ }
50
+
51
+ if (!res.ok) {
52
+ let bodyMessage;
53
+ try {
54
+ const errJson = await res.json();
55
+ bodyMessage = errJson.error || errJson.message;
56
+ } catch {
57
+ bodyMessage = null;
58
+ }
59
+ const err = new Error(messageForStatus(res.status, bodyMessage));
60
+ err.httpStatus = res.status;
61
+ throw err;
62
+ }
63
+
64
+ return res.json();
65
+ }
66
+
67
+ /** Use for exit code: 2 = auth error, 1 = other. */
68
+ export function isAuthError(err) {
69
+ return err?.httpStatus === 401 || err?.httpStatus === 403;
70
+ }
71
+
72
+ // ─── Projects ────────────────────────────────────────────────────────────────
73
+
74
+ export const projects = {
75
+ list: () => request("/prdforge-api/projects"),
76
+ get: (id) => request(`/prdforge-api/projects/${id}`),
77
+ create: (name, description) =>
78
+ request("/prdforge-api/projects", {
79
+ method: "POST",
80
+ body: { name, description },
81
+ }),
82
+ delete: (id) => request(`/prdforge-api/projects/${id}`, { method: "DELETE" }),
83
+ };
84
+
85
+ // ─── PRD Generation ───────────────────────────────────────────────────────────
86
+
87
+ export const prd = {
88
+ generate: (projectId, prompt, modelId) =>
89
+ request("/prdforge-ai", {
90
+ method: "POST",
91
+ body: {
92
+ action: "generate_from_prompt",
93
+ payload: { project_id: projectId, prompt },
94
+ ...(modelId ? { model_id: modelId } : {}),
95
+ },
96
+ }),
97
+
98
+ update: (projectId, prompt, modelId) =>
99
+ request("/prdforge-ai", {
100
+ method: "POST",
101
+ body: {
102
+ action: "smart_update",
103
+ payload: { project_id: projectId, prompt },
104
+ ...(modelId ? { model_id: modelId } : {}),
105
+ },
106
+ }),
107
+
108
+ getSections: (projectId) => request(`/prdforge-api/projects/${projectId}/sections`),
109
+
110
+ regenerateStale: (projectId, modelId) =>
111
+ request("/prdforge-ai", {
112
+ method: "POST",
113
+ body: {
114
+ action: "regenerate_stale",
115
+ payload: { project_id: projectId },
116
+ ...(modelId ? { model_id: modelId } : {}),
117
+ },
118
+ }),
119
+ };
120
+
121
+ // ─── Export ───────────────────────────────────────────────────────────────────
122
+
123
+ export const exports_ = {
124
+ export: (projectId, format) =>
125
+ request(`/prdforge-api/projects/${projectId}/export`, {
126
+ method: "POST",
127
+ body: { format },
128
+ }),
129
+ };
130
+
131
+ // ─── Auth ────────────────────────────────────────────────────────────────────
132
+
133
+ export const auth = {
134
+ validate: (apiKey) =>
135
+ fetch(`${getApiUrl()}/prdforge-api/auth/validate`, {
136
+ headers: { Authorization: `Bearer ${apiKey}` },
137
+ }).then((r) => r.json()),
138
+ };
139
+
140
+ // ─── User ────────────────────────────────────────────────────────────────────
141
+
142
+ export const user = {
143
+ validate: () => request("/prdforge-api/auth/validate"),
144
+ };
145
+
146
+ // ─── Models ──────────────────────────────────────────────────────────────────
147
+
148
+ const FALLBACK_MODELS = [
149
+ { model_id: null, name: "Default", is_free_tier: true, credit_cost: 4 },
150
+ { model_id: "standard", name: "Standard", is_free_tier: true, credit_cost: 4 },
151
+ { model_id: "premium", name: "Premium", is_free_tier: false, credit_cost: 8 },
152
+ { model_id: "advanced", name: "Advanced", is_free_tier: false, credit_cost: 16 },
153
+ ];
154
+
155
+ export const models = {
156
+ list: async () => {
157
+ try {
158
+ const data = await request("/prdforge-api/models");
159
+ return Array.isArray(data) && data.length > 0 ? data : FALLBACK_MODELS;
160
+ } catch {
161
+ return FALLBACK_MODELS;
162
+ }
163
+ },
164
+ };
@@ -0,0 +1,34 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import { isAuthError } from "./client.js";
4
+
5
+ describe("client", () => {
6
+ describe("isAuthError", () => {
7
+ it("returns true for 401", () => {
8
+ const err = new Error("Unauthorized");
9
+ err.httpStatus = 401;
10
+ assert.strictEqual(isAuthError(err), true);
11
+ });
12
+
13
+ it("returns true for 403", () => {
14
+ const err = new Error("Forbidden");
15
+ err.httpStatus = 403;
16
+ assert.strictEqual(isAuthError(err), true);
17
+ });
18
+
19
+ it("returns false for 404", () => {
20
+ const err = new Error("Not found");
21
+ err.httpStatus = 404;
22
+ assert.strictEqual(isAuthError(err), false);
23
+ });
24
+
25
+ it("returns false when httpStatus is missing", () => {
26
+ assert.strictEqual(isAuthError(new Error("Network")), false);
27
+ });
28
+
29
+ it("returns false for null/undefined", () => {
30
+ assert.strictEqual(isAuthError(null), false);
31
+ assert.strictEqual(isAuthError(undefined), false);
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,160 @@
1
+ import { Command } from "commander";
2
+ import { config, getApiKey, getEmail, setEmail, getAuthBase } from "../utils/config.js";
3
+ import { success, error, info, dim, header } from "../utils/output.js";
4
+
5
+ export function authCommand() {
6
+ const cmd = new Command("auth").description("Manage authentication and API keys");
7
+
8
+ cmd
9
+ .command("login")
10
+ .description("Authenticate with PRDForge (email OTP or API key)")
11
+ .option("-k, --key <key>", "Paste an API key directly (or set PRDFORGE_API_KEY env var)")
12
+ .action(async (opts) => {
13
+ const { default: inquirer } = await import("inquirer");
14
+
15
+ // If -k key was provided, skip the menu and save directly
16
+ if (opts.key) {
17
+ config.set("apiKey", opts.key);
18
+ success("API key saved. You are now authenticated.");
19
+ dim(" Key stored in: " + config.path);
20
+ info("Run 'prdforge prd list' to see your projects.");
21
+ return;
22
+ }
23
+
24
+ // Show auth method menu
25
+ const { method } = await inquirer.prompt([
26
+ {
27
+ type: "list",
28
+ name: "method",
29
+ message: "How do you want to authenticate?",
30
+ choices: [
31
+ { name: "Email + verification code (recommended)", value: "otp" },
32
+ { name: "Paste an API key directly", value: "key" },
33
+ ],
34
+ },
35
+ ]);
36
+
37
+ if (method === "key") {
38
+ const { apiKey } = await inquirer.prompt([
39
+ {
40
+ type: "password",
41
+ name: "apiKey",
42
+ message: "Enter your PRDForge API key:",
43
+ validate: (v) => v.length > 10 || "Key too short",
44
+ },
45
+ ]);
46
+ config.set("apiKey", apiKey);
47
+ success("API key saved. You are now authenticated.");
48
+ dim(" Key stored in: " + config.path);
49
+ info("Run 'prdforge prd list' to see your projects.");
50
+ return;
51
+ }
52
+
53
+ // OTP email flow
54
+ const { email } = await inquirer.prompt([
55
+ {
56
+ type: "input",
57
+ name: "email",
58
+ message: "Email address:",
59
+ validate: (v) => v.includes("@") || "Please enter a valid email",
60
+ },
61
+ ]);
62
+
63
+ const authBase = getAuthBase();
64
+ process.stdout.write("\n");
65
+ const { default: ora } = await import("ora");
66
+ const spinner = ora("Sending verification code…").start();
67
+
68
+ let sendRes;
69
+ try {
70
+ sendRes = await fetch(authBase, {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify({ action: "request", email }),
74
+ });
75
+ } catch {
76
+ spinner.fail("Network error — could not reach PRDForge.");
77
+ process.exit(1);
78
+ }
79
+
80
+ const sendData = await sendRes.json().catch(() => ({}));
81
+ if (!sendRes.ok || !sendData.success) {
82
+ spinner.fail(sendData.error ?? "Failed to send verification code.");
83
+ process.exit(1);
84
+ }
85
+ spinner.succeed("Verification code sent — check your email.");
86
+
87
+ const { code } = await inquirer.prompt([
88
+ {
89
+ type: "input",
90
+ name: "code",
91
+ message: "Enter the 6-digit code:",
92
+ validate: (v) => /^\d{6}$/.test(v.trim()) || "Must be a 6-digit number",
93
+ },
94
+ ]);
95
+
96
+ const verSpinner = ora("Verifying code…").start();
97
+ let verRes;
98
+ try {
99
+ verRes = await fetch(authBase, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({ action: "verify", email, code: code.trim() }),
103
+ });
104
+ } catch {
105
+ verSpinner.fail("Network error — could not reach PRDForge.");
106
+ process.exit(1);
107
+ }
108
+
109
+ const verData = await verRes.json().catch(() => ({}));
110
+ if (!verRes.ok || !verData.success) {
111
+ verSpinner.fail(verData.error ?? "Invalid or expired code.");
112
+ process.exit(1);
113
+ }
114
+
115
+ config.set("apiKey", verData.api_key);
116
+ setEmail(verData.email ?? email);
117
+ verSpinner.succeed(`Authenticated as ${verData.email ?? email}`);
118
+ dim(` Key prefix: ${verData.key_prefix ?? "(unknown)"}`);
119
+ dim(" Config: " + config.path);
120
+ info("Run 'prdforge prd list' to see your projects.");
121
+ });
122
+
123
+ cmd
124
+ .command("logout")
125
+ .description("Remove stored API key")
126
+ .action(() => {
127
+ config.delete("apiKey");
128
+ success("Logged out — API key removed.");
129
+ });
130
+
131
+ cmd
132
+ .command("status")
133
+ .description("Show current authentication status")
134
+ .action(async () => {
135
+ header("Auth Status");
136
+ const key = getApiKey();
137
+ if (!key) {
138
+ error("Not authenticated");
139
+ info("Run 'prdforge auth login' to set your API key.");
140
+ info("Get your key at: https://prdforge.netlify.app/dashboard (Settings → API Keys)");
141
+ return;
142
+ }
143
+ success("Authenticated");
144
+ const email = getEmail();
145
+ if (email) dim(` Email: ${email}`);
146
+ dim(` Key: ${key.slice(0, 8)}${"*".repeat(Math.max(0, key.length - 8))}`);
147
+ dim(` Config: ${config.path}`);
148
+ // Show live subscription/credits if possible
149
+ try {
150
+ const { user } = await import("../api/client.js");
151
+ const data = await user.validate();
152
+ dim(` Plan: ${data.subscription}`);
153
+ dim(` Credits used: ${data.credits_used_this_month} this month`);
154
+ } catch {
155
+ // Non-fatal — key might work but network unavailable
156
+ }
157
+ });
158
+
159
+ return cmd;
160
+ }
@@ -0,0 +1,126 @@
1
+ import { Command } from "commander";
2
+ import { readFileSync } from "fs";
3
+ import { projects, prd, isAuthError } from "../api/client.js";
4
+ import { success, error, info, dim } from "../utils/output.js";
5
+
6
+ function parseCSV(content) {
7
+ const lines = content.trim().split(/\r?\n/).filter(Boolean);
8
+ if (lines.length === 0) return [];
9
+ const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
10
+ const nameIdx = header.indexOf("name");
11
+ const promptIdx = header.indexOf("prompt");
12
+ const descIdx = header.indexOf("description");
13
+ if (nameIdx === -1 || (promptIdx === -1 && descIdx === -1)) {
14
+ throw new Error("CSV must have columns: name, and prompt or description");
15
+ }
16
+ const promptCol = promptIdx >= 0 ? promptIdx : descIdx;
17
+ const rows = [];
18
+ for (let i = 1; i < lines.length; i++) {
19
+ const values = lines[i].split(",").map((v) => v.trim());
20
+ const name = values[nameIdx] ?? "";
21
+ const prompt = values[promptCol] ?? "";
22
+ if (name) rows.push({ name, prompt });
23
+ }
24
+ return rows;
25
+ }
26
+
27
+ function parseJSON(content) {
28
+ const data = JSON.parse(content);
29
+ const arr = Array.isArray(data) ? data : [data];
30
+ return arr.map((row) => {
31
+ const name = row.name ?? row.title ?? "";
32
+ const prompt = row.prompt ?? row.description ?? "";
33
+ return { name, prompt };
34
+ });
35
+ }
36
+
37
+ export function bulkCommand() {
38
+ const cmd = new Command("bulk")
39
+ .description("Generate many PRDs from a CSV or JSON file (one PRD per row/item)")
40
+ .argument("<file>", "Path to .csv or .json file")
41
+ .option("--delay <ms>", "Delay in ms between each PRD generation (default: 2000)", "2000")
42
+ .option("--dry-run", "Only parse the file and show what would be created")
43
+ .option("-m, --model <modelId>", "AI model ID to use for generation")
44
+ .action(async (file, opts) => {
45
+ const { default: ora } = await import("ora");
46
+ let raw;
47
+ try {
48
+ raw = readFileSync(file, "utf8");
49
+ } catch (e) {
50
+ error(`Cannot read file: ${file}`);
51
+ process.exit(1);
52
+ }
53
+
54
+ const ext = file.replace(/^.*\./, "").toLowerCase();
55
+ let rows;
56
+ try {
57
+ if (ext === "csv") {
58
+ rows = parseCSV(raw);
59
+ } else if (ext === "json") {
60
+ rows = parseJSON(raw);
61
+ } else {
62
+ error("File must be .csv or .json");
63
+ process.exit(1);
64
+ }
65
+ } catch (e) {
66
+ error(e.message);
67
+ process.exit(1);
68
+ }
69
+
70
+ if (rows.length === 0) {
71
+ info("No rows to process.");
72
+ return;
73
+ }
74
+
75
+ if (opts.dryRun) {
76
+ info(`Dry run: would create ${rows.length} PRD(s):`);
77
+ rows.forEach((r, i) => dim(` ${i + 1}. ${r.name}: ${(r.prompt || "").slice(0, 50)}…`));
78
+ return;
79
+ }
80
+
81
+ const delayMs = Math.max(0, parseInt(opts.delay, 10) || 2000);
82
+ const results = [];
83
+ for (let i = 0; i < rows.length; i++) {
84
+ const { name, prompt } = rows[i];
85
+ const spinner = ora(`[${i + 1}/${rows.length}] Creating "${name}"…`).start();
86
+ try {
87
+ const project = await projects.create(name, prompt || "");
88
+ spinner.text = `[${i + 1}/${rows.length}] Generating PRD for "${name}"…`;
89
+ await prd.generate(project.id, prompt || "", opts.model);
90
+ spinner.succeed(`"${name}" created (${project.id.slice(0, 8)}…)`);
91
+ results.push({ name, id: project.id });
92
+ } catch (err) {
93
+ spinner.fail(`"${name}" failed`);
94
+ error(err.message);
95
+ if (isAuthError(err)) {
96
+ process.exit(2);
97
+ }
98
+ results.push({ name, error: err.message });
99
+ }
100
+ if (i < rows.length - 1 && delayMs > 0) {
101
+ await new Promise((r) => setTimeout(r, delayMs));
102
+ }
103
+ }
104
+
105
+ success(`Bulk run complete: ${results.filter((r) => !r.error).length} created, ${results.filter((r) => r.error).length} failed.`);
106
+ });
107
+
108
+ cmd.addHelpText(
109
+ "after",
110
+ `
111
+ File formats:
112
+ CSV: header row with "name" and "prompt" (or "description"). Example:
113
+ name,prompt
114
+ My App,A task manager for remote teams
115
+ API,REST API for payments
116
+
117
+ JSON: array of objects with "name" and "prompt" (or "description"). Example:
118
+ [{"name":"My App","prompt":"A task manager"},{"name":"API","prompt":"REST API"}]
119
+
120
+ Examples:
121
+ prdforge bulk ideas.csv
122
+ prdforge bulk ideas.json --delay 5000 --dry-run
123
+ `
124
+ );
125
+ return cmd;
126
+ }