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 +9 -0
- package/README.md +129 -0
- package/bin/prdforge +2 -0
- package/package.json +57 -0
- package/src/api/client.js +164 -0
- package/src/api/client.test.js +34 -0
- package/src/commands/auth.js +160 -0
- package/src/commands/bulk.js +126 -0
- package/src/commands/config.js +58 -0
- package/src/commands/credits.js +21 -0
- package/src/commands/dashboard.js +88 -0
- package/src/commands/export.js +67 -0
- package/src/commands/generate.js +117 -0
- package/src/commands/mcp.js +100 -0
- package/src/commands/prd.js +221 -0
- package/src/commands/watch.js +65 -0
- package/src/commands/whoami.js +24 -0
- package/src/index.js +65 -0
- package/src/ui/Dashboard.js +263 -0
- package/src/ui/PrdCreation.js +66 -0
- package/src/ui/PromptBox.js +129 -0
- package/src/ui/StageIndicator.js +40 -0
- package/src/ui/theme.js +21 -0
- package/src/utils/config.js +45 -0
- package/src/utils/config.test.js +40 -0
- package/src/utils/output.js +61 -0
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
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
|
+
}
|