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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { user, isAuthError } from "../api/client.js";
|
|
3
|
+
import { getEmail, requireApiKey } from "../utils/config.js";
|
|
4
|
+
import { header, dim, error } from "../utils/output.js";
|
|
5
|
+
|
|
6
|
+
export function whoamiCommand() {
|
|
7
|
+
return new Command("whoami")
|
|
8
|
+
.description("Show current authenticated user and usage")
|
|
9
|
+
.action(async () => {
|
|
10
|
+
requireApiKey();
|
|
11
|
+
try {
|
|
12
|
+
const data = await user.validate();
|
|
13
|
+
const email = getEmail() || data.email || "(unknown)";
|
|
14
|
+
header("Current User");
|
|
15
|
+
dim(` Email: ${email}`);
|
|
16
|
+
dim(` User ID: ${data.user_id}`);
|
|
17
|
+
dim(` Subscription: ${data.subscription}`);
|
|
18
|
+
dim(` Credits used: ${data.credits_used_this_month} this month`);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
error(err.message);
|
|
21
|
+
process.exit(isAuthError(err) ? 2 : 1);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
import { whoamiCommand } from "./commands/whoami.js";
|
|
12
|
+
import { creditsCommand } from "./commands/credits.js";
|
|
13
|
+
import { dashboardCommand } from "./commands/dashboard.js";
|
|
14
|
+
|
|
15
|
+
const LOGO = `
|
|
16
|
+
\x1b[1m\x1b[36m PRDForge CLI\x1b[0m \x1b[2mv0.1.0\x1b[0m
|
|
17
|
+
\x1b[90mThe planning layer for AI-first development\x1b[0m
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
const program = new Command();
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.name("prdforge")
|
|
24
|
+
.description("PRDForge CLI — generate, manage, and export PRDs from your terminal or AI agents")
|
|
25
|
+
.version("0.1.0", "-v, --version")
|
|
26
|
+
.addHelpText("beforeAll", LOGO)
|
|
27
|
+
.hook("preAction", async () => {
|
|
28
|
+
// Load .env from cwd if present
|
|
29
|
+
try {
|
|
30
|
+
const { config: dotenv } = await import("dotenv");
|
|
31
|
+
dotenv();
|
|
32
|
+
} catch {}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
program.addCommand(authCommand());
|
|
36
|
+
program.addCommand(whoamiCommand());
|
|
37
|
+
program.addCommand(creditsCommand());
|
|
38
|
+
program.addCommand(dashboardCommand());
|
|
39
|
+
program.addCommand(generateCommand());
|
|
40
|
+
program.addCommand(prdCommand());
|
|
41
|
+
program.addCommand(exportCommand(), { isDefault: false });
|
|
42
|
+
program.addCommand(watchCommand());
|
|
43
|
+
program.addCommand(mcpCommand());
|
|
44
|
+
program.addCommand(bulkCommand());
|
|
45
|
+
program.addCommand(configCommand());
|
|
46
|
+
|
|
47
|
+
// Friendly help for unknown commands
|
|
48
|
+
program.on("command:*", (cmds) => {
|
|
49
|
+
console.error(`\x1b[31m✖\x1b[0m Unknown command: ${cmds[0]}`);
|
|
50
|
+
console.log("Run \x1b[36mprdforge --help\x1b[0m for a list of commands.");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// When run with no arguments: open dashboard if authenticated, else show help
|
|
55
|
+
if (process.argv.length === 2) {
|
|
56
|
+
const { getApiKey } = await import("./utils/config.js");
|
|
57
|
+
if (getApiKey()) {
|
|
58
|
+
const { dashboardCommand } = await import("./commands/dashboard.js");
|
|
59
|
+
await dashboardCommand().parseAsync([], { from: "user" });
|
|
60
|
+
} else {
|
|
61
|
+
program.help();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
+
import { PromptBox } from "./PromptBox.js";
|
|
4
|
+
import { theme } from "./theme.js";
|
|
5
|
+
|
|
6
|
+
const TOTAL_STAGES = 8;
|
|
7
|
+
|
|
8
|
+
// ── Stage dots row ────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function StageDots({ completedStages, stale }) {
|
|
11
|
+
const count = completedStages || 0;
|
|
12
|
+
const dots = theme.stages.map((_, i) => {
|
|
13
|
+
if (stale && i === count - 1) {
|
|
14
|
+
return React.createElement(Text, { key: i, color: theme.warning }, "⚠");
|
|
15
|
+
}
|
|
16
|
+
if (i < count) {
|
|
17
|
+
return React.createElement(Text, { key: i, color: theme.success }, "✓");
|
|
18
|
+
}
|
|
19
|
+
return React.createElement(Text, { key: i, color: theme.dim }, "●");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return React.createElement(
|
|
23
|
+
Box,
|
|
24
|
+
{ gap: 1 },
|
|
25
|
+
...dots,
|
|
26
|
+
React.createElement(Text, { color: theme.dim }, ` ${count}/${TOTAL_STAGES}`)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Interactive dashboard TUI with two modes:
|
|
34
|
+
* "browse" — arrow-key navigate projects, n to enter compose
|
|
35
|
+
* "compose" — type a prompt, Tab to cycle model, Enter to create
|
|
36
|
+
*
|
|
37
|
+
* On create: exits TUI and calls onCreateNew({ name, prompt, modelId }).
|
|
38
|
+
* On Enter (browse): exits and prints the project workspace URL.
|
|
39
|
+
*/
|
|
40
|
+
export function Dashboard({ onCreateNew } = {}) {
|
|
41
|
+
const { exit } = useApp();
|
|
42
|
+
|
|
43
|
+
// Data
|
|
44
|
+
const [projectList, setProjectList] = useState([]);
|
|
45
|
+
const [userInfo, setUserInfo] = useState(null);
|
|
46
|
+
const [modelList, setModelList] = useState([]);
|
|
47
|
+
|
|
48
|
+
// UI state
|
|
49
|
+
const [mode, setMode] = useState("browse"); // "browse" | "compose"
|
|
50
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
51
|
+
const [loading, setLoading] = useState(true);
|
|
52
|
+
const [statusMsg, setStatusMsg] = useState(null);
|
|
53
|
+
|
|
54
|
+
// Compose state
|
|
55
|
+
const [promptText, setPromptText] = useState("");
|
|
56
|
+
const [modelIdx, setModelIdx] = useState(0);
|
|
57
|
+
|
|
58
|
+
// ── Load data ──────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
async function loadData() {
|
|
61
|
+
setLoading(true);
|
|
62
|
+
setStatusMsg(null);
|
|
63
|
+
try {
|
|
64
|
+
const { projects, user, models } = await import("../api/client.js");
|
|
65
|
+
const [projs, info, mods] = await Promise.all([
|
|
66
|
+
projects.list(),
|
|
67
|
+
user.validate(),
|
|
68
|
+
models.list(),
|
|
69
|
+
]);
|
|
70
|
+
setProjectList(Array.isArray(projs) ? projs : []);
|
|
71
|
+
setUserInfo(info);
|
|
72
|
+
setModelList(Array.isArray(mods) && mods.length > 0 ? mods : [
|
|
73
|
+
{ model_id: null, name: "Default", is_free_tier: true, credit_cost: 4 },
|
|
74
|
+
]);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
setStatusMsg(`Error: ${err.message}`);
|
|
77
|
+
} finally {
|
|
78
|
+
setLoading(false);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
useEffect(() => { loadData(); }, []);
|
|
83
|
+
|
|
84
|
+
// ── Keyboard handling ──────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
useInput((input, key) => {
|
|
87
|
+
if (loading) return;
|
|
88
|
+
|
|
89
|
+
if (mode === "compose") {
|
|
90
|
+
if (key.escape) {
|
|
91
|
+
setMode("browse");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (key.tab) {
|
|
95
|
+
setModelIdx((i) => (i + 1) % modelList.length);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (key.return && promptText.trim()) {
|
|
99
|
+
const chosen = modelList[modelIdx];
|
|
100
|
+
exit();
|
|
101
|
+
if (typeof onCreateNew === "function") {
|
|
102
|
+
onCreateNew({
|
|
103
|
+
prompt: promptText.trim(),
|
|
104
|
+
modelId: chosen?.model_id ?? null,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// All other keystrokes go to TextInput via onChange
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// browse mode
|
|
114
|
+
if (input === "q" || key.escape) { exit(); return; }
|
|
115
|
+
if (key.upArrow || input === "k") {
|
|
116
|
+
setSelectedIdx((i) => Math.max(0, i - 1));
|
|
117
|
+
}
|
|
118
|
+
if (key.downArrow || input === "j") {
|
|
119
|
+
setSelectedIdx((i) => Math.min(Math.max(0, projectList.length - 1), i + 1));
|
|
120
|
+
}
|
|
121
|
+
if (key.return && projectList[selectedIdx]) {
|
|
122
|
+
const p = projectList[selectedIdx];
|
|
123
|
+
exit();
|
|
124
|
+
process.stdout.write(`\nhttps://prdforge.netlify.app/workspace/${p.id}\n\n`);
|
|
125
|
+
}
|
|
126
|
+
if (input === "n" || input === "c") {
|
|
127
|
+
setMode("compose");
|
|
128
|
+
}
|
|
129
|
+
if (input === "r") {
|
|
130
|
+
loadData();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── Header ─────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
const headerRow = React.createElement(
|
|
137
|
+
Box,
|
|
138
|
+
{ justifyContent: "space-between", marginBottom: 1 },
|
|
139
|
+
React.createElement(
|
|
140
|
+
Box,
|
|
141
|
+
{ gap: 1 },
|
|
142
|
+
React.createElement(Text, { bold: true, color: theme.primary }, "PRDForge")
|
|
143
|
+
),
|
|
144
|
+
userInfo
|
|
145
|
+
? React.createElement(
|
|
146
|
+
Text,
|
|
147
|
+
{ color: theme.dim },
|
|
148
|
+
[
|
|
149
|
+
userInfo.email,
|
|
150
|
+
userInfo.credits_used_this_month != null
|
|
151
|
+
? `${userInfo.credits_used_this_month} credits used`
|
|
152
|
+
: null,
|
|
153
|
+
userInfo.subscription,
|
|
154
|
+
]
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join(" · ")
|
|
157
|
+
)
|
|
158
|
+
: null
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// ── Prompt box ─────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const promptBox = React.createElement(PromptBox, {
|
|
164
|
+
focused: mode === "compose",
|
|
165
|
+
value: promptText,
|
|
166
|
+
onChange: setPromptText,
|
|
167
|
+
models: modelList,
|
|
168
|
+
modelIdx,
|
|
169
|
+
onTabModel: () => setModelIdx((i) => (i + 1) % modelList.length),
|
|
170
|
+
onSubmit: ({ prompt, modelId }) => {
|
|
171
|
+
exit();
|
|
172
|
+
if (typeof onCreateNew === "function") onCreateNew({ prompt, modelId });
|
|
173
|
+
},
|
|
174
|
+
onFocus: () => setMode("compose"),
|
|
175
|
+
onCancel: () => setMode("browse"),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── Project list ───────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
let projectSection;
|
|
181
|
+
if (loading) {
|
|
182
|
+
projectSection = React.createElement(
|
|
183
|
+
Box,
|
|
184
|
+
{ marginY: 1 },
|
|
185
|
+
React.createElement(Text, { color: theme.dim }, "Loading…")
|
|
186
|
+
);
|
|
187
|
+
} else if (statusMsg) {
|
|
188
|
+
projectSection = React.createElement(
|
|
189
|
+
Box,
|
|
190
|
+
{ marginY: 1 },
|
|
191
|
+
React.createElement(Text, { color: theme.error }, statusMsg)
|
|
192
|
+
);
|
|
193
|
+
} else if (projectList.length === 0) {
|
|
194
|
+
projectSection = React.createElement(
|
|
195
|
+
Box,
|
|
196
|
+
{ marginY: 1 },
|
|
197
|
+
React.createElement(Text, { color: theme.dim }, "No projects yet. Press "),
|
|
198
|
+
React.createElement(Text, { color: theme.primary, bold: true }, "n"),
|
|
199
|
+
React.createElement(Text, { color: theme.dim }, " above to create one.")
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
const rows = projectList.map((p, i) => {
|
|
203
|
+
const isSelected = i === selectedIdx && mode === "browse";
|
|
204
|
+
const nameStr = (p.name || "(untitled)").padEnd(34).slice(0, 34);
|
|
205
|
+
return React.createElement(
|
|
206
|
+
Box,
|
|
207
|
+
{ key: p.id },
|
|
208
|
+
React.createElement(
|
|
209
|
+
Text,
|
|
210
|
+
{ color: isSelected ? theme.primary : theme.dim, bold: isSelected },
|
|
211
|
+
isSelected ? " ❯ " : " "
|
|
212
|
+
),
|
|
213
|
+
React.createElement(
|
|
214
|
+
Text,
|
|
215
|
+
{ color: isSelected ? theme.white : theme.dim, bold: isSelected },
|
|
216
|
+
nameStr
|
|
217
|
+
),
|
|
218
|
+
React.createElement(Text, { color: theme.dim }, " "),
|
|
219
|
+
React.createElement(StageDots, {
|
|
220
|
+
completedStages: p.completed_stages,
|
|
221
|
+
stale: p.has_stale_stages,
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
projectSection = React.createElement(
|
|
227
|
+
Box,
|
|
228
|
+
{ flexDirection: "column" },
|
|
229
|
+
React.createElement(
|
|
230
|
+
Box,
|
|
231
|
+
{ marginBottom: 1 },
|
|
232
|
+
React.createElement(Text, { color: theme.dim, bold: true }, " Your Projects"),
|
|
233
|
+
React.createElement(
|
|
234
|
+
Text,
|
|
235
|
+
{ color: theme.dim },
|
|
236
|
+
` (${projectList.length})`
|
|
237
|
+
)
|
|
238
|
+
),
|
|
239
|
+
...rows
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Footer ─────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
const footerHints = mode === "compose"
|
|
246
|
+
? "Tab: cycle model Enter: create Esc: cancel"
|
|
247
|
+
: "n compose ↑↓ navigate Enter open r refresh q quit";
|
|
248
|
+
|
|
249
|
+
const footer = React.createElement(
|
|
250
|
+
Box,
|
|
251
|
+
{ marginTop: 1, borderStyle: "single", borderColor: theme.dim, borderBottom: false, borderLeft: false, borderRight: false },
|
|
252
|
+
React.createElement(Text, { color: theme.dim }, footerHints)
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
return React.createElement(
|
|
256
|
+
Box,
|
|
257
|
+
{ flexDirection: "column", paddingX: 1, paddingTop: 1 },
|
|
258
|
+
headerRow,
|
|
259
|
+
promptBox,
|
|
260
|
+
projectSection,
|
|
261
|
+
footer
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { StageIndicator } from "./StageIndicator.js";
|
|
4
|
+
import { theme } from "./theme.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Animated PRD creation progress view.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ projectName: string, stages: Array<{ label: string, status: string }> }} props
|
|
10
|
+
*/
|
|
11
|
+
export function PrdCreation({ projectName, stages }) {
|
|
12
|
+
return React.createElement(
|
|
13
|
+
Box,
|
|
14
|
+
{ flexDirection: "column", paddingX: 1, paddingY: 0 },
|
|
15
|
+
|
|
16
|
+
// Header
|
|
17
|
+
React.createElement(
|
|
18
|
+
Box,
|
|
19
|
+
{ marginBottom: 1 },
|
|
20
|
+
React.createElement(
|
|
21
|
+
Text,
|
|
22
|
+
{ bold: true, color: theme.primary },
|
|
23
|
+
"Creating PRD"
|
|
24
|
+
),
|
|
25
|
+
React.createElement(Text, { color: theme.dim }, " "),
|
|
26
|
+
React.createElement(Text, { color: theme.white }, projectName)
|
|
27
|
+
),
|
|
28
|
+
|
|
29
|
+
// Stage rows
|
|
30
|
+
...stages.map((stage, i) =>
|
|
31
|
+
React.createElement(
|
|
32
|
+
Box,
|
|
33
|
+
{ key: i, marginLeft: 2, marginBottom: 0 },
|
|
34
|
+
React.createElement(StageIndicator, { label: stage.label, status: stage.status })
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the initial stages array (all pending).
|
|
42
|
+
* @param {string[]} labels
|
|
43
|
+
* @returns {Array<{ label: string, status: string }>}
|
|
44
|
+
*/
|
|
45
|
+
export function buildStages(labels) {
|
|
46
|
+
return labels.map((label) => ({ label, status: "pending" }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns a new stages array with the given index set to the given status.
|
|
51
|
+
* @param {Array<{ label: string, status: string }>} stages
|
|
52
|
+
* @param {number} index
|
|
53
|
+
* @param {string} status
|
|
54
|
+
*/
|
|
55
|
+
export function setStageStatus(stages, index, status) {
|
|
56
|
+
return stages.map((s, i) => (i === index ? { ...s, status } : s));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mark all remaining pending/active stages as done.
|
|
61
|
+
*/
|
|
62
|
+
export function markAllDone(stages) {
|
|
63
|
+
return stages.map((s) =>
|
|
64
|
+
s.status === "pending" || s.status === "active" ? { ...s, status: "done" } : s
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import { theme } from "./theme.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Styled prompt box matching the main app's overview "Start with AI" section.
|
|
8
|
+
*
|
|
9
|
+
* Props:
|
|
10
|
+
* focused boolean — whether text input is active
|
|
11
|
+
* value string — current prompt text
|
|
12
|
+
* onChange fn — called with new string as user types
|
|
13
|
+
* models Array — [{ model_id, name, credit_cost, is_free_tier }]
|
|
14
|
+
* modelIdx number — index into models array
|
|
15
|
+
* onTabModel fn — called when Tab pressed to cycle model
|
|
16
|
+
* onSubmit fn — called with { prompt, modelId } when Enter pressed
|
|
17
|
+
* onFocus fn — called when user presses key to focus the box
|
|
18
|
+
* onCancel fn — called when Esc pressed while focused
|
|
19
|
+
*/
|
|
20
|
+
export function PromptBox({
|
|
21
|
+
focused,
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
models,
|
|
25
|
+
modelIdx,
|
|
26
|
+
onTabModel,
|
|
27
|
+
onSubmit,
|
|
28
|
+
onFocus,
|
|
29
|
+
onCancel,
|
|
30
|
+
}) {
|
|
31
|
+
const currentModel = models?.[modelIdx] ?? { name: "Default", credit_cost: 4 };
|
|
32
|
+
const creditCost = (currentModel.credit_cost ?? 4) * 4; // 4 calls for full generation
|
|
33
|
+
const modelLabel = currentModel.name;
|
|
34
|
+
|
|
35
|
+
// ── Title bar ───────────────────────────────────────────────────
|
|
36
|
+
const titleBar = React.createElement(
|
|
37
|
+
Box,
|
|
38
|
+
{ justifyContent: "space-between" },
|
|
39
|
+
React.createElement(
|
|
40
|
+
Text,
|
|
41
|
+
{ color: focused ? theme.primary : theme.dim, bold: focused },
|
|
42
|
+
focused ? "✦ Start with AI" : "✦ Start with AI"
|
|
43
|
+
),
|
|
44
|
+
React.createElement(
|
|
45
|
+
Text,
|
|
46
|
+
{ color: theme.dim },
|
|
47
|
+
focused
|
|
48
|
+
? `~${creditCost} credits`
|
|
49
|
+
: "n to compose"
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// ── Input area ──────────────────────────────────────────────────
|
|
54
|
+
let inputArea;
|
|
55
|
+
if (focused) {
|
|
56
|
+
inputArea = React.createElement(
|
|
57
|
+
Box,
|
|
58
|
+
{ marginTop: 1, marginBottom: 1 },
|
|
59
|
+
React.createElement(TextInput, {
|
|
60
|
+
value,
|
|
61
|
+
onChange,
|
|
62
|
+
placeholder: "Describe your product idea (e.g. 'A Kanban app for remote teams with Slack integration')",
|
|
63
|
+
focus: true,
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
} else {
|
|
67
|
+
inputArea = React.createElement(
|
|
68
|
+
Box,
|
|
69
|
+
{ marginTop: 1, marginBottom: 1 },
|
|
70
|
+
React.createElement(
|
|
71
|
+
Text,
|
|
72
|
+
{ color: theme.dim },
|
|
73
|
+
value.length > 0
|
|
74
|
+
? value.length > 72
|
|
75
|
+
? value.slice(0, 69) + "…"
|
|
76
|
+
: value
|
|
77
|
+
: "Describe your product idea…"
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Model selector row ──────────────────────────────────────────
|
|
83
|
+
const modelRow = React.createElement(
|
|
84
|
+
Box,
|
|
85
|
+
{ justifyContent: "space-between" },
|
|
86
|
+
// Model pill
|
|
87
|
+
React.createElement(
|
|
88
|
+
Box,
|
|
89
|
+
{ gap: 1 },
|
|
90
|
+
React.createElement(
|
|
91
|
+
Text,
|
|
92
|
+
{ color: focused ? theme.primary : theme.dim },
|
|
93
|
+
"◉"
|
|
94
|
+
),
|
|
95
|
+
React.createElement(
|
|
96
|
+
Text,
|
|
97
|
+
{ color: focused ? theme.white : theme.dim, bold: focused },
|
|
98
|
+
modelLabel
|
|
99
|
+
),
|
|
100
|
+
currentModel.is_free_tier === false
|
|
101
|
+
? React.createElement(Text, { color: theme.warning }, " ★")
|
|
102
|
+
: null,
|
|
103
|
+
),
|
|
104
|
+
// Key hints
|
|
105
|
+
React.createElement(
|
|
106
|
+
Text,
|
|
107
|
+
{ color: theme.dim },
|
|
108
|
+
focused
|
|
109
|
+
? "Tab: model Enter: create Esc: cancel"
|
|
110
|
+
: ""
|
|
111
|
+
)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const borderColor = focused ? theme.primary : theme.dim;
|
|
115
|
+
|
|
116
|
+
return React.createElement(
|
|
117
|
+
Box,
|
|
118
|
+
{
|
|
119
|
+
flexDirection: "column",
|
|
120
|
+
borderStyle: "round",
|
|
121
|
+
borderColor,
|
|
122
|
+
paddingX: 1,
|
|
123
|
+
marginBottom: 1,
|
|
124
|
+
},
|
|
125
|
+
titleBar,
|
|
126
|
+
inputArea,
|
|
127
|
+
modelRow
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import { theme } from "./theme.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renders a single stage row with a status indicator.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ label: string, status: "pending"|"active"|"done"|"stale" }} props
|
|
10
|
+
*/
|
|
11
|
+
export function StageIndicator({ label, status }) {
|
|
12
|
+
if (status === "active") {
|
|
13
|
+
return React.createElement(
|
|
14
|
+
Text,
|
|
15
|
+
null,
|
|
16
|
+
React.createElement(Spinner, { type: "dots" }),
|
|
17
|
+
" ",
|
|
18
|
+
React.createElement(Text, { color: theme.white }, label)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dot = status === "done" ? "✓"
|
|
23
|
+
: status === "stale" ? "⚠"
|
|
24
|
+
: "●";
|
|
25
|
+
const color = status === "done" ? theme.success
|
|
26
|
+
: status === "stale" ? theme.warning
|
|
27
|
+
: theme.dim;
|
|
28
|
+
|
|
29
|
+
return React.createElement(
|
|
30
|
+
Text,
|
|
31
|
+
null,
|
|
32
|
+
React.createElement(Text, { color }, dot),
|
|
33
|
+
" ",
|
|
34
|
+
React.createElement(
|
|
35
|
+
Text,
|
|
36
|
+
{ color: status === "pending" ? theme.dim : theme.white },
|
|
37
|
+
label
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
}
|
package/src/ui/theme.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const theme = {
|
|
2
|
+
// Terminal ANSI-safe hex colors matching the main app's dark-blue palette
|
|
3
|
+
primary: "#5B8DEF", // bright blue
|
|
4
|
+
success: "#22C55E", // green checkmark / done
|
|
5
|
+
warning: "#F97316", // orange stale
|
|
6
|
+
error: "#EF4444", // red
|
|
7
|
+
dim: "#6B7280", // grey inactive
|
|
8
|
+
white: "#F1F5F9", // near-white text
|
|
9
|
+
|
|
10
|
+
// The 8 completion stages (overview + versions not counted)
|
|
11
|
+
stages: [
|
|
12
|
+
"Product Details",
|
|
13
|
+
"PRD Sections",
|
|
14
|
+
"Modules",
|
|
15
|
+
"Architecture",
|
|
16
|
+
"Dependencies",
|
|
17
|
+
"Guardrails",
|
|
18
|
+
"Tasks",
|
|
19
|
+
"Export",
|
|
20
|
+
],
|
|
21
|
+
};
|