ralph-cli-sandboxed 0.4.1 → 0.4.2

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.
Files changed (69) hide show
  1. package/README.md +30 -0
  2. package/dist/commands/action.js +9 -9
  3. package/dist/commands/chat.js +13 -12
  4. package/dist/commands/config.js +2 -1
  5. package/dist/commands/daemon.js +4 -3
  6. package/dist/commands/docker.js +102 -66
  7. package/dist/commands/fix-config.js +2 -1
  8. package/dist/commands/fix-prd.js +2 -2
  9. package/dist/commands/init.js +78 -17
  10. package/dist/commands/listen.js +3 -1
  11. package/dist/commands/notify.js +1 -1
  12. package/dist/commands/once.js +17 -9
  13. package/dist/commands/prd.js +4 -1
  14. package/dist/commands/run.js +40 -25
  15. package/dist/commands/slack.js +2 -2
  16. package/dist/config/responder-presets.json +69 -0
  17. package/dist/index.js +1 -1
  18. package/dist/providers/discord.d.ts +28 -0
  19. package/dist/providers/discord.js +227 -14
  20. package/dist/providers/slack.d.ts +41 -1
  21. package/dist/providers/slack.js +389 -8
  22. package/dist/providers/telegram.d.ts +30 -0
  23. package/dist/providers/telegram.js +185 -5
  24. package/dist/responders/claude-code-responder.d.ts +48 -0
  25. package/dist/responders/claude-code-responder.js +203 -0
  26. package/dist/responders/cli-responder.d.ts +62 -0
  27. package/dist/responders/cli-responder.js +298 -0
  28. package/dist/responders/llm-responder.d.ts +135 -0
  29. package/dist/responders/llm-responder.js +582 -0
  30. package/dist/templates/macos-scripts.js +2 -4
  31. package/dist/templates/prompts.js +4 -2
  32. package/dist/tui/ConfigEditor.js +19 -5
  33. package/dist/tui/components/ArrayEditor.js +1 -1
  34. package/dist/tui/components/EditorPanel.js +10 -6
  35. package/dist/tui/components/HelpPanel.d.ts +1 -1
  36. package/dist/tui/components/HelpPanel.js +1 -1
  37. package/dist/tui/components/JsonSnippetEditor.js +8 -5
  38. package/dist/tui/components/KeyValueEditor.js +54 -9
  39. package/dist/tui/components/LLMProvidersEditor.d.ts +22 -0
  40. package/dist/tui/components/LLMProvidersEditor.js +357 -0
  41. package/dist/tui/components/ObjectEditor.js +1 -1
  42. package/dist/tui/components/Preview.js +1 -1
  43. package/dist/tui/components/RespondersEditor.d.ts +22 -0
  44. package/dist/tui/components/RespondersEditor.js +437 -0
  45. package/dist/tui/components/SectionNav.js +27 -3
  46. package/dist/utils/chat-client.d.ts +4 -0
  47. package/dist/utils/chat-client.js +12 -5
  48. package/dist/utils/config.d.ts +84 -0
  49. package/dist/utils/config.js +78 -1
  50. package/dist/utils/daemon-client.d.ts +21 -0
  51. package/dist/utils/daemon-client.js +28 -1
  52. package/dist/utils/llm-client.d.ts +82 -0
  53. package/dist/utils/llm-client.js +185 -0
  54. package/dist/utils/message-queue.js +6 -6
  55. package/dist/utils/notification.d.ts +6 -1
  56. package/dist/utils/notification.js +103 -2
  57. package/dist/utils/prd-validator.js +60 -19
  58. package/dist/utils/prompt.js +22 -12
  59. package/dist/utils/responder-logger.d.ts +47 -0
  60. package/dist/utils/responder-logger.js +129 -0
  61. package/dist/utils/responder-presets.d.ts +92 -0
  62. package/dist/utils/responder-presets.js +156 -0
  63. package/dist/utils/responder.d.ts +88 -0
  64. package/dist/utils/responder.js +207 -0
  65. package/dist/utils/stream-json.js +6 -6
  66. package/docs/CHAT-RESPONDERS.md +785 -0
  67. package/docs/DEVELOPMENT.md +25 -0
  68. package/docs/chat-architecture.md +251 -0
  69. package/package.json +11 -1
package/README.md CHANGED
@@ -505,6 +505,36 @@ Features:
505
505
 
506
506
  See [docs/DOCKER.md](docs/DOCKER.md) for detailed Docker configuration, customization, and troubleshooting.
507
507
 
508
+ ## Chat Integration
509
+
510
+ Ralph can be controlled via chat platforms (Slack, Telegram, Discord) and includes intelligent chat responders powered by LLMs.
511
+
512
+ ### Chat Commands
513
+
514
+ Control Ralph remotely via chat:
515
+ - `/ralph run` - Start ralph automation
516
+ - `/ralph status` - Check PRD status
517
+ - `/ralph stop` - Stop running automation
518
+
519
+ ### Chat Responders
520
+
521
+ Responders handle messages and can answer questions about your codebase:
522
+
523
+ | Trigger | Type | Description |
524
+ |---------|------|-------------|
525
+ | `@qa` | LLM | Answer questions about the codebase |
526
+ | `@review` | LLM | Review code changes (supports `@review diff`, `@review last`) |
527
+ | `@code` | Claude Code | Make file modifications |
528
+ | `!lint` | CLI | Run custom commands |
529
+
530
+ **Features:**
531
+ - **Automatic file detection**: Mention file paths (e.g., `src/config.ts:42`) and they're automatically included in context
532
+ - **Git diff keywords**: Use `diff`, `staged`, `last`, `HEAD~N` to include git changes
533
+ - **Multi-turn conversations**: Continue discussions in Slack/Discord threads
534
+ - **Auto-notifications**: Results from `ralph run` are automatically sent to connected chat
535
+
536
+ See [docs/CHAT-CLIENTS.md](docs/CHAT-CLIENTS.md) for chat platform setup and [docs/CHAT-RESPONDERS.md](docs/CHAT-RESPONDERS.md) for responder configuration.
537
+
508
538
  ## How It Works
509
539
 
510
540
  1. **Read PRD**: Claude reads your requirements from `prd.json`
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "child_process";
2
2
  import { existsSync } from "fs";
3
3
  import { loadConfig, isRunningInContainer } from "../utils/config.js";
4
- import { getMessagesPath, sendMessage, waitForResponse, } from "../utils/message-queue.js";
4
+ import { getMessagesPath, sendMessage, waitForResponse } from "../utils/message-queue.js";
5
5
  import { getDefaultActions, getBuiltInActionNames } from "../utils/daemon-actions.js";
6
6
  /**
7
7
  * Execute an action from config.json - works both inside and outside containers.
@@ -60,23 +60,23 @@ export async function action(args) {
60
60
  console.log("No actions available.");
61
61
  console.log("");
62
62
  console.log("Configure actions in .ralph/config.json:");
63
- console.log(' {');
63
+ console.log(" {");
64
64
  console.log(' "daemon": {');
65
65
  console.log(' "actions": {');
66
66
  console.log(' "build": {');
67
67
  console.log(' "command": "./scripts/build.sh",');
68
68
  console.log(' "description": "Build the project"');
69
- console.log(' }');
70
- console.log(' }');
71
- console.log(' }');
72
- console.log(' }');
69
+ console.log(" }");
70
+ console.log(" }");
71
+ console.log(" }");
72
+ console.log(" }");
73
73
  }
74
74
  else {
75
75
  console.log("Available actions:");
76
76
  console.log("");
77
77
  // Show built-in actions first
78
- const builtInList = actionNames.filter(name => builtInNames.has(name) && !configuredActions[name]);
79
- const customList = actionNames.filter(name => !builtInNames.has(name) || configuredActions[name]);
78
+ const builtInList = actionNames.filter((name) => builtInNames.has(name) && !configuredActions[name]);
79
+ const customList = actionNames.filter((name) => !builtInNames.has(name) || configuredActions[name]);
80
80
  for (const name of builtInList) {
81
81
  const action = allActions[name];
82
82
  const desc = action.description || action.command;
@@ -187,7 +187,7 @@ async function executeDirectly(command, args, debug) {
187
187
  // Build full command with arguments
188
188
  let fullCommand = command;
189
189
  if (args.length > 0) {
190
- fullCommand = `${command} ${args.map(a => `"${a.replace(/"/g, '\\"')}"`).join(" ")}`;
190
+ fullCommand = `${command} ${args.map((a) => `"${a.replace(/"/g, '\\"')}"`).join(" ")}`;
191
191
  }
192
192
  if (debug) {
193
193
  console.log(`[action] Executing: ${fullCommand}`);
@@ -10,7 +10,7 @@ import { createTelegramClient } from "../providers/telegram.js";
10
10
  import { createSlackClient } from "../providers/slack.js";
11
11
  import { createDiscordClient } from "../providers/discord.js";
12
12
  import { generateProjectId, formatStatusMessage, formatStatusForChat, } from "../utils/chat-client.js";
13
- import { getMessagesPath, sendMessage, waitForResponse, } from "../utils/message-queue.js";
13
+ import { getMessagesPath, sendMessage, waitForResponse } from "../utils/message-queue.js";
14
14
  const CHAT_STATE_FILE = "chat-state.json";
15
15
  /**
16
16
  * Load chat state from .ralph/chat-state.json
@@ -307,15 +307,11 @@ async function handleCommand(command, client, config, state, debug) {
307
307
  }
308
308
  break;
309
309
  }
310
- case "stop": {
311
- await client.sendMessage(chatId, `${state.projectName}: Stop command received (not implemented yet)`);
312
- break;
313
- }
314
310
  case "action": {
315
311
  // Reload config to pick up new actions
316
312
  const freshConfig = loadConfig();
317
313
  const actions = freshConfig.daemon?.actions || {};
318
- const actionNames = Object.keys(actions).filter(name => name !== "notify" && name !== "telegram_notify");
314
+ const actionNames = Object.keys(actions).filter((name) => name !== "notify" && name !== "telegram_notify");
319
315
  if (args.length === 0) {
320
316
  // List available actions
321
317
  const usage = client.provider === "slack" ? "/ralph action <name>" : "/action <name>";
@@ -631,7 +627,9 @@ function showStatus(config) {
631
627
  }
632
628
  console.log("");
633
629
  if (config.chat.provider === "slack") {
634
- if (config.chat.slack?.botToken && config.chat.slack?.appToken && config.chat.slack?.signingSecret) {
630
+ if (config.chat.slack?.botToken &&
631
+ config.chat.slack?.appToken &&
632
+ config.chat.slack?.signingSecret) {
635
633
  console.log("Slack: configured");
636
634
  if (config.chat.slack.allowedChannelIds && config.chat.slack.allowedChannelIds.length > 0) {
637
635
  console.log(`Allowed channels: ${config.chat.slack.allowedChannelIds.join(", ")}`);
@@ -660,7 +658,8 @@ function showStatus(config) {
660
658
  else {
661
659
  console.log("Allowed guilds: all (no restrictions)");
662
660
  }
663
- if (config.chat.discord.allowedChannelIds && config.chat.discord.allowedChannelIds.length > 0) {
661
+ if (config.chat.discord.allowedChannelIds &&
662
+ config.chat.discord.allowedChannelIds.length > 0) {
664
663
  console.log(`Allowed channels: ${config.chat.discord.allowedChannelIds.join(", ")}`);
665
664
  }
666
665
  else {
@@ -698,12 +697,14 @@ async function testChat(config, chatId) {
698
697
  let client;
699
698
  let targetChatId;
700
699
  if (provider === "slack") {
701
- if (!config.chat.slack?.botToken || !config.chat.slack?.appToken || !config.chat.slack?.signingSecret) {
700
+ if (!config.chat.slack?.botToken ||
701
+ !config.chat.slack?.appToken ||
702
+ !config.chat.slack?.signingSecret) {
702
703
  console.error("Error: Slack configuration incomplete");
703
704
  console.error("Required: botToken, appToken, signingSecret");
704
705
  process.exit(1);
705
706
  }
706
- targetChatId = chatId || (config.chat.slack.allowedChannelIds?.[0]);
707
+ targetChatId = chatId || config.chat.slack.allowedChannelIds?.[0];
707
708
  if (!targetChatId) {
708
709
  console.error("Error: No channel ID specified and no allowed channel IDs configured");
709
710
  console.error("Usage: ralph chat test <channel_id>");
@@ -722,7 +723,7 @@ async function testChat(config, chatId) {
722
723
  console.error("Error: Discord bot token not configured");
723
724
  process.exit(1);
724
725
  }
725
- targetChatId = chatId || (config.chat.discord.allowedChannelIds?.[0]);
726
+ targetChatId = chatId || config.chat.discord.allowedChannelIds?.[0];
726
727
  if (!targetChatId) {
727
728
  console.error("Error: No channel ID specified and no allowed channel IDs configured");
728
729
  console.error("Usage: ralph chat test <channel_id>");
@@ -741,7 +742,7 @@ async function testChat(config, chatId) {
741
742
  console.error("Error: Telegram bot token not configured");
742
743
  process.exit(1);
743
744
  }
744
- targetChatId = chatId || (config.chat.telegram.allowedChatIds?.[0]);
745
+ targetChatId = chatId || config.chat.telegram.allowedChatIds?.[0];
745
746
  if (!targetChatId) {
746
747
  console.error("Error: No chat ID specified and no allowed chat IDs configured");
747
748
  console.error("Usage: ralph chat test <chat_id>");
@@ -44,8 +44,9 @@ SECTIONS:
44
44
  Docker Ports, volumes, environment, packages
45
45
  Daemon Actions, socket path
46
46
  Claude MCP servers, skills
47
- Chat Telegram integration
47
+ Chat Telegram, Slack, Discord integration
48
48
  Notify Notification settings
49
+ LLM LLM provider configuration (Anthropic, OpenAI, Ollama)
49
50
  `;
50
51
  console.log(helpText.trim());
51
52
  }
@@ -198,9 +198,10 @@ async function executeAction(action, args = []) {
198
198
  else {
199
199
  // Command can use $RALPH_MESSAGE env var
200
200
  // Also pass args for backwards compatibility
201
- fullCommand = args.length > 0
202
- ? `${action.command} ${args.map(a => `"${a.replace(/"/g, '\\"')}"`).join(" ")}`
203
- : action.command;
201
+ fullCommand =
202
+ args.length > 0
203
+ ? `${action.command} ${args.map((a) => `"${a.replace(/"/g, '\\"')}"`).join(" ")}`
204
+ : action.command;
204
205
  }
205
206
  const proc = spawn(fullCommand, [], {
206
207
  stdio: ["ignore", "pipe", "pipe"],
@@ -2,7 +2,7 @@ import { existsSync, writeFileSync, readFileSync, mkdirSync, chmodSync, openSync
2
2
  import { join, basename } from "path";
3
3
  import { spawn } from "child_process";
4
4
  import { createHash } from "crypto";
5
- import { loadConfig, getRalphDir } from "../utils/config.js";
5
+ import { loadConfig, getRalphDir, } from "../utils/config.js";
6
6
  import { promptConfirm } from "../utils/prompt.js";
7
7
  import { getLanguagesJson, getCliProvidersJson } from "../templates/prompts.js";
8
8
  // Track background processes for cleanup
@@ -19,11 +19,11 @@ function computeConfigHash(config) {
19
19
  claude: config.claude,
20
20
  };
21
21
  const content = JSON.stringify(relevantConfig, null, 2);
22
- return createHash('sha256').update(content).digest('hex').substring(0, 16);
22
+ return createHash("sha256").update(content).digest("hex").substring(0, 16);
23
23
  }
24
24
  // Save config hash to docker directory
25
25
  function saveConfigHash(dockerDir, hash) {
26
- writeFileSync(join(dockerDir, CONFIG_HASH_FILE), hash + '\n');
26
+ writeFileSync(join(dockerDir, CONFIG_HASH_FILE), hash + "\n");
27
27
  }
28
28
  // Load saved config hash, returns null if not found
29
29
  function loadConfigHash(dockerDir) {
@@ -31,7 +31,7 @@ function loadConfigHash(dockerDir) {
31
31
  if (!existsSync(hashPath)) {
32
32
  return null;
33
33
  }
34
- return readFileSync(hashPath, 'utf-8').trim();
34
+ return readFileSync(hashPath, "utf-8").trim();
35
35
  }
36
36
  // Check if config has changed since last docker init
37
37
  function hasConfigChanged(ralphDir, config) {
@@ -75,30 +75,30 @@ function generateDockerfile(language, javaVersion, cliProvider, dockerConfig) {
75
75
  const languageSnippet = getLanguageSnippet(language, javaVersion);
76
76
  const cliSnippet = getCliProviderSnippet(cliProvider);
77
77
  // Build custom packages section
78
- let customPackages = '';
78
+ let customPackages = "";
79
79
  if (dockerConfig?.packages && dockerConfig.packages.length > 0) {
80
- customPackages = dockerConfig.packages.map(pkg => ` ${pkg} \\`).join('\n') + '\n';
80
+ customPackages = dockerConfig.packages.map((pkg) => ` ${pkg} \\`).join("\n") + "\n";
81
81
  }
82
82
  // Build root build commands section
83
- let rootBuildCommands = '';
83
+ let rootBuildCommands = "";
84
84
  if (dockerConfig?.buildCommands?.root && dockerConfig.buildCommands.root.length > 0) {
85
- const commands = dockerConfig.buildCommands.root.map(cmd => `RUN ${cmd}`).join('\n');
85
+ const commands = dockerConfig.buildCommands.root.map((cmd) => `RUN ${cmd}`).join("\n");
86
86
  rootBuildCommands = `
87
87
  # Custom build commands (root)
88
88
  ${commands}
89
89
  `;
90
90
  }
91
91
  // Build node build commands section
92
- let nodeBuildCommands = '';
92
+ let nodeBuildCommands = "";
93
93
  if (dockerConfig?.buildCommands?.node && dockerConfig.buildCommands.node.length > 0) {
94
- const commands = dockerConfig.buildCommands.node.map(cmd => `RUN ${cmd}`).join('\n');
94
+ const commands = dockerConfig.buildCommands.node.map((cmd) => `RUN ${cmd}`).join("\n");
95
95
  nodeBuildCommands = `
96
96
  # Custom build commands (node user)
97
97
  ${commands}
98
98
  `;
99
99
  }
100
100
  // Build git config section if configured
101
- let gitConfigSection = '';
101
+ let gitConfigSection = "";
102
102
  if (dockerConfig?.git && (dockerConfig.git.name || dockerConfig.git.email)) {
103
103
  const gitCommands = [];
104
104
  if (dockerConfig.git.name) {
@@ -109,15 +109,15 @@ ${commands}
109
109
  }
110
110
  gitConfigSection = `
111
111
  # Configure git identity
112
- RUN ${gitCommands.join(' \\\n && ')}
112
+ RUN ${gitCommands.join(" \\\n && ")}
113
113
  `;
114
114
  }
115
115
  // Build asciinema installation section if enabled
116
- let asciinemaInstall = '';
117
- let asciinemaDir = '';
118
- let streamScriptCopy = '';
116
+ let asciinemaInstall = "";
117
+ let asciinemaDir = "";
118
+ let streamScriptCopy = "";
119
119
  if (dockerConfig?.asciinema?.enabled) {
120
- const outputDir = dockerConfig.asciinema.outputDir || '.recordings';
120
+ const outputDir = dockerConfig.asciinema.outputDir || ".recordings";
121
121
  asciinemaInstall = `
122
122
  # Install asciinema for terminal recording/streaming
123
123
  RUN apt-get update && apt-get install -y asciinema && rm -rf /var/lib/apt/lists/*
@@ -242,9 +242,9 @@ CMD ["zsh"]
242
242
  }
243
243
  function generateFirewallScript(customDomains = []) {
244
244
  // Generate custom domains section if any are configured
245
- let customDomainsSection = '';
245
+ let customDomainsSection = "";
246
246
  if (customDomains.length > 0) {
247
- const domainList = customDomains.join(' ');
247
+ const domainList = customDomains.join(" ");
248
248
  customDomainsSection = `
249
249
  # Custom allowed domains (from config)
250
250
  for ip in $(dig +short ${domainList}); do
@@ -254,8 +254,8 @@ done
254
254
  }
255
255
  // Generate echo line with custom domains if configured
256
256
  const allowedList = customDomains.length > 0
257
- ? `GitHub, npm, Anthropic API, local network, ${customDomains.join(', ')}`
258
- : 'GitHub, npm, Anthropic API, local network';
257
+ ? `GitHub, npm, Anthropic API, local network, ${customDomains.join(", ")}`
258
+ : "GitHub, npm, Anthropic API, local network";
259
259
  return `#!/bin/bash
260
260
  # Firewall initialization script for Ralph sandbox
261
261
  # Based on Claude Code devcontainer firewall
@@ -338,26 +338,26 @@ echo "Allowed: ${allowedList}"
338
338
  }
339
339
  function generateDockerCompose(imageName, dockerConfig) {
340
340
  // Build ports section if configured
341
- let portsSection = '';
341
+ let portsSection = "";
342
342
  if (dockerConfig?.ports && dockerConfig.ports.length > 0) {
343
- const portLines = dockerConfig.ports.map(port => ` - "${port}"`).join('\n');
343
+ const portLines = dockerConfig.ports.map((port) => ` - "${port}"`).join("\n");
344
344
  portsSection = ` ports:\n${portLines}\n`;
345
345
  }
346
346
  // Build volumes array: base volumes + custom volumes
347
347
  const baseVolumes = [
348
- ' # Mount project root (two levels up from .ralph/docker/)',
349
- ' - ../..:/workspace',
348
+ " # Mount project root (two levels up from .ralph/docker/)",
349
+ " - ../..:/workspace",
350
350
  " # Mount host's ~/.claude for Pro/Max OAuth credentials",
351
- ' - ${HOME}/.claude:/home/node/.claude',
351
+ " - ${HOME}/.claude:/home/node/.claude",
352
352
  ` - ${imageName}-history:/commandhistory`,
353
353
  ];
354
354
  if (dockerConfig?.volumes && dockerConfig.volumes.length > 0) {
355
- const customVolumeLines = dockerConfig.volumes.map(vol => ` - ${vol}`);
355
+ const customVolumeLines = dockerConfig.volumes.map((vol) => ` - ${vol}`);
356
356
  baseVolumes.push(...customVolumeLines);
357
357
  }
358
- const volumesSection = baseVolumes.join('\n');
358
+ const volumesSection = baseVolumes.join("\n");
359
359
  // Build environment section if configured
360
- let environmentSection = '';
360
+ let environmentSection = "";
361
361
  const envEntries = [];
362
362
  // Add user-configured environment variables
363
363
  if (dockerConfig?.environment && Object.keys(dockerConfig.environment).length > 0) {
@@ -366,7 +366,7 @@ function generateDockerCompose(imageName, dockerConfig) {
366
366
  }
367
367
  }
368
368
  if (envEntries.length > 0) {
369
- environmentSection = ` environment:\n${envEntries.join('\n')}\n`;
369
+ environmentSection = ` environment:\n${envEntries.join("\n")}\n`;
370
370
  }
371
371
  else {
372
372
  // Keep the commented placeholder for users who don't have config
@@ -375,12 +375,12 @@ function generateDockerCompose(imageName, dockerConfig) {
375
375
  # - ANTHROPIC_API_KEY=\${ANTHROPIC_API_KEY}\n`;
376
376
  }
377
377
  // Build command section if configured
378
- let commandSection = '';
379
- let streamJsonNote = '';
378
+ let commandSection = "";
379
+ let streamJsonNote = "";
380
380
  if (dockerConfig?.asciinema?.enabled && dockerConfig?.asciinema?.autoRecord) {
381
381
  // Wrap with asciinema recording
382
- const outputDir = dockerConfig.asciinema.outputDir || '.recordings';
383
- const innerCommand = dockerConfig.startCommand || 'zsh';
382
+ const outputDir = dockerConfig.asciinema.outputDir || ".recordings";
383
+ const innerCommand = dockerConfig.startCommand || "zsh";
384
384
  commandSection = ` command: bash -c "mkdir -p /workspace/${outputDir} && asciinema rec -c '${innerCommand}' /workspace/${outputDir}/session-$$(date +%Y%m%d-%H%M%S).cast"\n`;
385
385
  // Add note about stream-json if enabled
386
386
  if (dockerConfig.asciinema.streamJson?.enabled) {
@@ -402,14 +402,14 @@ function generateDockerCompose(imageName, dockerConfig) {
402
402
  }
403
403
  // Build restart policy section
404
404
  // Priority: restartCount (on-failure with max retries) > autoStart (unless-stopped)
405
- let restartSection = '';
405
+ let restartSection = "";
406
406
  if (dockerConfig?.restartCount !== undefined && dockerConfig.restartCount > 0) {
407
407
  // Use on-failure policy with max retry count
408
408
  restartSection = ` restart: on-failure:${dockerConfig.restartCount}\n`;
409
409
  }
410
410
  else if (dockerConfig?.autoStart) {
411
411
  // Use unless-stopped for auto-restart on daemon start
412
- restartSection = ' restart: unless-stopped\n';
412
+ restartSection = " restart: unless-stopped\n";
413
413
  }
414
414
  return `# Ralph CLI Docker Compose
415
415
  # Generated by ralph-cli
@@ -440,10 +440,12 @@ dist
440
440
  `;
441
441
  // Generate stream wrapper script for clean asciinema recordings
442
442
  function generateStreamScript(outputDir, saveRawJson) {
443
- const saveJsonSection = saveRawJson ? `
443
+ const saveJsonSection = saveRawJson
444
+ ? `
444
445
  # Save raw JSON for later analysis
445
446
  JSON_LOG="$OUTPUT_DIR/session-$TIMESTAMP.jsonl"
446
- TEE_CMD="tee \\"$JSON_LOG\\""` : `
447
+ TEE_CMD="tee \\"$JSON_LOG\\""`
448
+ : `
447
449
  TEE_CMD="cat"`;
448
450
  return `#!/bin/bash
449
451
  # Ralph stream wrapper - formats Claude stream-json output for clean terminal display
@@ -520,12 +522,12 @@ function generateMcpJson(mcpServers) {
520
522
  }
521
523
  // Generate skill file content with YAML frontmatter
522
524
  function generateSkillFile(skill) {
523
- const lines = ['---', `description: ${skill.description}`];
525
+ const lines = ["---", `description: ${skill.description}`];
524
526
  if (skill.userInvocable === false) {
525
- lines.push('user-invocable: false');
527
+ lines.push("user-invocable: false");
526
528
  }
527
- lines.push('---', '', skill.instructions, '');
528
- return lines.join('\n');
529
+ lines.push("---", "", skill.instructions, "");
530
+ return lines.join("\n");
529
531
  }
530
532
  async function generateFiles(ralphDir, language, imageName, force = false, javaVersion, cliProvider, dockerConfig, claudeConfig) {
531
533
  const dockerDir = join(ralphDir, DOCKER_DIR);
@@ -536,14 +538,17 @@ async function generateFiles(ralphDir, language, imageName, force = false, javaV
536
538
  }
537
539
  const customDomains = dockerConfig?.firewall?.allowedDomains || [];
538
540
  const files = [
539
- { name: "Dockerfile", content: generateDockerfile(language, javaVersion, cliProvider, dockerConfig) },
541
+ {
542
+ name: "Dockerfile",
543
+ content: generateDockerfile(language, javaVersion, cliProvider, dockerConfig),
544
+ },
540
545
  { name: "init-firewall.sh", content: generateFirewallScript(customDomains) },
541
546
  { name: "docker-compose.yml", content: generateDockerCompose(imageName, dockerConfig) },
542
547
  { name: ".dockerignore", content: DOCKERIGNORE },
543
548
  ];
544
549
  // Add stream script if streamJson is enabled
545
550
  if (dockerConfig?.asciinema?.enabled && dockerConfig.asciinema.streamJson?.enabled) {
546
- const outputDir = dockerConfig.asciinema.outputDir || '.recordings';
551
+ const outputDir = dockerConfig.asciinema.outputDir || ".recordings";
547
552
  const saveRawJson = dockerConfig.asciinema.streamJson.saveRawJson !== false; // default true
548
553
  files.push({ name: "ralph-stream.sh", content: generateStreamScript(outputDir, saveRawJson) });
549
554
  }
@@ -566,28 +571,28 @@ async function generateFiles(ralphDir, language, imageName, force = false, javaV
566
571
  const projectRoot = process.cwd();
567
572
  // Generate .mcp.json if MCP servers are configured
568
573
  if (claudeConfig?.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0) {
569
- const mcpJsonPath = join(projectRoot, '.mcp.json');
574
+ const mcpJsonPath = join(projectRoot, ".mcp.json");
570
575
  if (existsSync(mcpJsonPath) && !force) {
571
- const overwrite = await promptConfirm('.mcp.json already exists. Overwrite?');
576
+ const overwrite = await promptConfirm(".mcp.json already exists. Overwrite?");
572
577
  if (!overwrite) {
573
- console.log('Skipped .mcp.json');
578
+ console.log("Skipped .mcp.json");
574
579
  }
575
580
  else {
576
581
  writeFileSync(mcpJsonPath, generateMcpJson(claudeConfig.mcpServers));
577
- console.log('Created .mcp.json');
582
+ console.log("Created .mcp.json");
578
583
  }
579
584
  }
580
585
  else {
581
586
  writeFileSync(mcpJsonPath, generateMcpJson(claudeConfig.mcpServers));
582
- console.log('Created .mcp.json');
587
+ console.log("Created .mcp.json");
583
588
  }
584
589
  }
585
590
  // Generate skill files if skills are configured
586
591
  if (claudeConfig?.skills && claudeConfig.skills.length > 0) {
587
- const commandsDir = join(projectRoot, '.claude', 'commands');
592
+ const commandsDir = join(projectRoot, ".claude", "commands");
588
593
  if (!existsSync(commandsDir)) {
589
594
  mkdirSync(commandsDir, { recursive: true });
590
- console.log('Created .claude/commands/');
595
+ console.log("Created .claude/commands/");
591
596
  }
592
597
  for (const skill of claudeConfig.skills) {
593
598
  const skillPath = join(commandsDir, `${skill.name}.md`);
@@ -605,8 +610,8 @@ async function generateFiles(ralphDir, language, imageName, force = false, javaV
605
610
  // Save config hash for change detection
606
611
  const configForHash = {
607
612
  language,
608
- checkCommand: '',
609
- testCommand: '',
613
+ checkCommand: "",
614
+ testCommand: "",
610
615
  javaVersion,
611
616
  cliProvider,
612
617
  docker: dockerConfig,
@@ -627,12 +632,18 @@ async function buildImage(ralphDir) {
627
632
  if (hasConfigChanged(ralphDir, config)) {
628
633
  const regenerate = await promptConfirm("Config has changed since last docker init. Regenerate Docker files?");
629
634
  if (regenerate) {
630
- await generateFiles(ralphDir, config.language, config.imageName || `ralph-${basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-")}`, true, config.javaVersion, config.cliProvider, config.docker, config.claude);
635
+ await generateFiles(ralphDir, config.language, config.imageName ||
636
+ `ralph-${basename(process.cwd())
637
+ .toLowerCase()
638
+ .replace(/[^a-z0-9-]/g, "-")}`, true, config.javaVersion, config.cliProvider, config.docker, config.claude);
631
639
  console.log("");
632
640
  }
633
641
  }
634
642
  console.log("Building Docker image...\n");
635
- const imageName = config.imageName || `ralph-${basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
643
+ const imageName = config.imageName ||
644
+ `ralph-${basename(process.cwd())
645
+ .toLowerCase()
646
+ .replace(/[^a-z0-9-]/g, "-")}`;
636
647
  return new Promise((resolve, reject) => {
637
648
  // Use --no-cache and --pull to ensure we always get the latest CLI versions
638
649
  // Use -p to set unique project name per ralph project
@@ -718,8 +729,7 @@ function startBackgroundServices(config) {
718
729
  services.push("daemon");
719
730
  }
720
731
  // Start chat if telegram is configured and not explicitly disabled
721
- const telegramEnabled = config.chat?.telegram?.botToken &&
722
- config.chat.telegram.enabled !== false;
732
+ const telegramEnabled = config.chat?.telegram?.botToken && config.chat.telegram.enabled !== false;
723
733
  if (telegramEnabled) {
724
734
  const logPath = join(ralphDir, "chat.log");
725
735
  const logFd = openSync(logPath, "w");
@@ -760,8 +770,8 @@ async function runContainer(ralphDir, imageName, language, javaVersion, cliProvi
760
770
  if (dockerfileExists) {
761
771
  const configForHash = {
762
772
  language,
763
- checkCommand: '',
764
- testCommand: '',
773
+ checkCommand: "",
774
+ testCommand: "",
765
775
  javaVersion,
766
776
  cliProvider,
767
777
  docker: dockerConfig,
@@ -877,7 +887,18 @@ async function cleanImage(imageName, ralphDir) {
877
887
  // Remove containers, volumes, networks, and local images
878
888
  // Use -p to target only this project's resources
879
889
  await new Promise((resolve) => {
880
- const proc = spawn("docker", ["compose", "-p", imageName, "down", "--rmi", "local", "-v", "--remove-orphans", "--timeout", "5"], {
890
+ const proc = spawn("docker", [
891
+ "compose",
892
+ "-p",
893
+ imageName,
894
+ "down",
895
+ "--rmi",
896
+ "local",
897
+ "-v",
898
+ "--remove-orphans",
899
+ "--timeout",
900
+ "5",
901
+ ], {
881
902
  cwd: dockerDir,
882
903
  stdio: "inherit",
883
904
  });
@@ -904,7 +925,10 @@ async function cleanImage(imageName, ralphDir) {
904
925
  output += data.toString();
905
926
  });
906
927
  proc.on("close", async () => {
907
- const containerIds = output.trim().split("\n").filter((id) => id.length > 0);
928
+ const containerIds = output
929
+ .trim()
930
+ .split("\n")
931
+ .filter((id) => id.length > 0);
908
932
  if (containerIds.length > 0) {
909
933
  // Force remove these containers
910
934
  await new Promise((innerResolve) => {
@@ -944,7 +968,10 @@ async function cleanImage(imageName, ralphDir) {
944
968
  output += data.toString();
945
969
  });
946
970
  proc.on("close", async () => {
947
- const volumeNames = output.trim().split("\n").filter((name) => name.length > 0);
971
+ const volumeNames = output
972
+ .trim()
973
+ .split("\n")
974
+ .filter((name) => name.length > 0);
948
975
  if (volumeNames.length > 0) {
949
976
  // Force remove these volumes
950
977
  await new Promise((innerResolve) => {
@@ -985,7 +1012,10 @@ async function cleanImage(imageName, ralphDir) {
985
1012
  output += data.toString();
986
1013
  });
987
1014
  proc.on("close", async () => {
988
- const podIds = output.trim().split("\n").filter((id) => id.length > 0);
1015
+ const podIds = output
1016
+ .trim()
1017
+ .split("\n")
1018
+ .filter((id) => id.length > 0);
989
1019
  if (podIds.length > 0) {
990
1020
  // Force remove these pods (this also removes their containers)
991
1021
  await new Promise((innerResolve) => {
@@ -1026,7 +1056,10 @@ async function cleanImage(imageName, ralphDir) {
1026
1056
  export async function dockerInit(silent = false) {
1027
1057
  const config = loadConfig();
1028
1058
  const ralphDir = getRalphDir();
1029
- const imageName = config.imageName ?? `ralph-${basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
1059
+ const imageName = config.imageName ??
1060
+ `ralph-${basename(process.cwd())
1061
+ .toLowerCase()
1062
+ .replace(/[^a-z0-9-]/g, "-")}`;
1030
1063
  console.log(`\nGenerating Docker files for: ${config.language}`);
1031
1064
  if ((config.language === "java" || config.language === "kotlin") && config.javaVersion) {
1032
1065
  console.log(`Java version: ${config.javaVersion}`);
@@ -1112,7 +1145,10 @@ INSTALLING PACKAGES (works with Docker & Podman):
1112
1145
  }
1113
1146
  const config = loadConfig();
1114
1147
  // Get image name from config or generate default
1115
- const imageName = config.imageName || `ralph-${basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
1148
+ const imageName = config.imageName ||
1149
+ `ralph-${basename(process.cwd())
1150
+ .toLowerCase()
1151
+ .replace(/[^a-z0-9-]/g, "-")}`;
1116
1152
  const hasFlag = (flag) => subArgs.includes(flag);
1117
1153
  switch (subcommand) {
1118
1154
  case "build":
@@ -1134,8 +1170,8 @@ INSTALLING PACKAGES (works with Docker & Podman):
1134
1170
  default: {
1135
1171
  // Default to init if no subcommand or unrecognized subcommand
1136
1172
  const force = subcommand === "init"
1137
- ? (subArgs[0] === "-y" || subArgs[0] === "--yes")
1138
- : (subcommand === "-y" || subcommand === "--yes");
1173
+ ? subArgs[0] === "-y" || subArgs[0] === "--yes"
1174
+ : subcommand === "-y" || subcommand === "--yes";
1139
1175
  console.log(`Generating Docker files for: ${config.language}`);
1140
1176
  if ((config.language === "java" || config.language === "kotlin") && config.javaVersion) {
1141
1177
  console.log(`Java version: ${config.javaVersion}`);
@@ -206,7 +206,8 @@ function recoverSections(corruptContent, parsedPartial) {
206
206
  source = "parsed";
207
207
  }
208
208
  // If not found or invalid, try regex extraction for simple string fields
209
- if ((value === undefined || !validateSection(section, value)) && typeof corruptContent === "string") {
209
+ if ((value === undefined || !validateSection(section, value)) &&
210
+ typeof corruptContent === "string") {
210
211
  const extracted = extractSectionFromCorrupt(corruptContent, section);
211
212
  if (extracted !== undefined && validateSection(section, extracted)) {
212
213
  value = extracted;
@@ -31,7 +31,7 @@ function restoreFromBackup(prdPath, backupPath) {
31
31
  const validation = validatePrd(backupParsed);
32
32
  if (!validation.valid) {
33
33
  console.error("Error: Backup file contains invalid PRD structure:");
34
- validation.errors.slice(0, 3).forEach(err => {
34
+ validation.errors.slice(0, 3).forEach((err) => {
35
35
  console.error(` - ${err}`);
36
36
  });
37
37
  return false;
@@ -138,7 +138,7 @@ export async function fixPrd(args = []) {
138
138
  }
139
139
  // PRD is invalid
140
140
  console.log("\x1b[31m✗ PRD structure is invalid:\x1b[0m");
141
- validation.errors.slice(0, 5).forEach(err => {
141
+ validation.errors.slice(0, 5).forEach((err) => {
142
142
  console.log(` - ${err}`);
143
143
  });
144
144
  if (validation.errors.length > 5) {