ralph-cli-sandboxed 0.2.6 → 0.2.7

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.
@@ -113,6 +113,7 @@ RUN ${gitCommands.join(' \\\n && ')}
113
113
  // Build asciinema installation section if enabled
114
114
  let asciinemaInstall = '';
115
115
  let asciinemaDir = '';
116
+ let streamScriptCopy = '';
116
117
  if (dockerConfig?.asciinema?.enabled) {
117
118
  const outputDir = dockerConfig.asciinema.outputDir || '.recordings';
118
119
  asciinemaInstall = `
@@ -123,6 +124,14 @@ RUN apt-get update && apt-get install -y asciinema && rm -rf /var/lib/apt/lists/
123
124
  # Create asciinema recordings directory
124
125
  RUN mkdir -p /workspace/${outputDir} && chown node:node /workspace/${outputDir}
125
126
  `;
127
+ // Add stream script if streamJson is enabled
128
+ if (dockerConfig.asciinema.streamJson?.enabled) {
129
+ streamScriptCopy = `
130
+ # Copy ralph stream wrapper script for clean JSON output
131
+ COPY ralph-stream.sh /usr/local/bin/ralph-stream.sh
132
+ RUN chmod +x /usr/local/bin/ralph-stream.sh
133
+ `;
134
+ }
126
135
  }
127
136
  return `# Ralph CLI Sandbox Environment
128
137
  # Based on Claude Code devcontainer
@@ -218,7 +227,7 @@ ENV EDITOR=nano
218
227
  # Add bash aliases and prompt (fallback if using bash)
219
228
  RUN echo 'alias ll="ls -la"' >> /etc/bash.bashrc && \\
220
229
  echo 'PS1="\\[\\033[43;30m\\][ralph]\\w\\[\\033[0m\\]\\$ "' >> /etc/bash.bashrc
221
- ${rootBuildCommands}${asciinemaInstall}
230
+ ${rootBuildCommands}${asciinemaInstall}${streamScriptCopy}
222
231
  # Switch to non-root user
223
232
  USER node
224
233
  ${gitConfigSection}${nodeBuildCommands}
@@ -364,11 +373,21 @@ function generateDockerCompose(imageName, dockerConfig) {
364
373
  }
365
374
  // Build command section if configured
366
375
  let commandSection = '';
376
+ let streamJsonNote = '';
367
377
  if (dockerConfig?.asciinema?.enabled && dockerConfig?.asciinema?.autoRecord) {
368
378
  // Wrap with asciinema recording
369
379
  const outputDir = dockerConfig.asciinema.outputDir || '.recordings';
370
380
  const innerCommand = dockerConfig.startCommand || 'zsh';
371
381
  commandSection = ` command: bash -c "mkdir -p /workspace/${outputDir} && asciinema rec -c '${innerCommand}' /workspace/${outputDir}/session-$$(date +%Y%m%d-%H%M%S).cast"\n`;
382
+ // Add note about stream-json if enabled
383
+ if (dockerConfig.asciinema.streamJson?.enabled) {
384
+ streamJsonNote = `
385
+ # Stream JSON mode enabled - use ralph-stream.sh for clean Claude output:
386
+ # ralph-stream.sh -p "your prompt here"
387
+ # This formats stream-json output for readable terminal display.
388
+ # Raw JSON is saved to ${outputDir}/session-*.jsonl for later analysis.
389
+ `;
390
+ }
372
391
  }
373
392
  else if (dockerConfig?.startCommand) {
374
393
  commandSection = ` command: ${dockerConfig.startCommand}\n`;
@@ -394,7 +413,7 @@ ${environmentSection} working_dir: /workspace
394
413
  tty: true
395
414
  cap_add:
396
415
  - NET_ADMIN # Required for firewall
397
- ${commandSection}
416
+ ${streamJsonNote}${commandSection}
398
417
  volumes:
399
418
  ${imageName}-history:
400
419
  `;
@@ -405,6 +424,82 @@ dist
405
424
  .git
406
425
  *.log
407
426
  `;
427
+ // Generate stream wrapper script for clean asciinema recordings
428
+ function generateStreamScript(outputDir, saveRawJson) {
429
+ const saveJsonSection = saveRawJson ? `
430
+ # Save raw JSON for later analysis
431
+ JSON_LOG="$OUTPUT_DIR/session-$TIMESTAMP.jsonl"
432
+ TEE_CMD="tee \\"$JSON_LOG\\""` : `
433
+ TEE_CMD="cat"`;
434
+ return `#!/bin/bash
435
+ # Ralph stream wrapper - formats Claude stream-json output for clean terminal display
436
+ # Generated by ralph-cli
437
+
438
+ set -e
439
+
440
+ OUTPUT_DIR="\${RALPH_RECORDING_DIR:-/workspace/${outputDir}}"
441
+ TIMESTAMP=$(date +%Y%m%d-%H%M%S)
442
+
443
+ # Ensure output directory exists
444
+ mkdir -p "$OUTPUT_DIR"
445
+ ${saveJsonSection}
446
+
447
+ # jq filter to extract and format content from stream-json
448
+ # Handles text, tool calls, tool results, file operations, and commands
449
+ JQ_FILTER='
450
+ if .type == "content_block_delta" then
451
+ (if .delta.type == "text_delta" then .delta.text // empty
452
+ elif .delta.text then .delta.text
453
+ else empty end)
454
+ elif .type == "content_block_start" then
455
+ (if .content_block.type == "tool_use" then "\\n── Tool: " + (.content_block.name // "unknown") + " ──\\n"
456
+ elif .content_block.type == "text" then .content_block.text // empty
457
+ else empty end)
458
+ elif .type == "tool_result" then
459
+ "\\n── Tool Result ──\\n" + ((.content // .output // "") | tostring) + "\\n"
460
+ elif .type == "assistant" then
461
+ ([.message.content[]? | select(.type == "text") | .text] | join(""))
462
+ elif .type == "message_start" then
463
+ "\\n"
464
+ elif .type == "message_delta" then
465
+ (if .delta.stop_reason then "\\n[" + .delta.stop_reason + "]\\n" else empty end)
466
+ elif .type == "file_edit" or .type == "file_write" then
467
+ "\\n── Writing: " + (.path // .file // "unknown") + " ──\\n"
468
+ elif .type == "file_read" then
469
+ "── Reading: " + (.path // .file // "unknown") + " ──\\n"
470
+ elif .type == "bash" or .type == "command" then
471
+ "\\n── Running: " + (.command // .content // "") + " ──\\n"
472
+ elif .type == "bash_output" or .type == "command_output" then
473
+ (.output // .content // "") + "\\n"
474
+ elif .type == "result" then
475
+ (if .result then "\\n── Result ──\\n" + (.result | tostring) + "\\n" else empty end)
476
+ elif .type == "error" then
477
+ "\\n[Error] " + (.error.message // (.error | tostring)) + "\\n"
478
+ elif .type == "system" then
479
+ (if .message then "[System] " + .message + "\\n" else empty end)
480
+ elif .text then
481
+ .text
482
+ elif (.content | type) == "string" then
483
+ .content
484
+ else
485
+ empty
486
+ end
487
+ '
488
+
489
+ # Pass all arguments to claude with stream-json output
490
+ # Filter JSON lines, optionally save raw JSON, and display formatted text
491
+ claude \\
492
+ --output-format stream-json \\
493
+ --verbose \\
494
+ --print \\
495
+ "\$@" 2>&1 \\
496
+ | grep --line-buffered '^{' \\
497
+ | eval $TEE_CMD \\
498
+ | jq --unbuffered -rj "$JQ_FILTER"
499
+
500
+ echo "" # Ensure final newline
501
+ `;
502
+ }
408
503
  // Generate .mcp.json content for Claude Code MCP servers
409
504
  function generateMcpJson(mcpServers) {
410
505
  return JSON.stringify({ mcpServers }, null, 2);
@@ -432,6 +527,12 @@ async function generateFiles(ralphDir, language, imageName, force = false, javaV
432
527
  { name: "docker-compose.yml", content: generateDockerCompose(imageName, dockerConfig) },
433
528
  { name: ".dockerignore", content: DOCKERIGNORE },
434
529
  ];
530
+ // Add stream script if streamJson is enabled
531
+ if (dockerConfig?.asciinema?.enabled && dockerConfig.asciinema.streamJson?.enabled) {
532
+ const outputDir = dockerConfig.asciinema.outputDir || '.recordings';
533
+ const saveRawJson = dockerConfig.asciinema.streamJson.saveRawJson !== false; // default true
534
+ files.push({ name: "ralph-stream.sh", content: generateStreamScript(outputDir, saveRawJson) });
535
+ }
435
536
  for (const file of files) {
436
537
  const filePath = join(dockerDir, file.name);
437
538
  if (existsSync(filePath) && !force) {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
2
2
  import { join, basename, dirname } from "path";
3
3
  import { fileURLToPath } from "url";
4
- import { getLanguages, generatePromptTemplate, DEFAULT_PRD, DEFAULT_PROGRESS, getCliProviders } from "../templates/prompts.js";
4
+ import { getLanguages, generatePromptTemplate, DEFAULT_PRD, DEFAULT_PROGRESS, getCliProviders, getSkillsForLanguage } from "../templates/prompts.js";
5
5
  import { promptSelectWithArrows, promptConfirm, promptInput, promptMultiSelectWithArrows } from "../utils/prompt.js";
6
6
  import { dockerInit } from "./docker.js";
7
7
  // Get package root directory (works for both dev and installed package)
@@ -39,6 +39,7 @@ export async function init(args) {
39
39
  let cliConfig;
40
40
  let selectedKey;
41
41
  let selectedTechnologies = [];
42
+ let selectedSkills = [];
42
43
  let checkCommand;
43
44
  let testCommand;
44
45
  if (useDefaults) {
@@ -115,6 +116,29 @@ export async function init(args) {
115
116
  console.log("\nNo technologies selected.");
116
117
  }
117
118
  }
119
+ // Step 4: Select skills if available for this language
120
+ const availableSkills = getSkillsForLanguage(selectedKey);
121
+ if (availableSkills.length > 0) {
122
+ const skillOptions = availableSkills.map(s => `${s.name} - ${s.description}`);
123
+ const selectedSkillNames = await promptMultiSelectWithArrows("Select AI coding rules/skills to enable (optional):", skillOptions);
124
+ // Convert selected display names to SkillConfig objects
125
+ selectedSkills = selectedSkillNames.map(sel => {
126
+ const idx = skillOptions.indexOf(sel);
127
+ const skill = availableSkills[idx];
128
+ return {
129
+ name: skill.name,
130
+ description: skill.description,
131
+ instructions: skill.instructions,
132
+ userInvocable: skill.userInvocable,
133
+ };
134
+ });
135
+ if (selectedSkills.length > 0) {
136
+ console.log(`\nSelected skills: ${selectedSkills.map(s => s.name).join(", ")}`);
137
+ }
138
+ else {
139
+ console.log("\nNo skills selected.");
140
+ }
141
+ }
118
142
  // Allow custom commands for "none" language
119
143
  checkCommand = config.checkCommand;
120
144
  testCommand = config.testCommand;
@@ -164,6 +188,10 @@ export async function init(args) {
164
188
  enabled: false,
165
189
  autoRecord: false,
166
190
  outputDir: ".recordings",
191
+ streamJson: {
192
+ enabled: false,
193
+ saveRawJson: true,
194
+ },
167
195
  },
168
196
  firewall: {
169
197
  allowedDomains: [],
@@ -172,7 +200,7 @@ export async function init(args) {
172
200
  // Claude-specific configuration (MCP servers and skills)
173
201
  claude: {
174
202
  mcpServers: {},
175
- skills: [],
203
+ skills: selectedSkills,
176
204
  },
177
205
  };
178
206
  const configPath = join(ralphDir, CONFIG_FILE);
@@ -1,6 +1,139 @@
1
1
  import { spawn } from "child_process";
2
+ import { existsSync, appendFileSync, mkdirSync } from "fs";
3
+ import { join } from "path";
2
4
  import { checkFilesExist, loadConfig, loadPrompt, getPaths, getCliConfig, requireContainer } from "../utils/config.js";
3
5
  import { resolvePromptVariables } from "../templates/prompts.js";
6
+ /**
7
+ * Parses a stream-json line and extracts displayable text.
8
+ * Formats output similar to Claude Code's normal terminal display.
9
+ */
10
+ function parseStreamJsonLine(line, debug = false) {
11
+ try {
12
+ const json = JSON.parse(line);
13
+ if (debug && json.type) {
14
+ process.stderr.write(`[stream-json] type: ${json.type}\n`);
15
+ }
16
+ // Handle Claude Code CLI stream-json events
17
+ const type = json.type;
18
+ switch (type) {
19
+ // === Text Content ===
20
+ case "content_block_delta":
21
+ // Incremental text updates - the main streaming content
22
+ if (json.delta?.type === "text_delta") {
23
+ return json.delta.text || "";
24
+ }
25
+ // Tool input being streamed
26
+ if (json.delta?.type === "input_json_delta") {
27
+ return ""; // Don't show partial JSON, wait for complete tool call
28
+ }
29
+ return json.delta?.text || "";
30
+ case "text":
31
+ return json.text || "";
32
+ // === Tool Use ===
33
+ case "content_block_start":
34
+ if (json.content_block?.type === "tool_use") {
35
+ const toolName = json.content_block?.name || "unknown";
36
+ return `\n── Tool: ${toolName} ──\n`;
37
+ }
38
+ if (json.content_block?.type === "text") {
39
+ return json.content_block?.text || "";
40
+ }
41
+ return "";
42
+ case "content_block_stop":
43
+ // End of a content block - add newline after tool use
44
+ return "";
45
+ // === Tool Results ===
46
+ case "tool_result":
47
+ const toolOutput = json.content || json.output || "";
48
+ const truncated = typeof toolOutput === "string" && toolOutput.length > 500
49
+ ? toolOutput.substring(0, 500) + "... (truncated)"
50
+ : toolOutput;
51
+ return `\n── Tool Result ──\n${typeof truncated === "string" ? truncated : JSON.stringify(truncated, null, 2)}\n`;
52
+ // === Assistant Messages ===
53
+ case "assistant":
54
+ const contents = json.message?.content || json.content || [];
55
+ let output = "";
56
+ for (const block of contents) {
57
+ if (block.type === "text") {
58
+ output += block.text || "";
59
+ }
60
+ else if (block.type === "tool_use") {
61
+ output += `\n── Tool: ${block.name} ──\n`;
62
+ if (block.input) {
63
+ output += JSON.stringify(block.input, null, 2) + "\n";
64
+ }
65
+ }
66
+ }
67
+ return output;
68
+ case "message_start":
69
+ // Beginning of a new message
70
+ return "\n";
71
+ case "message_delta":
72
+ // Message completion info (stop_reason, usage)
73
+ if (json.delta?.stop_reason) {
74
+ return `\n[${json.delta.stop_reason}]\n`;
75
+ }
76
+ return "";
77
+ case "message_stop":
78
+ return "\n";
79
+ // === System/User Events ===
80
+ case "system":
81
+ if (json.message) {
82
+ return `[System] ${json.message}\n`;
83
+ }
84
+ return "";
85
+ case "user":
86
+ // User message echo - usually not needed to display
87
+ return "";
88
+ // === Results and Errors ===
89
+ case "result":
90
+ if (json.result !== undefined) {
91
+ return `\n── Result ──\n${JSON.stringify(json.result, null, 2)}\n`;
92
+ }
93
+ return "";
94
+ case "error":
95
+ const errMsg = json.error?.message || JSON.stringify(json.error);
96
+ return `\n[Error] ${errMsg}\n`;
97
+ // === File Operations (Claude Code specific) ===
98
+ case "file_edit":
99
+ case "file_write":
100
+ const filePath = json.path || json.file || "unknown";
101
+ return `\n── Writing: ${filePath} ──\n`;
102
+ case "file_read":
103
+ const readPath = json.path || json.file || "unknown";
104
+ return `── Reading: ${readPath} ──\n`;
105
+ case "bash":
106
+ case "command":
107
+ const cmd = json.command || json.content || "";
108
+ return `\n── Running: ${cmd} ──\n`;
109
+ case "bash_output":
110
+ case "command_output":
111
+ const cmdOutput = json.output || json.content || "";
112
+ return cmdOutput + "\n";
113
+ default:
114
+ // Fallback: check for common text fields
115
+ if (json.text)
116
+ return json.text;
117
+ if (json.content && typeof json.content === "string")
118
+ return json.content;
119
+ if (json.message && typeof json.message === "string")
120
+ return json.message;
121
+ if (json.output && typeof json.output === "string")
122
+ return json.output;
123
+ if (debug) {
124
+ process.stderr.write(`[stream-json] unhandled type: ${type}, keys: ${Object.keys(json).join(", ")}\n`);
125
+ }
126
+ return "";
127
+ }
128
+ }
129
+ catch (e) {
130
+ // Not valid JSON
131
+ if (debug) {
132
+ process.stderr.write(`[stream-json] parse error: ${e}\n`);
133
+ }
134
+ return "";
135
+ }
136
+ }
4
137
  export async function once(args) {
5
138
  // Parse flags
6
139
  let debug = false;
@@ -32,7 +165,19 @@ export async function once(args) {
32
165
  });
33
166
  const paths = getPaths();
34
167
  const cliConfig = getCliConfig(config);
35
- console.log("Starting single ralph iteration...\n");
168
+ // Check if stream-json output is enabled
169
+ const streamJsonConfig = config.docker?.asciinema?.streamJson;
170
+ const streamJsonEnabled = streamJsonConfig?.enabled ?? false;
171
+ const saveRawJson = streamJsonConfig?.saveRawJson !== false; // default true
172
+ const outputDir = config.docker?.asciinema?.outputDir || ".recordings";
173
+ console.log("Starting single ralph iteration...");
174
+ if (streamJsonEnabled) {
175
+ console.log("Stream JSON output enabled - displaying formatted Claude output");
176
+ if (saveRawJson) {
177
+ console.log(`Raw JSON logs will be saved to: ${outputDir}/`);
178
+ }
179
+ }
180
+ console.log();
36
181
  // Build CLI arguments: config args + yolo args + model args + prompt args
37
182
  // Use yoloArgs from config if available, otherwise default to Claude's --dangerously-skip-permissions
38
183
  const yoloArgs = cliConfig.yoloArgs ?? ["--dangerously-skip-permissions"];
@@ -42,6 +187,20 @@ export async function once(args) {
42
187
  ...(cliConfig.args ?? []),
43
188
  ...yoloArgs,
44
189
  ];
190
+ // Add stream-json output format if enabled
191
+ let jsonLogPath;
192
+ if (streamJsonEnabled) {
193
+ cliArgs.push("--output-format", "stream-json", "--verbose", "--print");
194
+ // Setup JSON log file if saving raw JSON
195
+ if (saveRawJson) {
196
+ const fullOutputDir = join(process.cwd(), outputDir);
197
+ if (!existsSync(fullOutputDir)) {
198
+ mkdirSync(fullOutputDir, { recursive: true });
199
+ }
200
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
201
+ jsonLogPath = join(fullOutputDir, `ralph-once-${timestamp}.jsonl`);
202
+ }
203
+ }
45
204
  // Add model args if model is specified
46
205
  if (model && cliConfig.modelArgs) {
47
206
  cliArgs.push(...cliConfig.modelArgs, model);
@@ -49,19 +208,98 @@ export async function once(args) {
49
208
  cliArgs.push(...promptArgs, promptValue);
50
209
  if (debug) {
51
210
  console.log(`[debug] ${cliConfig.command} ${cliArgs.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}\n`);
211
+ if (jsonLogPath) {
212
+ console.log(`[debug] Saving raw JSON to: ${jsonLogPath}\n`);
213
+ }
52
214
  }
53
215
  return new Promise((resolve, reject) => {
54
- const proc = spawn(cliConfig.command, cliArgs, {
55
- stdio: "inherit",
56
- });
57
- proc.on("close", (code) => {
58
- if (code !== 0) {
59
- console.error(`\n${cliConfig.command} exited with code ${code}`);
60
- }
61
- resolve();
62
- });
63
- proc.on("error", (err) => {
64
- reject(new Error(`Failed to start ${cliConfig.command}: ${err.message}`));
65
- });
216
+ if (streamJsonEnabled) {
217
+ // Stream JSON mode: capture stdout, parse JSON, display clean text
218
+ let lineBuffer = "";
219
+ const proc = spawn(cliConfig.command, cliArgs, {
220
+ stdio: ["inherit", "pipe", "inherit"],
221
+ });
222
+ proc.stdout.on("data", (data) => {
223
+ const chunk = data.toString();
224
+ lineBuffer += chunk;
225
+ const lines = lineBuffer.split("\n");
226
+ // Keep the last incomplete line in the buffer
227
+ lineBuffer = lines.pop() || "";
228
+ for (const line of lines) {
229
+ const trimmedLine = line.trim();
230
+ if (!trimmedLine)
231
+ continue;
232
+ // Check if this is a JSON line
233
+ if (trimmedLine.startsWith("{")) {
234
+ // Save raw JSON if enabled
235
+ if (jsonLogPath) {
236
+ try {
237
+ appendFileSync(jsonLogPath, trimmedLine + "\n");
238
+ }
239
+ catch {
240
+ // Ignore write errors
241
+ }
242
+ }
243
+ // Parse and display clean text
244
+ const text = parseStreamJsonLine(trimmedLine, debug);
245
+ if (text) {
246
+ process.stdout.write(text);
247
+ }
248
+ }
249
+ else {
250
+ // Non-JSON line - display as-is (might be status messages, errors, etc.)
251
+ process.stdout.write(trimmedLine + "\n");
252
+ }
253
+ }
254
+ });
255
+ proc.on("close", (code) => {
256
+ // Process any remaining buffered content
257
+ if (lineBuffer.trim()) {
258
+ const trimmedLine = lineBuffer.trim();
259
+ if (trimmedLine.startsWith("{")) {
260
+ if (jsonLogPath) {
261
+ try {
262
+ appendFileSync(jsonLogPath, trimmedLine + "\n");
263
+ }
264
+ catch {
265
+ // Ignore write errors
266
+ }
267
+ }
268
+ const text = parseStreamJsonLine(trimmedLine, debug);
269
+ if (text) {
270
+ process.stdout.write(text);
271
+ }
272
+ }
273
+ else {
274
+ // Non-JSON remaining content
275
+ process.stdout.write(trimmedLine + "\n");
276
+ }
277
+ }
278
+ // Ensure final newline
279
+ process.stdout.write("\n");
280
+ if (code !== 0) {
281
+ console.error(`\n${cliConfig.command} exited with code ${code}`);
282
+ }
283
+ resolve();
284
+ });
285
+ proc.on("error", (err) => {
286
+ reject(new Error(`Failed to start ${cliConfig.command}: ${err.message}`));
287
+ });
288
+ }
289
+ else {
290
+ // Standard mode: pass through all I/O
291
+ const proc = spawn(cliConfig.command, cliArgs, {
292
+ stdio: "inherit",
293
+ });
294
+ proc.on("close", (code) => {
295
+ if (code !== 0) {
296
+ console.error(`\n${cliConfig.command} exited with code ${code}`);
297
+ }
298
+ resolve();
299
+ });
300
+ proc.on("error", (err) => {
301
+ reject(new Error(`Failed to start ${cliConfig.command}: ${err.message}`));
302
+ });
303
+ }
66
304
  });
67
305
  }
@@ -1,9 +1,140 @@
1
1
  import { spawn } from "child_process";
2
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
2
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, appendFileSync, mkdirSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { checkFilesExist, loadConfig, loadPrompt, getPaths, getCliConfig, requireContainer } from "../utils/config.js";
5
5
  import { resolvePromptVariables } from "../templates/prompts.js";
6
6
  import { validatePrd, smartMerge, readPrdFile, writePrd, expandPrdFileReferences } from "../utils/prd-validator.js";
7
+ /**
8
+ * Parses a stream-json line and extracts displayable text.
9
+ * Formats output similar to Claude Code's normal terminal display.
10
+ */
11
+ function parseStreamJsonLine(line, debug = false) {
12
+ try {
13
+ const json = JSON.parse(line);
14
+ if (debug && json.type) {
15
+ process.stderr.write(`[stream-json] type: ${json.type}\n`);
16
+ }
17
+ // Handle Claude Code CLI stream-json events
18
+ const type = json.type;
19
+ switch (type) {
20
+ // === Text Content ===
21
+ case "content_block_delta":
22
+ // Incremental text updates - the main streaming content
23
+ if (json.delta?.type === "text_delta") {
24
+ return json.delta.text || "";
25
+ }
26
+ // Tool input being streamed
27
+ if (json.delta?.type === "input_json_delta") {
28
+ return ""; // Don't show partial JSON, wait for complete tool call
29
+ }
30
+ return json.delta?.text || "";
31
+ case "text":
32
+ return json.text || "";
33
+ // === Tool Use ===
34
+ case "content_block_start":
35
+ if (json.content_block?.type === "tool_use") {
36
+ const toolName = json.content_block?.name || "unknown";
37
+ return `\n── Tool: ${toolName} ──\n`;
38
+ }
39
+ if (json.content_block?.type === "text") {
40
+ return json.content_block?.text || "";
41
+ }
42
+ return "";
43
+ case "content_block_stop":
44
+ // End of a content block - add newline after tool use
45
+ return "";
46
+ // === Tool Results ===
47
+ case "tool_result":
48
+ const toolOutput = json.content || json.output || "";
49
+ const truncated = typeof toolOutput === "string" && toolOutput.length > 500
50
+ ? toolOutput.substring(0, 500) + "... (truncated)"
51
+ : toolOutput;
52
+ return `\n── Tool Result ──\n${typeof truncated === "string" ? truncated : JSON.stringify(truncated, null, 2)}\n`;
53
+ // === Assistant Messages ===
54
+ case "assistant":
55
+ const contents = json.message?.content || json.content || [];
56
+ let output = "";
57
+ for (const block of contents) {
58
+ if (block.type === "text") {
59
+ output += block.text || "";
60
+ }
61
+ else if (block.type === "tool_use") {
62
+ output += `\n── Tool: ${block.name} ──\n`;
63
+ if (block.input) {
64
+ output += JSON.stringify(block.input, null, 2) + "\n";
65
+ }
66
+ }
67
+ }
68
+ return output;
69
+ case "message_start":
70
+ // Beginning of a new message
71
+ return "\n";
72
+ case "message_delta":
73
+ // Message completion info (stop_reason, usage)
74
+ if (json.delta?.stop_reason) {
75
+ return `\n[${json.delta.stop_reason}]\n`;
76
+ }
77
+ return "";
78
+ case "message_stop":
79
+ return "\n";
80
+ // === System/User Events ===
81
+ case "system":
82
+ if (json.message) {
83
+ return `[System] ${json.message}\n`;
84
+ }
85
+ return "";
86
+ case "user":
87
+ // User message echo - usually not needed to display
88
+ return "";
89
+ // === Results and Errors ===
90
+ case "result":
91
+ if (json.result !== undefined) {
92
+ return `\n── Result ──\n${JSON.stringify(json.result, null, 2)}\n`;
93
+ }
94
+ return "";
95
+ case "error":
96
+ const errMsg = json.error?.message || JSON.stringify(json.error);
97
+ return `\n[Error] ${errMsg}\n`;
98
+ // === File Operations (Claude Code specific) ===
99
+ case "file_edit":
100
+ case "file_write":
101
+ const filePath = json.path || json.file || "unknown";
102
+ return `\n── Writing: ${filePath} ──\n`;
103
+ case "file_read":
104
+ const readPath = json.path || json.file || "unknown";
105
+ return `── Reading: ${readPath} ──\n`;
106
+ case "bash":
107
+ case "command":
108
+ const cmd = json.command || json.content || "";
109
+ return `\n── Running: ${cmd} ──\n`;
110
+ case "bash_output":
111
+ case "command_output":
112
+ const cmdOutput = json.output || json.content || "";
113
+ return cmdOutput + "\n";
114
+ default:
115
+ // Fallback: check for common text fields
116
+ if (json.text)
117
+ return json.text;
118
+ if (json.content && typeof json.content === "string")
119
+ return json.content;
120
+ if (json.message && typeof json.message === "string")
121
+ return json.message;
122
+ if (json.output && typeof json.output === "string")
123
+ return json.output;
124
+ if (debug) {
125
+ process.stderr.write(`[stream-json] unhandled type: ${type}, keys: ${Object.keys(json).join(", ")}\n`);
126
+ }
127
+ return "";
128
+ }
129
+ }
130
+ catch (e) {
131
+ // Not valid JSON
132
+ if (debug) {
133
+ process.stderr.write(`[stream-json] parse error: ${e}\n`);
134
+ }
135
+ return "";
136
+ }
137
+ }
7
138
  const CATEGORIES = ["ui", "feature", "bugfix", "setup", "development", "testing", "docs"];
8
139
  /**
9
140
  * Creates a filtered PRD file containing only incomplete items (passes: false).
@@ -71,9 +202,11 @@ function syncPassesFromTasks(tasksPath, prdPath) {
71
202
  return 0;
72
203
  }
73
204
  }
74
- async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig, debug, model) {
205
+ async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig, debug, model, streamJson) {
75
206
  return new Promise((resolve, reject) => {
76
207
  let output = "";
208
+ let jsonLogPath;
209
+ let lineBuffer = ""; // Buffer for incomplete JSON lines
77
210
  // Build CLI arguments: config args + yolo args + model args + prompt args
78
211
  const cliArgs = [
79
212
  ...(cliConfig.args ?? []),
@@ -84,6 +217,19 @@ async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig
84
217
  const yoloArgs = cliConfig.yoloArgs ?? ["--dangerously-skip-permissions"];
85
218
  cliArgs.push(...yoloArgs);
86
219
  }
220
+ // Add stream-json output format if enabled
221
+ if (streamJson?.enabled) {
222
+ cliArgs.push("--output-format", "stream-json", "--verbose", "--print");
223
+ // Setup JSON log file if saving raw JSON
224
+ if (streamJson.saveRawJson) {
225
+ const outputDir = join(process.cwd(), streamJson.outputDir);
226
+ if (!existsSync(outputDir)) {
227
+ mkdirSync(outputDir, { recursive: true });
228
+ }
229
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
230
+ jsonLogPath = join(outputDir, `ralph-run-${timestamp}.jsonl`);
231
+ }
232
+ }
87
233
  // Add model args if model is specified
88
234
  if (model && cliConfig.modelArgs) {
89
235
  cliArgs.push(...cliConfig.modelArgs, model);
@@ -95,16 +241,85 @@ async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig
95
241
  cliArgs.push(...promptArgs, promptValue);
96
242
  if (debug) {
97
243
  console.log(`[debug] ${cliConfig.command} ${cliArgs.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}\n`);
244
+ if (jsonLogPath) {
245
+ console.log(`[debug] Saving raw JSON to: ${jsonLogPath}\n`);
246
+ }
98
247
  }
99
248
  const proc = spawn(cliConfig.command, cliArgs, {
100
249
  stdio: ["inherit", "pipe", "inherit"],
101
250
  });
102
251
  proc.stdout.on("data", (data) => {
103
252
  const chunk = data.toString();
104
- output += chunk;
105
- process.stdout.write(chunk);
253
+ if (streamJson?.enabled) {
254
+ // Process stream-json output: parse JSON and display clean text
255
+ lineBuffer += chunk;
256
+ const lines = lineBuffer.split("\n");
257
+ // Keep the last incomplete line in the buffer
258
+ lineBuffer = lines.pop() || "";
259
+ for (const line of lines) {
260
+ const trimmedLine = line.trim();
261
+ if (!trimmedLine)
262
+ continue;
263
+ // Check if this is a JSON line
264
+ if (trimmedLine.startsWith("{")) {
265
+ // Save raw JSON if enabled
266
+ if (jsonLogPath) {
267
+ try {
268
+ appendFileSync(jsonLogPath, trimmedLine + "\n");
269
+ }
270
+ catch {
271
+ // Ignore write errors
272
+ }
273
+ }
274
+ // Parse and display clean text
275
+ const text = parseStreamJsonLine(trimmedLine, debug);
276
+ if (text) {
277
+ process.stdout.write(text);
278
+ output += text; // Accumulate parsed text for completion detection
279
+ }
280
+ }
281
+ else {
282
+ // Non-JSON line - display as-is (might be status messages, errors, etc.)
283
+ process.stdout.write(trimmedLine + "\n");
284
+ output += trimmedLine + "\n";
285
+ }
286
+ }
287
+ }
288
+ else {
289
+ // Standard output: pass through as-is
290
+ output += chunk;
291
+ process.stdout.write(chunk);
292
+ }
106
293
  });
107
294
  proc.on("close", (code) => {
295
+ // Process any remaining buffered content
296
+ if (streamJson?.enabled && lineBuffer.trim()) {
297
+ const trimmedLine = lineBuffer.trim();
298
+ if (trimmedLine.startsWith("{")) {
299
+ if (jsonLogPath) {
300
+ try {
301
+ appendFileSync(jsonLogPath, trimmedLine + "\n");
302
+ }
303
+ catch {
304
+ // Ignore write errors
305
+ }
306
+ }
307
+ const text = parseStreamJsonLine(trimmedLine, debug);
308
+ if (text) {
309
+ process.stdout.write(text);
310
+ output += text;
311
+ }
312
+ }
313
+ else {
314
+ // Non-JSON remaining content
315
+ process.stdout.write(trimmedLine + "\n");
316
+ output += trimmedLine + "\n";
317
+ }
318
+ }
319
+ // Ensure final newline for clean output
320
+ if (streamJson?.enabled) {
321
+ process.stdout.write("\n");
322
+ }
108
323
  resolve({ exitCode: code ?? 0, output });
109
324
  });
110
325
  proc.on("error", (err) => {
@@ -265,6 +480,13 @@ export async function run(args) {
265
480
  });
266
481
  const paths = getPaths();
267
482
  const cliConfig = getCliConfig(config);
483
+ // Check if stream-json output is enabled
484
+ const streamJsonConfig = config.docker?.asciinema?.streamJson;
485
+ const streamJson = streamJsonConfig?.enabled ? {
486
+ enabled: true,
487
+ saveRawJson: streamJsonConfig.saveRawJson !== false, // default true
488
+ outputDir: config.docker?.asciinema?.outputDir || ".recordings",
489
+ } : undefined;
268
490
  // Progress tracking: stop only if no tasks complete after N iterations
269
491
  const MAX_ITERATIONS_WITHOUT_PROGRESS = 3;
270
492
  // Get requested iteration count (may be adjusted dynamically)
@@ -285,6 +507,12 @@ export async function run(args) {
285
507
  if (category) {
286
508
  console.log(`Filtering PRD items by category: ${category}`);
287
509
  }
510
+ if (streamJson?.enabled) {
511
+ console.log("Stream JSON output enabled - displaying formatted Claude output");
512
+ if (streamJson.saveRawJson) {
513
+ console.log(`Raw JSON logs will be saved to: ${streamJson.outputDir}/`);
514
+ }
515
+ }
288
516
  console.log();
289
517
  // Track temp file for cleanup
290
518
  let filteredPrdPath = null;
@@ -381,7 +609,7 @@ export async function run(args) {
381
609
  break;
382
610
  }
383
611
  }
384
- const { exitCode, output } = await runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig, debug, model);
612
+ const { exitCode, output } = await runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig, debug, model, streamJson);
385
613
  // Sync any completed items from prd-tasks.json back to prd.json
386
614
  // This catches cases where the LLM updated prd-tasks.json instead of prd.json
387
615
  syncPassesFromTasks(filteredPrdPath, paths.prd);
@@ -0,0 +1,12 @@
1
+ {
2
+ "skills": {
3
+ "swift": [
4
+ {
5
+ "name": "swift-main-naming",
6
+ "description": "Prevents naming files main.swift when using @main attribute",
7
+ "instructions": "IMPORTANT: In Swift, files containing the @main attribute MUST NOT be named main.swift.\n\nWhen the @main attribute is used (e.g., @main struct App), Swift automatically generates an entry point. If the file is also named main.swift, Swift treats it as having a manual entry point, causing a conflict.\n\nRULES:\n- Never name a file main.swift if it contains @main attribute\n- Use descriptive names like App.swift, MyApp.swift, or the actual type name\n- If you encounter a main.swift with @main, rename it to match the type (e.g., struct MyApp -> MyApp.swift)\n\nEXAMPLES:\n\nBAD:\n```\n// main.swift\n@main\nstruct App {\n static func main() { ... }\n}\n```\n\nGOOD:\n```\n// App.swift\n@main\nstruct App {\n static func main() { ... }\n}\n```",
8
+ "userInvocable": false
9
+ }
10
+ ]
11
+ }
12
+ }
@@ -48,9 +48,20 @@ export interface CliProviderConfig {
48
48
  interface CliProvidersJson {
49
49
  providers: Record<string, CliProviderConfig>;
50
50
  }
51
+ export interface SkillDefinition {
52
+ name: string;
53
+ description: string;
54
+ instructions: string;
55
+ userInvocable?: boolean;
56
+ }
57
+ interface SkillsJson {
58
+ skills: Record<string, SkillDefinition[]>;
59
+ }
51
60
  export declare function getLanguagesJson(): LanguagesJson;
52
61
  export declare function getCliProvidersJson(): CliProvidersJson;
53
62
  export declare function getCliProviders(): Record<string, CliProviderConfig>;
63
+ export declare function getSkillsJson(): SkillsJson;
64
+ export declare function getSkillsForLanguage(language: string): SkillDefinition[];
54
65
  export declare function getLanguages(): Record<string, LanguageConfig>;
55
66
  export declare const LANGUAGES: Record<string, LanguageConfig>;
56
67
  export declare function generatePromptTemplate(): string;
@@ -17,6 +17,12 @@ function loadCliProvidersConfig() {
17
17
  const content = readFileSync(configPath, "utf-8");
18
18
  return JSON.parse(content);
19
19
  }
20
+ // Load skills from JSON config file
21
+ function loadSkillsConfig() {
22
+ const configPath = join(__dirname, "..", "config", "skills.json");
23
+ const content = readFileSync(configPath, "utf-8");
24
+ return JSON.parse(content);
25
+ }
20
26
  // Convert JSON config to the legacy format for compatibility
21
27
  function convertToLanguageConfig(config) {
22
28
  return {
@@ -31,6 +37,7 @@ function convertToLanguageConfig(config) {
31
37
  let _languagesCache = null;
32
38
  let _languagesJsonCache = null;
33
39
  let _cliProvidersCache = null;
40
+ let _skillsCache = null;
34
41
  export function getLanguagesJson() {
35
42
  if (!_languagesJsonCache) {
36
43
  _languagesJsonCache = loadLanguagesConfig();
@@ -46,6 +53,16 @@ export function getCliProvidersJson() {
46
53
  export function getCliProviders() {
47
54
  return getCliProvidersJson().providers;
48
55
  }
56
+ export function getSkillsJson() {
57
+ if (!_skillsCache) {
58
+ _skillsCache = loadSkillsConfig();
59
+ }
60
+ return _skillsCache;
61
+ }
62
+ export function getSkillsForLanguage(language) {
63
+ const skills = getSkillsJson().skills;
64
+ return skills[language] || [];
65
+ }
49
66
  export function getLanguages() {
50
67
  if (!_languagesCache) {
51
68
  const json = getLanguagesJson();
@@ -16,10 +16,15 @@ export interface SkillConfig {
16
16
  instructions: string;
17
17
  userInvocable?: boolean;
18
18
  }
19
+ export interface StreamJsonConfig {
20
+ enabled: boolean;
21
+ saveRawJson?: boolean;
22
+ }
19
23
  export interface AsciinemaConfig {
20
24
  enabled: boolean;
21
25
  autoRecord?: boolean;
22
26
  outputDir?: string;
27
+ streamJson?: StreamJsonConfig;
23
28
  }
24
29
  export interface RalphConfig {
25
30
  language: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-cli-sandboxed",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "AI-driven development automation CLI for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {