prdforge 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 +53 -0
- package/src/api/client.js +138 -0
- package/src/api/client.test.js +34 -0
- package/src/commands/auth.js +60 -0
- package/src/commands/bulk.js +126 -0
- package/src/commands/config.js +58 -0
- package/src/commands/export.js +67 -0
- package/src/commands/generate.js +117 -0
- package/src/commands/mcp.js +93 -0
- package/src/commands/prd.js +178 -0
- package/src/commands/watch.js +65 -0
- package/src/index.js +53 -0
- package/src/utils/config.js +31 -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
|
|
15
|
+
# or run directly
|
|
16
|
+
npx prdforge
|
|
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,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prdforge",
|
|
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
|
+
"prepublishOnly": "npm test"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"prdforge",
|
|
19
|
+
"prd",
|
|
20
|
+
"product",
|
|
21
|
+
"requirements",
|
|
22
|
+
"ai",
|
|
23
|
+
"cli",
|
|
24
|
+
"agent",
|
|
25
|
+
"mcp"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin",
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
".env.example"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
39
|
+
"chalk": "^5.3.0",
|
|
40
|
+
"cli-table3": "^0.6.5",
|
|
41
|
+
"commander": "^12.1.0",
|
|
42
|
+
"conf": "^13.0.0",
|
|
43
|
+
"dotenv": "^16.4.5",
|
|
44
|
+
"inquirer": "^10.2.2",
|
|
45
|
+
"marked": "^14.1.2",
|
|
46
|
+
"marked-terminal": "^7.1.0",
|
|
47
|
+
"node-fetch": "^3.3.2",
|
|
48
|
+
"ora": "^8.1.1"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
};
|
|
@@ -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,60 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { config, getApiKey } 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("Set your PRDForge API key")
|
|
11
|
+
.option("-k, --key <key>", "API key (or set PRDFORGE_API_KEY env var)")
|
|
12
|
+
.action(async (opts) => {
|
|
13
|
+
let key = opts.key;
|
|
14
|
+
|
|
15
|
+
if (!key) {
|
|
16
|
+
const { default: inquirer } = await import("inquirer");
|
|
17
|
+
const { apiKey } = await inquirer.prompt([
|
|
18
|
+
{
|
|
19
|
+
type: "password",
|
|
20
|
+
name: "apiKey",
|
|
21
|
+
message: "Enter your PRDForge API key:",
|
|
22
|
+
validate: (v) => v.length > 10 || "Key too short",
|
|
23
|
+
},
|
|
24
|
+
]);
|
|
25
|
+
key = apiKey;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
config.set("apiKey", key);
|
|
29
|
+
success("API key saved. You are now authenticated.");
|
|
30
|
+
dim(" Key stored in: " + config.path);
|
|
31
|
+
info("Run 'prdforge prd list' to see your projects.");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
cmd
|
|
35
|
+
.command("logout")
|
|
36
|
+
.description("Remove stored API key")
|
|
37
|
+
.action(() => {
|
|
38
|
+
config.delete("apiKey");
|
|
39
|
+
success("Logged out — API key removed.");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
cmd
|
|
43
|
+
.command("status")
|
|
44
|
+
.description("Show current authentication status")
|
|
45
|
+
.action(() => {
|
|
46
|
+
header("Auth Status");
|
|
47
|
+
const key = getApiKey();
|
|
48
|
+
if (key) {
|
|
49
|
+
success("Authenticated");
|
|
50
|
+
dim(` Key: ${key.slice(0, 8)}${"*".repeat(Math.max(0, key.length - 8))}`);
|
|
51
|
+
dim(` Config: ${config.path}`);
|
|
52
|
+
} else {
|
|
53
|
+
error("Not authenticated");
|
|
54
|
+
info("Run 'prdforge auth login' to set your API key.");
|
|
55
|
+
info("Get your key at: https://prdforge.netlify.app/dashboard (Settings → API Keys)");
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return cmd;
|
|
60
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { config } from "../utils/config.js";
|
|
3
|
+
import { success, error, info, header, table } from "../utils/output.js";
|
|
4
|
+
|
|
5
|
+
const CONFIGURABLE_KEYS = ["apiUrl", "defaultFormat", "defaultProject", "outputDir"];
|
|
6
|
+
|
|
7
|
+
export function configCommand() {
|
|
8
|
+
const cmd = new Command("config").description("Manage CLI configuration");
|
|
9
|
+
|
|
10
|
+
cmd
|
|
11
|
+
.command("list")
|
|
12
|
+
.description("Show all current configuration values")
|
|
13
|
+
.action(() => {
|
|
14
|
+
header("Configuration");
|
|
15
|
+
table(
|
|
16
|
+
["Key", "Value"],
|
|
17
|
+
CONFIGURABLE_KEYS.map((k) => [k, String(config.get(k) || "")])
|
|
18
|
+
);
|
|
19
|
+
info(`Config file: ${config.path}`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
cmd
|
|
23
|
+
.command("set <key> <value>")
|
|
24
|
+
.description("Set a configuration value")
|
|
25
|
+
.action((key, value) => {
|
|
26
|
+
if (!CONFIGURABLE_KEYS.includes(key)) {
|
|
27
|
+
error(`Unknown key: ${key}`);
|
|
28
|
+
info(`Valid keys: ${CONFIGURABLE_KEYS.join(", ")}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
config.set(key, value);
|
|
32
|
+
success(`${key} = ${value}`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
cmd
|
|
36
|
+
.command("get <key>")
|
|
37
|
+
.description("Get a configuration value")
|
|
38
|
+
.action((key) => {
|
|
39
|
+
console.log(config.get(key) ?? "");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
cmd
|
|
43
|
+
.command("reset")
|
|
44
|
+
.description("Reset all configuration to defaults")
|
|
45
|
+
.action(async () => {
|
|
46
|
+
const { default: inquirer } = await import("inquirer");
|
|
47
|
+
const { confirm } = await inquirer.prompt([
|
|
48
|
+
{ type: "confirm", name: "confirm", message: "Reset all config? (API key will be kept)", default: false },
|
|
49
|
+
]);
|
|
50
|
+
if (!confirm) return;
|
|
51
|
+
const key = config.get("apiKey");
|
|
52
|
+
config.clear();
|
|
53
|
+
if (key) config.set("apiKey", key);
|
|
54
|
+
success("Configuration reset to defaults.");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return cmd;
|
|
58
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { exports_, isAuthError } from "../api/client.js";
|
|
5
|
+
import { config } from "../utils/config.js";
|
|
6
|
+
import { success, error, info, dim } from "../utils/output.js";
|
|
7
|
+
|
|
8
|
+
/** API supports markdown and json only. See SYNC.md for adding new formats. */
|
|
9
|
+
const FORMATS = ["markdown", "json"];
|
|
10
|
+
|
|
11
|
+
export function exportCommand() {
|
|
12
|
+
const cmd = new Command("export").description("Export a PRD in various formats");
|
|
13
|
+
|
|
14
|
+
cmd
|
|
15
|
+
.argument("<projectId>", "Project ID to export")
|
|
16
|
+
.option("-f, --format <format>", `Export format: ${FORMATS.join(", ")}`, "markdown")
|
|
17
|
+
.option("-o, --output <path>", "Output file path (default: ./prd-exports/<id>.<ext>)")
|
|
18
|
+
.option("--stdout", "Print to stdout instead of saving to file")
|
|
19
|
+
.action(async (projectId, opts) => {
|
|
20
|
+
const { default: ora } = await import("ora");
|
|
21
|
+
|
|
22
|
+
if (!FORMATS.includes(opts.format)) {
|
|
23
|
+
error(`Unknown format: ${opts.format}. Valid options: ${FORMATS.join(", ")}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const spinner = ora(`Exporting as ${opts.format}…`).start();
|
|
28
|
+
try {
|
|
29
|
+
const result = await exports_.export(projectId, opts.format);
|
|
30
|
+
spinner.stop();
|
|
31
|
+
|
|
32
|
+
const content =
|
|
33
|
+
opts.format === "json"
|
|
34
|
+
? JSON.stringify(result.data ?? result, null, 2)
|
|
35
|
+
: result.content ?? result.data ?? String(result);
|
|
36
|
+
|
|
37
|
+
if (opts.stdout) {
|
|
38
|
+
console.log(content);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ext = opts.format === "markdown" ? "md" : opts.format;
|
|
43
|
+
const outDir = config.get("outputDir");
|
|
44
|
+
mkdirSync(outDir, { recursive: true });
|
|
45
|
+
const filePath = opts.output || join(outDir, `${projectId.slice(0, 8)}.${ext}`);
|
|
46
|
+
writeFileSync(filePath, content, "utf8");
|
|
47
|
+
|
|
48
|
+
success(`Exported to: ${filePath}`);
|
|
49
|
+
dim(` Format: ${opts.format}`);
|
|
50
|
+
info("Feed this file directly to Claude Code, Cursor, or any AI coding tool.");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
spinner.fail("Export failed");
|
|
53
|
+
error(err.message);
|
|
54
|
+
process.exit(isAuthError(err) ? 2 : 1);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
cmd.addHelpText(
|
|
59
|
+
"after",
|
|
60
|
+
`
|
|
61
|
+
Examples:
|
|
62
|
+
prdforge export <projectId> -f markdown -o PRD.md
|
|
63
|
+
prdforge export <projectId> --format json --stdout
|
|
64
|
+
`
|
|
65
|
+
);
|
|
66
|
+
return cmd;
|
|
67
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { writeFileSync, mkdirSync, existsSync, renameSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { projects, prd, exports_, isAuthError } from "../api/client.js";
|
|
5
|
+
import { success, error, info, dim } from "../utils/output.js";
|
|
6
|
+
|
|
7
|
+
function timestamp() {
|
|
8
|
+
const d = new Date();
|
|
9
|
+
return [
|
|
10
|
+
d.getFullYear(),
|
|
11
|
+
String(d.getMonth() + 1).padStart(2, "0"),
|
|
12
|
+
String(d.getDate()).padStart(2, "0"),
|
|
13
|
+
String(d.getHours()).padStart(2, "0"),
|
|
14
|
+
String(d.getMinutes()).padStart(2, "0"),
|
|
15
|
+
String(d.getSeconds()).padStart(2, "0"),
|
|
16
|
+
].join("-");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function generateCommand() {
|
|
20
|
+
const cmd = new Command("generate")
|
|
21
|
+
.description("Generate a PRD and save it to your app folder (with optional backup of existing PRD.md)")
|
|
22
|
+
.option("-n, --name <name>", "Project name (for new PRD)")
|
|
23
|
+
.option("-p, --prompt <prompt>", "Product idea or update prompt (or set PRDFORGE_PROMPT)")
|
|
24
|
+
.option("--project <id>", "Existing project ID (for update or refresh)")
|
|
25
|
+
.option("--refresh", "Regenerate all stale stages, then save")
|
|
26
|
+
.option("-d, --dir <path>", "App folder to write PRD.md (default: current directory)", process.cwd())
|
|
27
|
+
.option("-o, --output <path>", "Output filename (default: PRD.md)", "PRD.md")
|
|
28
|
+
.option("--no-backup", "Do not backup existing PRD.md before overwriting")
|
|
29
|
+
.option("-m, --model <modelId>", "AI model ID to use")
|
|
30
|
+
.option("--json", "Output raw JSON instead of writing file")
|
|
31
|
+
.addHelpText(
|
|
32
|
+
"after",
|
|
33
|
+
`
|
|
34
|
+
Examples:
|
|
35
|
+
prdforge generate -n "My App" -p "A todo app with due dates and tags"
|
|
36
|
+
prdforge generate --project <id> -p "Add OAuth section"
|
|
37
|
+
prdforge generate --project <id> --refresh
|
|
38
|
+
prdforge generate -n "API" -p "REST API for payments" -d ./my-app -o PRD.md
|
|
39
|
+
`
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
cmd.action(async (opts) => {
|
|
43
|
+
const { default: ora } = await import("ora");
|
|
44
|
+
const prompt = opts.prompt ?? process.env.PRDFORGE_PROMPT ?? "";
|
|
45
|
+
|
|
46
|
+
const isRefresh = !!opts.project && !!opts.refresh;
|
|
47
|
+
const isUpdate = !!opts.project && !opts.refresh && prompt !== "";
|
|
48
|
+
const isNew = !!opts.name && prompt !== "";
|
|
49
|
+
|
|
50
|
+
if (!isNew && !isUpdate && !isRefresh) {
|
|
51
|
+
if (opts.name) {
|
|
52
|
+
error("Provide -p/--prompt or set PRDFORGE_PROMPT to generate a new PRD.");
|
|
53
|
+
} else {
|
|
54
|
+
error("Use -n/--name with -p/--prompt for a new PRD, or --project <id> with -p/--prompt or --refresh.");
|
|
55
|
+
}
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let projectId;
|
|
60
|
+
let content;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
if (isNew) {
|
|
64
|
+
const spinner = ora("Creating project…").start();
|
|
65
|
+
const project = await projects.create(opts.name, prompt);
|
|
66
|
+
projectId = project.id;
|
|
67
|
+
spinner.text = "Generating PRD (this may take ~30–60s)…";
|
|
68
|
+
await prd.generate(projectId, prompt, opts.model);
|
|
69
|
+
spinner.succeed("PRD generated!");
|
|
70
|
+
} else if (isUpdate) {
|
|
71
|
+
projectId = opts.project;
|
|
72
|
+
const spinner = ora("Analysing and merging update…").start();
|
|
73
|
+
await prd.update(projectId, prompt, opts.model);
|
|
74
|
+
spinner.succeed("PRD updated!");
|
|
75
|
+
} else {
|
|
76
|
+
projectId = opts.project;
|
|
77
|
+
const spinner = ora("Regenerating stale stages…").start();
|
|
78
|
+
await prd.regenerateStale(projectId, opts.model);
|
|
79
|
+
spinner.succeed("Stale stages refreshed!");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const spinner = ora("Exporting markdown…").start();
|
|
83
|
+
const result = await exports_.export(projectId, "markdown");
|
|
84
|
+
spinner.stop();
|
|
85
|
+
content =
|
|
86
|
+
result.content ?? result.data ?? (typeof result === "string" ? result : JSON.stringify(result, null, 2));
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (opts.json && err.message) {
|
|
89
|
+
console.error(JSON.stringify({ error: err.message, httpStatus: err.httpStatus }));
|
|
90
|
+
} else {
|
|
91
|
+
error(err.message);
|
|
92
|
+
}
|
|
93
|
+
process.exit(isAuthError(err) ? 2 : 1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (opts.json) {
|
|
97
|
+
console.log(JSON.stringify({ project_id: projectId, content }, null, 2));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const dir = opts.dir || process.cwd();
|
|
102
|
+
const outputPath = join(dir, opts.output || "PRD.md");
|
|
103
|
+
mkdirSync(dir, { recursive: true });
|
|
104
|
+
|
|
105
|
+
if (existsSync(outputPath) && opts.backup !== false) {
|
|
106
|
+
const backupPath = `${outputPath}.backup.${timestamp()}`;
|
|
107
|
+
renameSync(outputPath, backupPath);
|
|
108
|
+
info(`Backed up existing file to ${backupPath}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
writeFileSync(outputPath, content, "utf8");
|
|
112
|
+
success(`PRD saved to ${outputPath}`);
|
|
113
|
+
dim(" Use this file with Cursor, Claude Code, or any AI coding tool.");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return cmd;
|
|
117
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { projects, prd, exports_ } from "../api/client.js";
|
|
6
|
+
import { requireApiKey } from "../utils/config.js";
|
|
7
|
+
|
|
8
|
+
export function mcpCommand() {
|
|
9
|
+
const cmd = new Command("mcp").description("MCP (Model Context Protocol) server for PRDForge");
|
|
10
|
+
|
|
11
|
+
cmd
|
|
12
|
+
.command("serve")
|
|
13
|
+
.description("Run the MCP server (stdio). Use with Cursor or other MCP clients.")
|
|
14
|
+
.action(async () => {
|
|
15
|
+
// Ensure API key is available before starting (fail fast)
|
|
16
|
+
requireApiKey();
|
|
17
|
+
|
|
18
|
+
const mcpServer = new McpServer({
|
|
19
|
+
name: "prdforge",
|
|
20
|
+
version: "0.1.0",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
mcpServer.registerTool(
|
|
24
|
+
"prdforge_list_projects",
|
|
25
|
+
{
|
|
26
|
+
description: "List all PRDForge projects for the authenticated user",
|
|
27
|
+
inputSchema: {},
|
|
28
|
+
},
|
|
29
|
+
async () => {
|
|
30
|
+
try {
|
|
31
|
+
const data = await projects.list();
|
|
32
|
+
const text =
|
|
33
|
+
Array.isArray(data) && data.length > 0
|
|
34
|
+
? JSON.stringify(data.map((p) => ({ id: p.id, name: p.name, updated_at: p.updated_at })), null, 2)
|
|
35
|
+
: "No projects yet.";
|
|
36
|
+
return { content: [{ type: "text", text }] };
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return { content: [{ type: "text", text: err.message }], isError: true };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
mcpServer.registerTool(
|
|
44
|
+
"prdforge_get_prd",
|
|
45
|
+
{
|
|
46
|
+
description: "Export a PRD project as markdown by project ID",
|
|
47
|
+
inputSchema: {
|
|
48
|
+
project_id: z.string().describe("PRDForge project ID (UUID)"),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
async ({ project_id }) => {
|
|
52
|
+
try {
|
|
53
|
+
const result = await exports_.export(project_id, "markdown");
|
|
54
|
+
const content =
|
|
55
|
+
result.content ?? result.data ?? (typeof result === "string" ? result : JSON.stringify(result, null, 2));
|
|
56
|
+
return { content: [{ type: "text", text: content }] };
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return { content: [{ type: "text", text: err.message }], isError: true };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
mcpServer.registerTool(
|
|
64
|
+
"prdforge_get_sections",
|
|
65
|
+
{
|
|
66
|
+
description: "Get PRD sections for a project (optionally filter by section_type)",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
project_id: z.string().describe("PRDForge project ID (UUID)"),
|
|
69
|
+
section_type: z.string().optional().describe("Optional section type filter (e.g. feature_list)"),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
async ({ project_id, section_type }) => {
|
|
73
|
+
try {
|
|
74
|
+
const sections = await prd.getSections(project_id);
|
|
75
|
+
const filtered = section_type
|
|
76
|
+
? sections.filter((s) => s.section_type === section_type)
|
|
77
|
+
: sections;
|
|
78
|
+
const text = JSON.stringify(filtered, null, 2);
|
|
79
|
+
return { content: [{ type: "text", text }] };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { content: [{ type: "text", text: err.message }], isError: true };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const transport = new StdioServerTransport();
|
|
87
|
+
await mcpServer.connect(transport);
|
|
88
|
+
// Log to stderr so stdout stays clean for JSON-RPC
|
|
89
|
+
console.error("PRDForge MCP server running on stdio. Press Ctrl+C to stop.");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return cmd;
|
|
93
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { prd, projects, isAuthError } from "../api/client.js";
|
|
3
|
+
import { success, error, info, dim, header, table, printPRDSection } from "../utils/output.js";
|
|
4
|
+
|
|
5
|
+
export function prdCommand() {
|
|
6
|
+
const cmd = new Command("prd").description("Generate, view, and update PRDs");
|
|
7
|
+
|
|
8
|
+
// prdforge prd list
|
|
9
|
+
cmd
|
|
10
|
+
.command("list")
|
|
11
|
+
.description("List all your PRD projects")
|
|
12
|
+
.option("--json", "Output raw JSON")
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
const { default: ora } = await import("ora");
|
|
15
|
+
const spinner = ora("Fetching projects…").start();
|
|
16
|
+
try {
|
|
17
|
+
const data = await projects.list();
|
|
18
|
+
spinner.stop();
|
|
19
|
+
if (opts.json) {
|
|
20
|
+
console.log(JSON.stringify(data, null, 2));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
header("Your PRD Projects");
|
|
24
|
+
if (!data?.length) {
|
|
25
|
+
info("No projects yet. Run 'prdforge prd create' to start.");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
table(
|
|
29
|
+
["ID", "Name", "Stages", "Updated"],
|
|
30
|
+
data.map((p) => [
|
|
31
|
+
p.id.slice(0, 8) + "…",
|
|
32
|
+
p.name,
|
|
33
|
+
`${p.completed_stages || 0}/9`,
|
|
34
|
+
new Date(p.updated_at).toLocaleDateString(),
|
|
35
|
+
])
|
|
36
|
+
);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
spinner.fail("Failed");
|
|
39
|
+
error(err.message);
|
|
40
|
+
process.exit(isAuthError(err) ? 2 : 1);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// prdforge prd get <id>
|
|
45
|
+
cmd
|
|
46
|
+
.command("get <projectId>")
|
|
47
|
+
.description("Show all PRD sections for a project")
|
|
48
|
+
.option("--section <type>", "Show a specific section type")
|
|
49
|
+
.option("--json", "Output raw JSON")
|
|
50
|
+
.action(async (projectId, opts) => {
|
|
51
|
+
const { default: ora } = await import("ora");
|
|
52
|
+
const spinner = ora("Loading PRD…").start();
|
|
53
|
+
try {
|
|
54
|
+
const sections = await prd.getSections(projectId);
|
|
55
|
+
spinner.stop();
|
|
56
|
+
if (opts.json) {
|
|
57
|
+
console.log(JSON.stringify(sections, null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const filtered = opts.section
|
|
61
|
+
? sections.filter((s) => s.section_type === opts.section)
|
|
62
|
+
: sections;
|
|
63
|
+
header(`PRD Sections (${filtered.length})`);
|
|
64
|
+
filtered.forEach(printPRDSection);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
spinner.fail("Failed");
|
|
67
|
+
error(err.message);
|
|
68
|
+
process.exit(isAuthError(err) ? 2 : 1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// prdforge prd create
|
|
73
|
+
cmd
|
|
74
|
+
.command("create")
|
|
75
|
+
.description("Create a new PRD project from a prompt")
|
|
76
|
+
.requiredOption("-n, --name <name>", "Project name")
|
|
77
|
+
.option("-p, --prompt <prompt>", "Product idea or description")
|
|
78
|
+
.option("-m, --model <modelId>", "AI model ID to use")
|
|
79
|
+
.option("--json", "Output raw JSON")
|
|
80
|
+
.action(async (opts) => {
|
|
81
|
+
const { default: ora } = await import("ora");
|
|
82
|
+
let prompt = opts.prompt;
|
|
83
|
+
|
|
84
|
+
if (!prompt) {
|
|
85
|
+
const { default: inquirer } = await import("inquirer");
|
|
86
|
+
const ans = await inquirer.prompt([
|
|
87
|
+
{
|
|
88
|
+
type: "editor",
|
|
89
|
+
name: "prompt",
|
|
90
|
+
message: "Describe your product idea (opens your $EDITOR):",
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
93
|
+
prompt = ans.prompt;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const spinner = ora("Creating project…").start();
|
|
97
|
+
try {
|
|
98
|
+
const project = await projects.create(opts.name, prompt);
|
|
99
|
+
spinner.text = "Generating PRD (this takes ~30–60s)…";
|
|
100
|
+
const result = await prd.generate(project.id, prompt, opts.model);
|
|
101
|
+
spinner.succeed("PRD generated!");
|
|
102
|
+
if (opts.json) {
|
|
103
|
+
console.log(JSON.stringify({ project, result }, null, 2));
|
|
104
|
+
} else {
|
|
105
|
+
success(`Project "${opts.name}" created`);
|
|
106
|
+
dim(` ID: ${project.id}`);
|
|
107
|
+
info(`View in browser: https://prdforge.netlify.app/workspace/${project.id}`);
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
spinner.fail("Failed");
|
|
111
|
+
error(err.message);
|
|
112
|
+
process.exit(isAuthError(err) ? 2 : 1);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// prdforge prd update <id>
|
|
117
|
+
cmd
|
|
118
|
+
.command("update <projectId>")
|
|
119
|
+
.description("Intelligently merge an update into an existing PRD")
|
|
120
|
+
.option("-p, --prompt <prompt>", "What to update or add")
|
|
121
|
+
.option("-m, --model <modelId>", "AI model ID to use")
|
|
122
|
+
.option("--json", "Output raw JSON")
|
|
123
|
+
.action(async (projectId, opts) => {
|
|
124
|
+
const { default: ora } = await import("ora");
|
|
125
|
+
let prompt = opts.prompt;
|
|
126
|
+
|
|
127
|
+
if (!prompt) {
|
|
128
|
+
const { default: inquirer } = await import("inquirer");
|
|
129
|
+
const ans = await inquirer.prompt([
|
|
130
|
+
{
|
|
131
|
+
type: "editor",
|
|
132
|
+
name: "prompt",
|
|
133
|
+
message: "Describe the update (opens your $EDITOR):",
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
prompt = ans.prompt;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const spinner = ora("Analysing and merging update…").start();
|
|
140
|
+
try {
|
|
141
|
+
const result = await prd.update(projectId, prompt, opts.model);
|
|
142
|
+
spinner.succeed("PRD updated!");
|
|
143
|
+
if (opts.json) {
|
|
144
|
+
console.log(JSON.stringify(result, null, 2));
|
|
145
|
+
} else {
|
|
146
|
+
success(`Smart merge complete`);
|
|
147
|
+
if (result.summary) dim(` Summary: ${result.summary}`);
|
|
148
|
+
if (result.sectionsUpdated != null) dim(` Sections updated: ${result.sectionsUpdated}`);
|
|
149
|
+
if (result.cascadeTriggered) info(" Downstream stages (modules, tasks, etc.) were regenerated.");
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
spinner.fail("Failed");
|
|
153
|
+
error(err.message);
|
|
154
|
+
process.exit(isAuthError(err) ? 2 : 1);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// prdforge prd refresh <id>
|
|
159
|
+
cmd
|
|
160
|
+
.command("refresh <projectId>")
|
|
161
|
+
.description("Regenerate all stale stages for a project")
|
|
162
|
+
.option("-m, --model <modelId>", "AI model ID to use")
|
|
163
|
+
.action(async (projectId, opts) => {
|
|
164
|
+
const { default: ora } = await import("ora");
|
|
165
|
+
const spinner = ora("Regenerating stale stages…").start();
|
|
166
|
+
try {
|
|
167
|
+
const result = await prd.regenerateStale(projectId, opts.model);
|
|
168
|
+
spinner.succeed("All stale stages refreshed!");
|
|
169
|
+
if (result?.summary) dim(` ${result.summary}`);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
spinner.fail("Failed");
|
|
172
|
+
error(err.message);
|
|
173
|
+
process.exit(isAuthError(err) ? 2 : 1);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return cmd;
|
|
178
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { projects, exports_, isAuthError } from "../api/client.js";
|
|
5
|
+
import { config } from "../utils/config.js";
|
|
6
|
+
import { success, error, info, dim } from "../utils/output.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_INTERVAL_SEC = 30;
|
|
9
|
+
const DEFAULT_OUTPUT = "PRD.md";
|
|
10
|
+
|
|
11
|
+
export function watchCommand() {
|
|
12
|
+
const cmd = new Command("watch")
|
|
13
|
+
.description("Watch a PRD project and re-export markdown when it changes (live sync during development)")
|
|
14
|
+
.argument("<projectId>", "Project ID to watch")
|
|
15
|
+
.option("-o, --output <path>", "Output file path (default: PRD.md)", DEFAULT_OUTPUT)
|
|
16
|
+
.option("-i, --interval <seconds>", "Poll interval in seconds", String(DEFAULT_INTERVAL_SEC))
|
|
17
|
+
.action(async (projectId, opts) => {
|
|
18
|
+
const intervalSec = Math.max(5, parseInt(opts.interval, 10) || DEFAULT_INTERVAL_SEC);
|
|
19
|
+
const outputPath = opts.output || DEFAULT_OUTPUT;
|
|
20
|
+
|
|
21
|
+
let lastUpdatedAt = null;
|
|
22
|
+
let firstRun = true;
|
|
23
|
+
|
|
24
|
+
const exportOnce = async () => {
|
|
25
|
+
try {
|
|
26
|
+
const project = await projects.get(projectId);
|
|
27
|
+
const updatedAt = project?.updated_at ?? null;
|
|
28
|
+
if (firstRun) {
|
|
29
|
+
firstRun = false;
|
|
30
|
+
lastUpdatedAt = updatedAt;
|
|
31
|
+
} else if (updatedAt === lastUpdatedAt) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
lastUpdatedAt = updatedAt;
|
|
35
|
+
|
|
36
|
+
const result = await exports_.export(projectId, "markdown");
|
|
37
|
+
const content =
|
|
38
|
+
result.content ?? result.data ?? (typeof result === "string" ? result : JSON.stringify(result, null, 2));
|
|
39
|
+
const fullPath = join(process.cwd(), outputPath);
|
|
40
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
41
|
+
writeFileSync(fullPath, content, "utf8");
|
|
42
|
+
success(`Exported to ${fullPath}`);
|
|
43
|
+
dim(` Updated at: ${updatedAt ? new Date(updatedAt).toISOString() : "—"}`);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
error(err.message);
|
|
46
|
+
if (isAuthError(err)) process.exit(2);
|
|
47
|
+
// Don't exit on transient errors; next poll will retry
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
info(`Watching project ${projectId.slice(0, 8)}… (every ${intervalSec}s). Press Ctrl+C to stop.`);
|
|
52
|
+
await exportOnce();
|
|
53
|
+
const timer = setInterval(exportOnce, intervalSec * 1000);
|
|
54
|
+
|
|
55
|
+
const shutdown = () => {
|
|
56
|
+
clearInterval(timer);
|
|
57
|
+
info("Stopped watching.");
|
|
58
|
+
process.exit(0);
|
|
59
|
+
};
|
|
60
|
+
process.on("SIGINT", shutdown);
|
|
61
|
+
process.on("SIGTERM", shutdown);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return cmd;
|
|
65
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { authCommand } from "./commands/auth.js";
|
|
4
|
+
import { prdCommand } from "./commands/prd.js";
|
|
5
|
+
import { exportCommand } from "./commands/export.js";
|
|
6
|
+
import { generateCommand } from "./commands/generate.js";
|
|
7
|
+
import { configCommand } from "./commands/config.js";
|
|
8
|
+
import { watchCommand } from "./commands/watch.js";
|
|
9
|
+
import { mcpCommand } from "./commands/mcp.js";
|
|
10
|
+
import { bulkCommand } from "./commands/bulk.js";
|
|
11
|
+
|
|
12
|
+
const LOGO = `
|
|
13
|
+
\x1b[1m\x1b[36m PRDForge CLI\x1b[0m \x1b[2mv0.1.0\x1b[0m
|
|
14
|
+
\x1b[90mThe planning layer for AI-first development\x1b[0m
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const program = new Command();
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.name("prdforge")
|
|
21
|
+
.description("PRDForge CLI — generate, manage, and export PRDs from your terminal or AI agents")
|
|
22
|
+
.version("0.1.0", "-v, --version")
|
|
23
|
+
.addHelpText("beforeAll", LOGO)
|
|
24
|
+
.hook("preAction", async () => {
|
|
25
|
+
// Load .env from cwd if present
|
|
26
|
+
try {
|
|
27
|
+
const { config: dotenv } = await import("dotenv");
|
|
28
|
+
dotenv();
|
|
29
|
+
} catch {}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
program.addCommand(authCommand());
|
|
33
|
+
program.addCommand(generateCommand());
|
|
34
|
+
program.addCommand(prdCommand());
|
|
35
|
+
program.addCommand(exportCommand(), { isDefault: false });
|
|
36
|
+
program.addCommand(watchCommand());
|
|
37
|
+
program.addCommand(mcpCommand());
|
|
38
|
+
program.addCommand(bulkCommand());
|
|
39
|
+
program.addCommand(configCommand());
|
|
40
|
+
|
|
41
|
+
// Friendly help for unknown commands
|
|
42
|
+
program.on("command:*", (cmds) => {
|
|
43
|
+
console.error(`\x1b[31m✖\x1b[0m Unknown command: ${cmds[0]}`);
|
|
44
|
+
console.log("Run \x1b[36mprdforge --help\x1b[0m for a list of commands.");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Show help when run with no arguments
|
|
49
|
+
if (process.argv.length === 2) {
|
|
50
|
+
program.help();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Conf from "conf";
|
|
2
|
+
|
|
3
|
+
export const config = new Conf({
|
|
4
|
+
projectName: "prdforge",
|
|
5
|
+
schema: {
|
|
6
|
+
apiKey: { type: "string", default: "" },
|
|
7
|
+
apiUrl: { type: "string", default: "https://jnlkzcmeiksqljnbtfhb.supabase.co/functions/v1" },
|
|
8
|
+
defaultFormat: { type: "string", default: "markdown" },
|
|
9
|
+
defaultProject: { type: "string", default: "" },
|
|
10
|
+
outputDir: { type: "string", default: "./prd-exports" },
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function getApiKey() {
|
|
15
|
+
return process.env.PRDFORGE_API_KEY || config.get("apiKey");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getApiUrl() {
|
|
19
|
+
return process.env.PRDFORGE_API_URL || config.get("apiUrl");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function requireApiKey() {
|
|
23
|
+
const key = getApiKey();
|
|
24
|
+
if (!key) {
|
|
25
|
+
console.error(
|
|
26
|
+
"\x1b[31m✖\x1b[0m No API key set. Run \x1b[36mprdforge auth login\x1b[0m to authenticate."
|
|
27
|
+
);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
return key;
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { getApiKey, getApiUrl } from "./config.js";
|
|
4
|
+
|
|
5
|
+
describe("config", () => {
|
|
6
|
+
const origApiKey = process.env.PRDFORGE_API_KEY;
|
|
7
|
+
const origApiUrl = process.env.PRDFORGE_API_URL;
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
if (origApiKey !== undefined) process.env.PRDFORGE_API_KEY = origApiKey;
|
|
11
|
+
else delete process.env.PRDFORGE_API_KEY;
|
|
12
|
+
if (origApiUrl !== undefined) process.env.PRDFORGE_API_URL = origApiUrl;
|
|
13
|
+
else delete process.env.PRDFORGE_API_URL;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("getApiKey", () => {
|
|
17
|
+
it("returns PRDFORGE_API_KEY when set", () => {
|
|
18
|
+
process.env.PRDFORGE_API_KEY = "env-key-12345";
|
|
19
|
+
assert.strictEqual(getApiKey(), "env-key-12345");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns a string when PRDFORGE_API_KEY is unset", () => {
|
|
23
|
+
delete process.env.PRDFORGE_API_KEY;
|
|
24
|
+
assert.strictEqual(typeof getApiKey(), "string");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("getApiUrl", () => {
|
|
29
|
+
it("returns PRDFORGE_API_URL when set", () => {
|
|
30
|
+
process.env.PRDFORGE_API_URL = "https://custom.example.com/v1";
|
|
31
|
+
assert.strictEqual(getApiUrl(), "https://custom.example.com/v1");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns a string when PRDFORGE_API_URL is unset", () => {
|
|
35
|
+
delete process.env.PRDFORGE_API_URL;
|
|
36
|
+
assert.strictEqual(typeof getApiUrl(), "string");
|
|
37
|
+
assert.ok(getApiUrl().length > 0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import Table from "cli-table3";
|
|
2
|
+
|
|
3
|
+
const C = {
|
|
4
|
+
reset: "\x1b[0m",
|
|
5
|
+
bold: "\x1b[1m",
|
|
6
|
+
dim: "\x1b[2m",
|
|
7
|
+
green: "\x1b[32m",
|
|
8
|
+
red: "\x1b[31m",
|
|
9
|
+
yellow: "\x1b[33m",
|
|
10
|
+
blue: "\x1b[34m",
|
|
11
|
+
cyan: "\x1b[36m",
|
|
12
|
+
magenta: "\x1b[35m",
|
|
13
|
+
gray: "\x1b[90m",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const color = C;
|
|
17
|
+
|
|
18
|
+
export function success(msg) {
|
|
19
|
+
console.log(`${C.green}✔${C.reset} ${msg}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function error(msg) {
|
|
23
|
+
console.error(`${C.red}✖${C.reset} ${msg}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function warn(msg) {
|
|
27
|
+
console.warn(`${C.yellow}⚠${C.reset} ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function info(msg) {
|
|
31
|
+
console.log(`${C.blue}ℹ${C.reset} ${msg}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function dim(msg) {
|
|
35
|
+
console.log(`${C.gray}${msg}${C.reset}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function header(title) {
|
|
39
|
+
const line = "─".repeat(Math.min(process.stdout.columns || 60, 60));
|
|
40
|
+
console.log(`\n${C.bold}${C.cyan}${title}${C.reset}`);
|
|
41
|
+
console.log(`${C.gray}${line}${C.reset}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function table(columns, rows) {
|
|
45
|
+
const t = new Table({
|
|
46
|
+
head: columns.map((c) => `${C.bold}${C.cyan}${c}${C.reset}`),
|
|
47
|
+
style: { border: ["gray"], head: [] },
|
|
48
|
+
});
|
|
49
|
+
rows.forEach((r) => t.push(r));
|
|
50
|
+
console.log(t.toString());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function json(obj) {
|
|
54
|
+
console.log(JSON.stringify(obj, null, 2));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function printPRDSection(section) {
|
|
58
|
+
console.log(`\n${C.bold}${C.blue}## ${section.title}${C.reset}`);
|
|
59
|
+
console.log(`${C.gray}Type: ${section.section_type}${C.reset}`);
|
|
60
|
+
console.log(`\n${section.content}\n`);
|
|
61
|
+
}
|