prdforge-cli 0.1.0 → 0.2.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/package.json +2 -2
- package/src/api/client.js +35 -9
- package/src/api/stream.js +97 -0
- package/src/commands/auth.js +132 -1
- package/src/commands/dashboard.js +2 -74
- package/src/hooks/useScroll.js +20 -0
- package/src/hooks/useStream.js +144 -0
- package/src/hooks/useTerminalSize.js +24 -0
- package/src/index.js +10 -5
- package/src/ui/LeftPane.js +215 -0
- package/src/ui/PrdCreation.js +1 -1
- package/src/ui/PreviewPanel.js +148 -0
- package/src/ui/REPLDashboard.js +418 -0
- package/src/ui/ReplPanel.js +227 -0
- package/src/ui/RightPane.js +66 -0
- package/src/ui/StageIndicator.js +51 -20
- package/src/ui/theme.js +4 -2
- package/src/utils/debug.js +7 -0
- package/src/utils/logo.js +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prdforge-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Official CLI for PRDForge — generate and manage PRDs from your terminal or AI agents",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -40,13 +40,13 @@
|
|
|
40
40
|
"commander": "^12.1.0",
|
|
41
41
|
"conf": "^13.0.0",
|
|
42
42
|
"dotenv": "^16.4.5",
|
|
43
|
+
"figlet": "^1.11.0",
|
|
43
44
|
"ink": "^5.2.1",
|
|
44
45
|
"ink-spinner": "^5.0.0",
|
|
45
46
|
"ink-text-input": "^6.0.0",
|
|
46
47
|
"inquirer": "^10.2.2",
|
|
47
48
|
"marked": "^14.1.2",
|
|
48
49
|
"marked-terminal": "^7.1.0",
|
|
49
|
-
"node-fetch": "^3.3.2",
|
|
50
50
|
"ora": "^8.1.1",
|
|
51
51
|
"react": "^18.3.1"
|
|
52
52
|
},
|
package/src/api/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getApiUrl, requireApiKey } from "../utils/config.js";
|
|
2
|
+
import { debugLog } from "../utils/debug.js";
|
|
2
3
|
|
|
3
4
|
/** User-facing messages for HTTP status codes (centralized for CLI and agents). */
|
|
4
5
|
function messageForStatus(status, bodyMessage) {
|
|
@@ -24,16 +25,25 @@ function messageForStatus(status, bodyMessage) {
|
|
|
24
25
|
* Make an authenticated request to the PRDForge API (Supabase Edge Functions).
|
|
25
26
|
* Throws an Error with user-facing message and .httpStatus for exit code handling.
|
|
26
27
|
*/
|
|
28
|
+
export function buildHeaders(apiKey) {
|
|
29
|
+
return {
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
32
|
+
"x-prdforge-cli": "1",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
async function request(path, { method = "GET", body } = {}) {
|
|
28
37
|
const apiKey = requireApiKey();
|
|
29
38
|
const baseUrl = getApiUrl();
|
|
30
39
|
const url = `${baseUrl}${path}`;
|
|
31
40
|
|
|
32
|
-
const headers =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
const headers = buildHeaders(apiKey);
|
|
42
|
+
|
|
43
|
+
debugLog("request", { method, url });
|
|
44
|
+
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const timeoutId = setTimeout(() => controller.abort(), 30_000);
|
|
37
47
|
|
|
38
48
|
let res;
|
|
39
49
|
try {
|
|
@@ -41,13 +51,23 @@ async function request(path, { method = "GET", body } = {}) {
|
|
|
41
51
|
method,
|
|
42
52
|
headers,
|
|
43
53
|
body: body ? JSON.stringify(body) : undefined,
|
|
54
|
+
signal: controller.signal,
|
|
44
55
|
});
|
|
45
56
|
} catch (e) {
|
|
46
|
-
const
|
|
57
|
+
const isTimeout = e.name === "AbortError";
|
|
58
|
+
const err = new Error(
|
|
59
|
+
isTimeout
|
|
60
|
+
? "Request timed out. Check your connection and try again."
|
|
61
|
+
: "Could not reach PRDForge. Check your connection and API URL."
|
|
62
|
+
);
|
|
47
63
|
err.httpStatus = 0;
|
|
48
64
|
throw err;
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timeoutId);
|
|
49
67
|
}
|
|
50
68
|
|
|
69
|
+
debugLog("response", { status: res.status });
|
|
70
|
+
|
|
51
71
|
if (!res.ok) {
|
|
52
72
|
let bodyMessage;
|
|
53
73
|
try {
|
|
@@ -131,10 +151,16 @@ export const exports_ = {
|
|
|
131
151
|
// ─── Auth ────────────────────────────────────────────────────────────────────
|
|
132
152
|
|
|
133
153
|
export const auth = {
|
|
134
|
-
validate: (apiKey) =>
|
|
135
|
-
|
|
154
|
+
validate: (apiKey) => {
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const timeoutId = setTimeout(() => controller.abort(), 30_000);
|
|
157
|
+
return fetch(`${getApiUrl()}/prdforge-api/auth/validate`, {
|
|
136
158
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
137
|
-
|
|
159
|
+
signal: controller.signal,
|
|
160
|
+
})
|
|
161
|
+
.then((r) => r.json())
|
|
162
|
+
.finally(() => clearTimeout(timeoutId));
|
|
163
|
+
},
|
|
138
164
|
};
|
|
139
165
|
|
|
140
166
|
// ─── User ────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { buildHeaders } from "./client.js";
|
|
2
|
+
import { getApiUrl, requireApiKey } from "../utils/config.js";
|
|
3
|
+
import { debugLog } from "../utils/debug.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Async generator that streams PRD AI events via SSE.
|
|
7
|
+
* Falls back to a single synthesized "done" event if the server returns JSON.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ action: string, project_id?: string, prompt?: string, model_id?: string }} body
|
|
10
|
+
* @param {AbortSignal} [signal]
|
|
11
|
+
* @yields {{ event: string, data: object }}
|
|
12
|
+
*/
|
|
13
|
+
export async function* streamPrdAi(body, signal) {
|
|
14
|
+
const apiKey = requireApiKey();
|
|
15
|
+
const url = `${getApiUrl()}/prdforge-ai`;
|
|
16
|
+
debugLog("stream:request", { url, action: body.action });
|
|
17
|
+
|
|
18
|
+
let res;
|
|
19
|
+
try {
|
|
20
|
+
res = await fetch(url, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { ...buildHeaders(apiKey), "Accept": "text/event-stream" },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
signal,
|
|
25
|
+
});
|
|
26
|
+
} catch (e) {
|
|
27
|
+
const isAbort = e.name === "AbortError";
|
|
28
|
+
if (isAbort) return; // clean cancellation
|
|
29
|
+
const err = new Error("Could not reach PRDForge. Check your connection and API URL.");
|
|
30
|
+
err.httpStatus = 0;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Fallback: server returned JSON (no SSE support yet)
|
|
35
|
+
if (!res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
36
|
+
debugLog("stream:fallback", { status: res.status });
|
|
37
|
+
let json;
|
|
38
|
+
try { json = await res.json(); } catch { json = {}; }
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const err = new Error(json.error ?? `Request failed (HTTP ${res.status}).`);
|
|
41
|
+
err.httpStatus = res.status;
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
yield { event: "done", data: json };
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// SSE stream
|
|
49
|
+
const decoder = new TextDecoder();
|
|
50
|
+
let buffer = "";
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
for await (const chunk of res.body) {
|
|
54
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
55
|
+
const blocks = buffer.split("\n\n");
|
|
56
|
+
buffer = blocks.pop(); // keep incomplete trailing block
|
|
57
|
+
for (const block of blocks) {
|
|
58
|
+
const parsed = parseSSEBlock(block);
|
|
59
|
+
if (parsed) {
|
|
60
|
+
debugLog("stream:event", { event: parsed.event });
|
|
61
|
+
yield parsed;
|
|
62
|
+
if (parsed.event === "done" || parsed.event === "error") return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (e.name === "AbortError") return;
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Flush any remaining buffer after stream ends
|
|
72
|
+
if (buffer.trim()) {
|
|
73
|
+
const parsed = parseSSEBlock(buffer);
|
|
74
|
+
if (parsed) yield parsed;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse a single SSE block (lines separated by \n, blocks separated by \n\n).
|
|
80
|
+
* @param {string} block
|
|
81
|
+
* @returns {{ event: string, data: object | string } | null}
|
|
82
|
+
*/
|
|
83
|
+
function parseSSEBlock(block) {
|
|
84
|
+
let event = "message";
|
|
85
|
+
let data = null;
|
|
86
|
+
|
|
87
|
+
for (const line of block.split("\n")) {
|
|
88
|
+
if (line.startsWith("event: ")) {
|
|
89
|
+
event = line.slice(7).trim();
|
|
90
|
+
} else if (line.startsWith("data: ")) {
|
|
91
|
+
const raw = line.slice(6);
|
|
92
|
+
try { data = JSON.parse(raw); } catch { data = raw; }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return data !== null ? { event, data } : null;
|
|
97
|
+
}
|
package/src/commands/auth.js
CHANGED
|
@@ -120,10 +120,141 @@ export function authCommand() {
|
|
|
120
120
|
info("Run 'prdforge prd list' to see your projects.");
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
cmd
|
|
124
|
+
.command("signup")
|
|
125
|
+
.description("Create a new PRDForge account")
|
|
126
|
+
.action(async () => {
|
|
127
|
+
const { default: inquirer } = await import("inquirer");
|
|
128
|
+
const { default: ora } = await import("ora");
|
|
129
|
+
|
|
130
|
+
const { email } = await inquirer.prompt([{
|
|
131
|
+
type: "input",
|
|
132
|
+
name: "email",
|
|
133
|
+
message: "Email address:",
|
|
134
|
+
validate: (v) => v.trim().includes("@") || "Enter a valid email",
|
|
135
|
+
}]);
|
|
136
|
+
|
|
137
|
+
const { firstName } = await inquirer.prompt([{
|
|
138
|
+
type: "input",
|
|
139
|
+
name: "firstName",
|
|
140
|
+
message: "First name (optional, press Enter to skip):",
|
|
141
|
+
}]);
|
|
142
|
+
|
|
143
|
+
const authBase = getAuthBase();
|
|
144
|
+
process.stdout.write("\n");
|
|
145
|
+
const spinner = ora("Creating your account…").start();
|
|
146
|
+
|
|
147
|
+
let sendRes, sendData;
|
|
148
|
+
try {
|
|
149
|
+
sendRes = await fetch(authBase, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { "Content-Type": "application/json" },
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
action: "signup",
|
|
154
|
+
email: email.trim().toLowerCase(),
|
|
155
|
+
...(firstName.trim() ? { first_name: firstName.trim().slice(0, 50) } : {}),
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
sendData = await sendRes.json().catch(() => ({}));
|
|
159
|
+
} catch {
|
|
160
|
+
spinner.fail("Network error — could not reach PRDForge.");
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const errMsg = (sendData.error ?? "").toLowerCase();
|
|
165
|
+
|
|
166
|
+
if (!sendRes.ok && !errMsg.includes("already") && !errMsg.includes("exists")
|
|
167
|
+
&& sendRes.status !== 404 && sendRes.status !== 405) {
|
|
168
|
+
spinner.fail(sendData.error ?? "Signup failed.");
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (errMsg.includes("already") || errMsg.includes("exists")) {
|
|
173
|
+
spinner.warn("Account already exists — sending a login code instead.");
|
|
174
|
+
try {
|
|
175
|
+
sendRes = await fetch(authBase, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "Content-Type": "application/json" },
|
|
178
|
+
body: JSON.stringify({ action: "request", email: email.trim().toLowerCase() }),
|
|
179
|
+
});
|
|
180
|
+
sendData = await sendRes.json().catch(() => ({}));
|
|
181
|
+
} catch {
|
|
182
|
+
error("Network error.");
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
if (!sendRes.ok || !sendData.success) {
|
|
186
|
+
error(sendData.error ?? "Failed to send code.");
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
} else if (sendRes.status === 404 || sendRes.status === 405) {
|
|
190
|
+
// No dedicated signup endpoint — fall back to OTP request silently
|
|
191
|
+
try {
|
|
192
|
+
sendRes = await fetch(authBase, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "Content-Type": "application/json" },
|
|
195
|
+
body: JSON.stringify({ action: "request", email: email.trim().toLowerCase() }),
|
|
196
|
+
});
|
|
197
|
+
sendData = await sendRes.json().catch(() => ({}));
|
|
198
|
+
} catch {
|
|
199
|
+
spinner.fail("Network error.");
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
if (!sendRes.ok || !sendData.success) {
|
|
203
|
+
spinner.fail(sendData.error ?? "Failed to send verification code.");
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
spinner.succeed("Verification code sent — check your email.");
|
|
209
|
+
|
|
210
|
+
const { code } = await inquirer.prompt([{
|
|
211
|
+
type: "input",
|
|
212
|
+
name: "code",
|
|
213
|
+
message: "Enter the 6-digit code:",
|
|
214
|
+
validate: (v) => /^\d{6}$/.test(v.trim()) || "Must be 6 digits",
|
|
215
|
+
}]);
|
|
216
|
+
|
|
217
|
+
const verSpinner = ora("Verifying…").start();
|
|
218
|
+
let verRes, verData;
|
|
219
|
+
try {
|
|
220
|
+
verRes = await fetch(authBase, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: { "Content-Type": "application/json" },
|
|
223
|
+
body: JSON.stringify({ action: "verify", email: email.trim().toLowerCase(), code: code.trim() }),
|
|
224
|
+
});
|
|
225
|
+
verData = await verRes.json().catch(() => ({}));
|
|
226
|
+
} catch {
|
|
227
|
+
verSpinner.fail("Network error.");
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!verRes.ok || !verData.success) {
|
|
232
|
+
verSpinner.fail(verData.error ?? "Invalid or expired code.");
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
config.set("apiKey", verData.api_key);
|
|
237
|
+
setEmail(verData.email ?? email.trim().toLowerCase());
|
|
238
|
+
verSpinner.succeed(`Welcome to PRDForge! Signed in as ${verData.email ?? email}`);
|
|
239
|
+
dim(" Config: " + config.path);
|
|
240
|
+
info("Run 'prdforge prd list' or just 'prdforge' to get started.");
|
|
241
|
+
});
|
|
242
|
+
|
|
123
243
|
cmd
|
|
124
244
|
.command("logout")
|
|
125
245
|
.description("Remove stored API key")
|
|
126
|
-
.
|
|
246
|
+
.option("-f, --force", "Skip confirmation prompt")
|
|
247
|
+
.action(async (opts) => {
|
|
248
|
+
if (!opts.force) {
|
|
249
|
+
const { default: inquirer } = await import("inquirer");
|
|
250
|
+
const { confirmed } = await inquirer.prompt([{
|
|
251
|
+
type: "confirm",
|
|
252
|
+
name: "confirmed",
|
|
253
|
+
message: "Remove your stored API key?",
|
|
254
|
+
default: false,
|
|
255
|
+
}]);
|
|
256
|
+
if (!confirmed) { info("Logout cancelled."); return; }
|
|
257
|
+
}
|
|
127
258
|
config.delete("apiKey");
|
|
128
259
|
success("Logged out — API key removed.");
|
|
129
260
|
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { render } from "ink";
|
|
4
|
-
import { Dashboard } from "../ui/Dashboard.js";
|
|
5
4
|
import { requireApiKey } from "../utils/config.js";
|
|
6
5
|
|
|
7
6
|
export function dashboardCommand() {
|
|
@@ -10,79 +9,8 @@ export function dashboardCommand() {
|
|
|
10
9
|
.description("Open interactive project overview (keyboard navigable)")
|
|
11
10
|
.action(async () => {
|
|
12
11
|
requireApiKey();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const { waitUntilExit } = render(
|
|
17
|
-
React.createElement(Dashboard, {
|
|
18
|
-
onCreateNew: (args) => { createArgs = args; },
|
|
19
|
-
})
|
|
20
|
-
);
|
|
21
|
-
|
|
12
|
+
const { REPLDashboard } = await import("../ui/REPLDashboard.js");
|
|
13
|
+
const { waitUntilExit } = render(React.createElement(REPLDashboard));
|
|
22
14
|
await waitUntilExit();
|
|
23
|
-
|
|
24
|
-
// If user pressed Enter in compose mode, launch the prd create flow
|
|
25
|
-
if (createArgs) {
|
|
26
|
-
const { prdCommand } = await import("./prd.js");
|
|
27
|
-
const { projects, prd, isAuthError } = await import("../api/client.js");
|
|
28
|
-
const { success, error, info, dim } = await import("../utils/output.js");
|
|
29
|
-
const { default: ora } = await import("ora");
|
|
30
|
-
const { render: inkRender } = await import("ink");
|
|
31
|
-
const { PrdCreation, buildStages, setStageStatus, markAllDone } = await import("../ui/PrdCreation.js");
|
|
32
|
-
const { theme } = await import("../ui/theme.js");
|
|
33
|
-
|
|
34
|
-
// Prompt for project name
|
|
35
|
-
const { default: inquirer } = await import("inquirer");
|
|
36
|
-
const { projectName } = await inquirer.prompt([
|
|
37
|
-
{
|
|
38
|
-
type: "input",
|
|
39
|
-
name: "projectName",
|
|
40
|
-
message: "Project name:",
|
|
41
|
-
validate: (v) => v.trim().length > 0 || "Required",
|
|
42
|
-
},
|
|
43
|
-
]);
|
|
44
|
-
|
|
45
|
-
const { prompt, modelId } = createArgs;
|
|
46
|
-
|
|
47
|
-
// Animated stage progress
|
|
48
|
-
let stages = buildStages(theme.stages);
|
|
49
|
-
stages = setStageStatus(stages, 0, "active");
|
|
50
|
-
|
|
51
|
-
const inkInstance = inkRender(
|
|
52
|
-
React.createElement(PrdCreation, { projectName: projectName.trim(), stages })
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
const STAGE_INTERVAL_MS = 8000;
|
|
56
|
-
let currentStage = 0;
|
|
57
|
-
const timer = setInterval(() => {
|
|
58
|
-
if (currentStage < theme.stages.length - 1) {
|
|
59
|
-
stages = setStageStatus(stages, currentStage, "done");
|
|
60
|
-
currentStage += 1;
|
|
61
|
-
stages = setStageStatus(stages, currentStage, "active");
|
|
62
|
-
inkInstance.rerender(
|
|
63
|
-
React.createElement(PrdCreation, { projectName: projectName.trim(), stages })
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
}, STAGE_INTERVAL_MS);
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
const project = await projects.create(projectName.trim(), prompt);
|
|
70
|
-
const result = await prd.generate(project.id, prompt, modelId ?? undefined);
|
|
71
|
-
clearInterval(timer);
|
|
72
|
-
stages = markAllDone(stages);
|
|
73
|
-
inkInstance.rerender(
|
|
74
|
-
React.createElement(PrdCreation, { projectName: projectName.trim(), stages })
|
|
75
|
-
);
|
|
76
|
-
inkInstance.unmount();
|
|
77
|
-
success(`Project "${projectName.trim()}" created`);
|
|
78
|
-
dim(` ID: ${project.id}`);
|
|
79
|
-
info(`View in browser: https://prdforge.netlify.app/workspace/${project.id}`);
|
|
80
|
-
} catch (err) {
|
|
81
|
-
clearInterval(timer);
|
|
82
|
-
inkInstance.unmount();
|
|
83
|
-
error(err.message);
|
|
84
|
-
process.exit(isAuthError(err) ? 2 : 1);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
15
|
});
|
|
88
16
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Virtual windowing scroll hook.
|
|
5
|
+
* @param {number} totalItems - total number of items in the list
|
|
6
|
+
* @param {number} visibleRows - how many rows can be shown at once
|
|
7
|
+
*/
|
|
8
|
+
export function useScroll(totalItems, visibleRows) {
|
|
9
|
+
const [offset, setOffset] = useState(0);
|
|
10
|
+
|
|
11
|
+
const clamp = (o) => Math.min(Math.max(o, 0), Math.max(0, totalItems - visibleRows));
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
offset,
|
|
15
|
+
scrollDown: () => setOffset((o) => clamp(o + 1)),
|
|
16
|
+
scrollUp: () => setOffset((o) => clamp(o - 1)),
|
|
17
|
+
jumpToBottom: () => setOffset(clamp(totalItems)),
|
|
18
|
+
reset: () => setOffset(0),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
2
|
+
import { streamPrdAi } from "../api/stream.js";
|
|
3
|
+
import { theme } from "../ui/theme.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages SSE streaming state for PRD generation and updates.
|
|
7
|
+
*
|
|
8
|
+
* Returns:
|
|
9
|
+
* stages — [{label, status, preview}] for StageIndicator rendering
|
|
10
|
+
* messages — [{id, role, text, status}] for ReplPanel history
|
|
11
|
+
* setMessages — direct setter (for adding user messages)
|
|
12
|
+
* isStreaming — bool
|
|
13
|
+
* error — string | null
|
|
14
|
+
* startStream — (body, options?) => void begins stream
|
|
15
|
+
* abort — () => void cancels current stream
|
|
16
|
+
*/
|
|
17
|
+
export function useStream() {
|
|
18
|
+
const [stages, setStages] = useState([]);
|
|
19
|
+
const [messages, setMessages] = useState([]);
|
|
20
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
21
|
+
const [error, setError] = useState(null);
|
|
22
|
+
|
|
23
|
+
const abortRef = useRef(null);
|
|
24
|
+
const chunkBufRef = useRef({});
|
|
25
|
+
const flushIntervalRef = useRef(null);
|
|
26
|
+
|
|
27
|
+
// Flush accumulated chunk buffers to stage preview state at ~12fps
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!isStreaming) return;
|
|
30
|
+
const id = setInterval(() => {
|
|
31
|
+
const buf = chunkBufRef.current;
|
|
32
|
+
if (Object.keys(buf).length === 0) return;
|
|
33
|
+
setStages((prev) =>
|
|
34
|
+
prev.map((s, i) =>
|
|
35
|
+
buf[i] !== undefined ? { ...s, preview: buf[i].slice(0, 120) } : s
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
}, 80);
|
|
39
|
+
flushIntervalRef.current = id;
|
|
40
|
+
return () => clearInterval(id);
|
|
41
|
+
}, [isStreaming]);
|
|
42
|
+
|
|
43
|
+
const startStream = useCallback(async (body, { initialStages } = {}) => {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
abortRef.current = controller;
|
|
46
|
+
chunkBufRef.current = {};
|
|
47
|
+
setError(null);
|
|
48
|
+
setIsStreaming(true);
|
|
49
|
+
|
|
50
|
+
if (initialStages) {
|
|
51
|
+
setStages(initialStages.map((label) => ({ label, status: "pending", preview: undefined })));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Add a streaming placeholder message in REPL history (for smart_update)
|
|
55
|
+
if (body.action === "smart_update") {
|
|
56
|
+
setMessages((prev) => [
|
|
57
|
+
...prev,
|
|
58
|
+
{ id: crypto.randomUUID(), role: "ai", text: "Analysing PRD…", status: "streaming" },
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
for await (const { event, data } of streamPrdAi(body, controller.signal)) {
|
|
64
|
+
if (event === "stage_start") {
|
|
65
|
+
const i = data.stage_index ?? 0;
|
|
66
|
+
setStages((prev) =>
|
|
67
|
+
prev.map((s, idx) =>
|
|
68
|
+
idx < i ? { ...s, status: "done", preview: undefined } :
|
|
69
|
+
idx === i ? { ...s, status: "streaming" } : s
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
} else if (event === "stage_chunk") {
|
|
73
|
+
const i = data.stage_index ?? 0;
|
|
74
|
+
chunkBufRef.current[i] = (chunkBufRef.current[i] ?? "") + (data.chunk ?? "");
|
|
75
|
+
} else if (event === "stage_done") {
|
|
76
|
+
const i = data.stage_index ?? 0;
|
|
77
|
+
delete chunkBufRef.current[i];
|
|
78
|
+
setStages((prev) =>
|
|
79
|
+
prev.map((s, idx) =>
|
|
80
|
+
idx === i ? { ...s, status: "done", preview: undefined } : s
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
} else if (event === "update_progress") {
|
|
84
|
+
const msg = data.message ?? "Updating…";
|
|
85
|
+
setMessages((prev) => {
|
|
86
|
+
const idx = prev.findLastIndex((m) => m.status === "streaming");
|
|
87
|
+
if (idx === -1) return prev;
|
|
88
|
+
return prev.map((m, i) => i === idx ? { ...m, text: msg } : m);
|
|
89
|
+
});
|
|
90
|
+
} else if (event === "done") {
|
|
91
|
+
const summary = data.summary ? ` — ${data.summary}` : "";
|
|
92
|
+
setStages((prev) =>
|
|
93
|
+
prev.map((s) =>
|
|
94
|
+
s.status === "streaming" || s.status === "pending"
|
|
95
|
+
? { ...s, status: "done", preview: undefined }
|
|
96
|
+
: s
|
|
97
|
+
)
|
|
98
|
+
);
|
|
99
|
+
if (body.action === "smart_update") {
|
|
100
|
+
setMessages((prev) =>
|
|
101
|
+
prev.map((m) =>
|
|
102
|
+
m.status === "streaming"
|
|
103
|
+
? { ...m, role: "status", text: `✓ Done${summary}`, status: "done" }
|
|
104
|
+
: m
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
} else if (event === "error") {
|
|
110
|
+
const msg = data.message ?? "Generation failed";
|
|
111
|
+
setError(msg);
|
|
112
|
+
setMessages((prev) =>
|
|
113
|
+
prev.map((m) =>
|
|
114
|
+
m.status === "streaming"
|
|
115
|
+
? { ...m, text: msg, status: "error" }
|
|
116
|
+
: m
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (e.name !== "AbortError") {
|
|
124
|
+
const msg = e.message ?? "Unexpected error";
|
|
125
|
+
setError(msg);
|
|
126
|
+
setMessages((prev) =>
|
|
127
|
+
prev.map((m) =>
|
|
128
|
+
m.status === "streaming" ? { ...m, text: msg, status: "error" } : m
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
clearInterval(flushIntervalRef.current);
|
|
134
|
+
setIsStreaming(false);
|
|
135
|
+
abortRef.current = null;
|
|
136
|
+
}
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
const abort = useCallback(() => {
|
|
140
|
+
abortRef.current?.abort();
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
return { stages, setStages, messages, setMessages, isStreaming, error, startStream, abort };
|
|
144
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { useStdout } from "ink";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns live terminal dimensions, updating on window resize.
|
|
6
|
+
* @returns {{ columns: number, rows: number }}
|
|
7
|
+
*/
|
|
8
|
+
export function useTerminalSize() {
|
|
9
|
+
const { stdout } = useStdout();
|
|
10
|
+
const [size, setSize] = useState({
|
|
11
|
+
columns: stdout?.columns ?? 80,
|
|
12
|
+
rows: stdout?.rows ?? 24,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!stdout) return;
|
|
17
|
+
const handler = () =>
|
|
18
|
+
setSize({ columns: stdout.columns ?? 80, rows: stdout.rows ?? 24 });
|
|
19
|
+
stdout.on("resize", handler);
|
|
20
|
+
return () => stdout.off("resize", handler);
|
|
21
|
+
}, [stdout]);
|
|
22
|
+
|
|
23
|
+
return size;
|
|
24
|
+
}
|
package/src/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { creditsCommand } from "./commands/credits.js";
|
|
|
13
13
|
import { dashboardCommand } from "./commands/dashboard.js";
|
|
14
14
|
|
|
15
15
|
const LOGO = `
|
|
16
|
-
\x1b[1m\x1b[36m PRDForge CLI\x1b[0m \x1b[2mv0.
|
|
16
|
+
\x1b[1m\x1b[36m PRDForge CLI\x1b[0m \x1b[2mv0.2.0\x1b[0m
|
|
17
17
|
\x1b[90mThe planning layer for AI-first development\x1b[0m
|
|
18
18
|
`;
|
|
19
19
|
|
|
@@ -22,14 +22,17 @@ const program = new Command();
|
|
|
22
22
|
program
|
|
23
23
|
.name("prdforge")
|
|
24
24
|
.description("PRDForge CLI — generate, manage, and export PRDs from your terminal or AI agents")
|
|
25
|
-
.version("0.
|
|
25
|
+
.version("0.2.0", "-v, --version")
|
|
26
26
|
.addHelpText("beforeAll", LOGO)
|
|
27
|
-
.
|
|
27
|
+
.option("--debug", "Enable verbose debug logging")
|
|
28
|
+
.hook("preAction", async (thisCommand) => {
|
|
28
29
|
// Load .env from cwd if present
|
|
29
30
|
try {
|
|
30
31
|
const { config: dotenv } = await import("dotenv");
|
|
31
32
|
dotenv();
|
|
32
33
|
} catch {}
|
|
34
|
+
// Activate debug mode
|
|
35
|
+
if (thisCommand.opts().debug) global.__prdforgeDebug = true;
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
program.addCommand(authCommand());
|
|
@@ -51,14 +54,16 @@ program.on("command:*", (cmds) => {
|
|
|
51
54
|
process.exit(1);
|
|
52
55
|
});
|
|
53
56
|
|
|
54
|
-
// When run with no arguments: open dashboard if authenticated, else show
|
|
57
|
+
// When run with no arguments: open dashboard if authenticated, else show welcome screen
|
|
55
58
|
if (process.argv.length === 2) {
|
|
56
59
|
const { getApiKey } = await import("./utils/config.js");
|
|
57
60
|
if (getApiKey()) {
|
|
58
61
|
const { dashboardCommand } = await import("./commands/dashboard.js");
|
|
59
62
|
await dashboardCommand().parseAsync([], { from: "user" });
|
|
60
63
|
} else {
|
|
61
|
-
|
|
64
|
+
const { printWelcome } = await import("./utils/logo.js");
|
|
65
|
+
printWelcome();
|
|
66
|
+
process.exit(0);
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
|