ralph-cli-sandboxed 0.2.5 → 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.
package/README.md CHANGED
@@ -85,12 +85,28 @@ After running `ralph init`, you'll have:
85
85
 
86
86
  ### Supported Languages
87
87
 
88
- - **Bun** (TypeScript) - `bun check`, `bun test`
89
- - **Node.js** (TypeScript) - `npm run typecheck`, `npm test`
90
- - **Python** - `mypy .`, `pytest`
91
- - **Go** - `go build ./...`, `go test ./...`
92
- - **Rust** - `cargo check`, `cargo test`
93
- - **Custom** - Define your own commands
88
+ Ralph supports 18 programming languages with pre-configured build/test commands:
89
+
90
+ | Language | Check Command | Test Command |
91
+ |----------|--------------|--------------|
92
+ | Bun (TypeScript) | `bun check` | `bun test` |
93
+ | Node.js (TypeScript) | `npm run typecheck` | `npm test` |
94
+ | Python | `mypy .` | `pytest` |
95
+ | Go | `go build ./...` | `go test ./...` |
96
+ | Rust | `cargo check` | `cargo test` |
97
+ | Java | `mvn compile` | `mvn test` |
98
+ | Kotlin | `gradle build` | `gradle test` |
99
+ | C#/.NET | `dotnet build` | `dotnet test` |
100
+ | Ruby | `bundle exec rubocop` | `bundle exec rspec` |
101
+ | PHP | `composer validate` | `vendor/bin/phpunit` |
102
+ | Swift | `swift build` | `swift test` |
103
+ | Elixir | `mix compile` | `mix test` |
104
+ | Scala | `sbt compile` | `sbt test` |
105
+ | Zig | `zig build` | `zig build test` |
106
+ | Haskell | `stack build` | `stack test` |
107
+ | Clojure | `lein check` | `lein test` |
108
+ | Deno (TypeScript) | `deno check **/*.ts` | `deno test` |
109
+ | Custom | User-defined | User-defined |
94
110
 
95
111
  ### Supported CLI Providers
96
112
 
@@ -147,7 +163,7 @@ The PRD (`prd.json`) is an array of requirements:
147
163
  ]
148
164
  ```
149
165
 
150
- Categories: `ui`, `feature`, `bugfix`, `setup`, `development`, `testing`, `docs`
166
+ Categories: `setup`, `feature`, `bugfix`, `refactor`, `docs`, `test`, `release`, `config`, `ui`
151
167
 
152
168
  ### Advanced: File References
153
169
 
@@ -191,13 +207,7 @@ Features:
191
207
  - Your `~/.claude` credentials mounted automatically (Pro/Max OAuth)
192
208
  - Language-specific tooling pre-installed
193
209
 
194
- ### Installing packages in container
195
-
196
- ```bash
197
- # Run as root to install packages
198
- docker compose run -u root ralph apt-get update
199
- docker compose run -u root ralph apt-get install <package>
200
- ```
210
+ See [docs/DOCKER.md](docs/DOCKER.md) for detailed Docker configuration, customization, and troubleshooting.
201
211
 
202
212
  ## How It Works
203
213
 
@@ -212,71 +222,24 @@ When all PRD items pass, Claude outputs `<promise>COMPLETE</promise>` and stops.
212
222
 
213
223
  ## Security
214
224
 
215
- ### Container Requirement
216
-
217
- **It is strongly recommended to run ralph inside a Docker container for security.** The Ralph Wiggum technique involves running an AI agent autonomously, which means granting it elevated permissions to execute code and modify files without manual approval for each action.
218
-
219
- ### The `--dangerously-skip-permissions` Flag
220
-
221
- When running inside a container, ralph automatically passes the `--dangerously-skip-permissions` flag to Claude Code. This flag:
225
+ **It is strongly recommended to run ralph inside a Docker container for security.** The Ralph Wiggum technique involves running an AI agent autonomously with elevated permissions.
222
226
 
223
- - Allows Claude to execute commands and modify files without prompting for permission
224
- - Is **only** enabled when ralph detects it's running inside a container
225
- - Is required for autonomous operation (otherwise Claude would pause for approval on every action)
227
+ When running inside a container, ralph automatically passes `--dangerously-skip-permissions` to Claude Code, allowing autonomous operation. This flag is only enabled in containers for safety.
226
228
 
227
- **Warning:** The `--dangerously-skip-permissions` flag gives the AI agent full control over the environment. This is why container isolation is critical:
228
-
229
- - The container provides a sandbox boundary
230
- - Network access is restricted to essential services (GitHub, npm, Anthropic API)
231
- - Your host system remains protected even if something goes wrong
232
-
233
- ### Container Detection
234
-
235
- Ralph detects container environments by checking:
236
- - `DEVCONTAINER` environment variable
237
- - Presence of `/.dockerenv` file
238
- - Container indicators in `/proc/1/cgroup`
239
- - `container` environment variable
240
-
241
- If you're running outside a container and need autonomous mode, use `ralph docker` to set up a safe sandbox environment first.
229
+ See [docs/SECURITY.md](docs/SECURITY.md) for detailed security information, container detection, and best practices.
242
230
 
243
231
  ## Development
244
232
 
245
233
  To contribute or test changes to ralph locally:
246
234
 
247
235
  ```bash
248
- # Clone the repository
249
236
  git clone https://github.com/choas/ralph-cli-sandboxed
250
237
  cd ralph-cli-sandboxed
251
-
252
- # Install dependencies
253
238
  npm install
254
-
255
- # Run ralph in development mode (without building)
256
- npm run dev -- <args>
257
-
258
- # Examples:
259
- npm run dev -- --version
260
- npm run dev -- list
261
- npm run dev -- once
239
+ npm run dev -- <args> # Run from TypeScript source
262
240
  ```
263
241
 
264
- The `npm run dev -- <args>` command runs ralph directly from TypeScript source using `tsx`, allowing you to test changes without rebuilding.
265
-
266
- ### Platform-Specific Dependencies
267
-
268
- The `node_modules` folder contains platform-specific binaries (e.g., esbuild). If you switch between running on your host machine and inside a Docker/Podman container, you'll need to reinstall dependencies:
269
-
270
- ```bash
271
- # When switching environments (host <-> container)
272
- rm -rf node_modules && npm install
273
- ```
274
-
275
- Alternatively, when mounting your project into a container, use a separate volume for node_modules to keep host and container dependencies isolated:
276
-
277
- ```bash
278
- podman run -v $(pwd):/workspace -v /workspace/node_modules your-image
279
- ```
242
+ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for detailed development setup, project structure, and contribution guidelines.
280
243
 
281
244
  ## Requirements
282
245
 
@@ -1,10 +1,46 @@
1
- import { existsSync, writeFileSync, mkdirSync, chmodSync } from "fs";
1
+ import { existsSync, writeFileSync, readFileSync, mkdirSync, chmodSync } from "fs";
2
2
  import { join, basename } from "path";
3
3
  import { spawn } from "child_process";
4
+ import { createHash } from "crypto";
4
5
  import { loadConfig, getRalphDir } from "../utils/config.js";
5
6
  import { promptConfirm } from "../utils/prompt.js";
6
7
  import { getLanguagesJson, getCliProvidersJson } from "../templates/prompts.js";
7
8
  const DOCKER_DIR = "docker";
9
+ const CONFIG_HASH_FILE = ".config-hash";
10
+ // Compute hash of docker-relevant config fields
11
+ function computeConfigHash(config) {
12
+ const relevantConfig = {
13
+ language: config.language,
14
+ javaVersion: config.javaVersion,
15
+ cliProvider: config.cliProvider,
16
+ docker: config.docker,
17
+ claude: config.claude,
18
+ };
19
+ const content = JSON.stringify(relevantConfig, null, 2);
20
+ return createHash('sha256').update(content).digest('hex').substring(0, 16);
21
+ }
22
+ // Save config hash to docker directory
23
+ function saveConfigHash(dockerDir, hash) {
24
+ writeFileSync(join(dockerDir, CONFIG_HASH_FILE), hash + '\n');
25
+ }
26
+ // Load saved config hash, returns null if not found
27
+ function loadConfigHash(dockerDir) {
28
+ const hashPath = join(dockerDir, CONFIG_HASH_FILE);
29
+ if (!existsSync(hashPath)) {
30
+ return null;
31
+ }
32
+ return readFileSync(hashPath, 'utf-8').trim();
33
+ }
34
+ // Check if config has changed since last docker init
35
+ function hasConfigChanged(ralphDir, config) {
36
+ const dockerDir = join(ralphDir, DOCKER_DIR);
37
+ const savedHash = loadConfigHash(dockerDir);
38
+ if (!savedHash) {
39
+ return false; // No hash file means docker init hasn't run yet
40
+ }
41
+ const currentHash = computeConfigHash(config);
42
+ return savedHash !== currentHash;
43
+ }
8
44
  // Get language Docker snippet from config, with version substitution
9
45
  function getLanguageSnippet(language, javaVersion) {
10
46
  const languagesJson = getLanguagesJson();
@@ -36,6 +72,29 @@ function getCliProviderSnippet(cliProvider) {
36
72
  function generateDockerfile(language, javaVersion, cliProvider, dockerConfig) {
37
73
  const languageSnippet = getLanguageSnippet(language, javaVersion);
38
74
  const cliSnippet = getCliProviderSnippet(cliProvider);
75
+ // Build custom packages section
76
+ let customPackages = '';
77
+ if (dockerConfig?.packages && dockerConfig.packages.length > 0) {
78
+ customPackages = dockerConfig.packages.map(pkg => ` ${pkg} \\`).join('\n') + '\n';
79
+ }
80
+ // Build root build commands section
81
+ let rootBuildCommands = '';
82
+ if (dockerConfig?.buildCommands?.root && dockerConfig.buildCommands.root.length > 0) {
83
+ const commands = dockerConfig.buildCommands.root.map(cmd => `RUN ${cmd}`).join('\n');
84
+ rootBuildCommands = `
85
+ # Custom build commands (root)
86
+ ${commands}
87
+ `;
88
+ }
89
+ // Build node build commands section
90
+ let nodeBuildCommands = '';
91
+ if (dockerConfig?.buildCommands?.node && dockerConfig.buildCommands.node.length > 0) {
92
+ const commands = dockerConfig.buildCommands.node.map(cmd => `RUN ${cmd}`).join('\n');
93
+ nodeBuildCommands = `
94
+ # Custom build commands (node user)
95
+ ${commands}
96
+ `;
97
+ }
39
98
  // Build git config section if configured
40
99
  let gitConfigSection = '';
41
100
  if (dockerConfig?.git && (dockerConfig.git.name || dockerConfig.git.email)) {
@@ -51,6 +110,29 @@ function generateDockerfile(language, javaVersion, cliProvider, dockerConfig) {
51
110
  RUN ${gitCommands.join(' \\\n && ')}
52
111
  `;
53
112
  }
113
+ // Build asciinema installation section if enabled
114
+ let asciinemaInstall = '';
115
+ let asciinemaDir = '';
116
+ let streamScriptCopy = '';
117
+ if (dockerConfig?.asciinema?.enabled) {
118
+ const outputDir = dockerConfig.asciinema.outputDir || '.recordings';
119
+ asciinemaInstall = `
120
+ # Install asciinema for terminal recording/streaming
121
+ RUN apt-get update && apt-get install -y asciinema && rm -rf /var/lib/apt/lists/*
122
+ `;
123
+ asciinemaDir = `
124
+ # Create asciinema recordings directory
125
+ RUN mkdir -p /workspace/${outputDir} && chown node:node /workspace/${outputDir}
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
+ }
135
+ }
54
136
  return `# Ralph CLI Sandbox Environment
55
137
  # Based on Claude Code devcontainer
56
138
  # Generated by ralph-cli
@@ -85,7 +167,7 @@ RUN apt-get update && apt-get install -y \\
85
167
  iproute2 \\
86
168
  dnsutils \\
87
169
  zsh \\
88
- && rm -rf /var/lib/apt/lists/*
170
+ ${customPackages} && rm -rf /var/lib/apt/lists/*
89
171
 
90
172
  # Setup zsh with oh-my-zsh and plugins (no theme, we set custom prompt)
91
173
  RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v\${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \\
@@ -130,7 +212,7 @@ RUN echo "node ALL=(ALL) NOPASSWD: /usr/local/bin/init-firewall.sh" >> /etc/sudo
130
212
  RUN mkdir -p /workspace && chown node:node /workspace
131
213
  RUN mkdir -p /home/node/.claude && chown node:node /home/node/.claude
132
214
  RUN mkdir -p /commandhistory && chown node:node /commandhistory
133
-
215
+ ${asciinemaDir}
134
216
  # Copy firewall script
135
217
  COPY init-firewall.sh /usr/local/bin/init-firewall.sh
136
218
  RUN chmod +x /usr/local/bin/init-firewall.sh
@@ -145,17 +227,33 @@ ENV EDITOR=nano
145
227
  # Add bash aliases and prompt (fallback if using bash)
146
228
  RUN echo 'alias ll="ls -la"' >> /etc/bash.bashrc && \\
147
229
  echo 'PS1="\\[\\033[43;30m\\][ralph]\\w\\[\\033[0m\\]\\$ "' >> /etc/bash.bashrc
148
-
230
+ ${rootBuildCommands}${asciinemaInstall}${streamScriptCopy}
149
231
  # Switch to non-root user
150
232
  USER node
151
- ${gitConfigSection}
233
+ ${gitConfigSection}${nodeBuildCommands}
152
234
  WORKDIR /workspace
153
235
 
154
236
  # Default to zsh
155
237
  CMD ["zsh"]
156
238
  `;
157
239
  }
158
- const FIREWALL_SCRIPT = `#!/bin/bash
240
+ function generateFirewallScript(customDomains = []) {
241
+ // Generate custom domains section if any are configured
242
+ let customDomainsSection = '';
243
+ if (customDomains.length > 0) {
244
+ const domainList = customDomains.join(' ');
245
+ customDomainsSection = `
246
+ # Custom allowed domains (from config)
247
+ for ip in $(dig +short ${domainList}); do
248
+ ipset add allowed_ips $ip 2>/dev/null || true
249
+ done
250
+ `;
251
+ }
252
+ // Generate echo line with custom domains if configured
253
+ const allowedList = customDomains.length > 0
254
+ ? `GitHub, npm, Anthropic API, local network, ${customDomains.join(', ')}`
255
+ : 'GitHub, npm, Anthropic API, local network';
256
+ return `#!/bin/bash
159
257
  # Firewall initialization script for Ralph sandbox
160
258
  # Based on Claude Code devcontainer firewall
161
259
 
@@ -211,7 +309,7 @@ done
211
309
  for ip in $(dig +short api.anthropic.com); do
212
310
  ipset add allowed_ips $ip 2>/dev/null || true
213
311
  done
214
-
312
+ ${customDomainsSection}
215
313
  # Allow host network (for mounted volumes, etc.)
216
314
  HOST_NETWORK=$(ip route | grep default | awk '{print $3}' | head -1)
217
315
  if [ -n "$HOST_NETWORK" ]; then
@@ -232,8 +330,9 @@ iptables -I OUTPUT -p tcp --dport 443 -m set --match-set allowed_ips dst -j ACCE
232
330
  iptables -I OUTPUT -p tcp --dport 80 -m set --match-set allowed_ips dst -j ACCEPT
233
331
 
234
332
  echo "Firewall initialized. Only allowed destinations are accessible."
235
- echo "Allowed: GitHub, npm, Anthropic API, local network"
333
+ echo "Allowed: ${allowedList}"
236
334
  `;
335
+ }
237
336
  function generateDockerCompose(imageName, dockerConfig) {
238
337
  // Build ports section if configured
239
338
  let portsSection = '';
@@ -256,11 +355,15 @@ function generateDockerCompose(imageName, dockerConfig) {
256
355
  const volumesSection = baseVolumes.join('\n');
257
356
  // Build environment section if configured
258
357
  let environmentSection = '';
358
+ const envEntries = [];
359
+ // Add user-configured environment variables
259
360
  if (dockerConfig?.environment && Object.keys(dockerConfig.environment).length > 0) {
260
- const envLines = Object.entries(dockerConfig.environment)
261
- .map(([key, value]) => ` - ${key}=${value}`)
262
- .join('\n');
263
- environmentSection = ` environment:\n${envLines}\n`;
361
+ for (const [key, value] of Object.entries(dockerConfig.environment)) {
362
+ envEntries.push(` - ${key}=${value}`);
363
+ }
364
+ }
365
+ if (envEntries.length > 0) {
366
+ environmentSection = ` environment:\n${envEntries.join('\n')}\n`;
264
367
  }
265
368
  else {
266
369
  // Keep the commented placeholder for users who don't have config
@@ -268,6 +371,32 @@ function generateDockerCompose(imageName, dockerConfig) {
268
371
  # environment:
269
372
  # - ANTHROPIC_API_KEY=\${ANTHROPIC_API_KEY}\n`;
270
373
  }
374
+ // Build command section if configured
375
+ let commandSection = '';
376
+ let streamJsonNote = '';
377
+ if (dockerConfig?.asciinema?.enabled && dockerConfig?.asciinema?.autoRecord) {
378
+ // Wrap with asciinema recording
379
+ const outputDir = dockerConfig.asciinema.outputDir || '.recordings';
380
+ const innerCommand = dockerConfig.startCommand || 'zsh';
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
+ }
391
+ }
392
+ else if (dockerConfig?.startCommand) {
393
+ commandSection = ` command: ${dockerConfig.startCommand}\n`;
394
+ }
395
+ else {
396
+ // Keep the commented placeholder for users who don't have config
397
+ commandSection = ` # Uncomment to enable firewall sandboxing:
398
+ # command: bash -c "sudo /usr/local/bin/init-firewall.sh && zsh"\n`;
399
+ }
271
400
  return `# Ralph CLI Docker Compose
272
401
  # Generated by ralph-cli
273
402
 
@@ -284,9 +413,7 @@ ${environmentSection} working_dir: /workspace
284
413
  tty: true
285
414
  cap_add:
286
415
  - NET_ADMIN # Required for firewall
287
- # Uncomment to enable firewall sandboxing:
288
- # command: bash -c "sudo /usr/local/bin/init-firewall.sh && zsh"
289
-
416
+ ${streamJsonNote}${commandSection}
290
417
  volumes:
291
418
  ${imageName}-history:
292
419
  `;
@@ -297,19 +424,115 @@ dist
297
424
  .git
298
425
  *.log
299
426
  `;
300
- async function generateFiles(ralphDir, language, imageName, force = false, javaVersion, cliProvider, dockerConfig) {
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
+ }
503
+ // Generate .mcp.json content for Claude Code MCP servers
504
+ function generateMcpJson(mcpServers) {
505
+ return JSON.stringify({ mcpServers }, null, 2);
506
+ }
507
+ // Generate skill file content with YAML frontmatter
508
+ function generateSkillFile(skill) {
509
+ const lines = ['---', `description: ${skill.description}`];
510
+ if (skill.userInvocable === false) {
511
+ lines.push('user-invocable: false');
512
+ }
513
+ lines.push('---', '', skill.instructions, '');
514
+ return lines.join('\n');
515
+ }
516
+ async function generateFiles(ralphDir, language, imageName, force = false, javaVersion, cliProvider, dockerConfig, claudeConfig) {
301
517
  const dockerDir = join(ralphDir, DOCKER_DIR);
302
518
  // Create docker directory
303
519
  if (!existsSync(dockerDir)) {
304
520
  mkdirSync(dockerDir, { recursive: true });
305
521
  console.log(`Created ${DOCKER_DIR}/`);
306
522
  }
523
+ const customDomains = dockerConfig?.firewall?.allowedDomains || [];
307
524
  const files = [
308
525
  { name: "Dockerfile", content: generateDockerfile(language, javaVersion, cliProvider, dockerConfig) },
309
- { name: "init-firewall.sh", content: FIREWALL_SCRIPT },
526
+ { name: "init-firewall.sh", content: generateFirewallScript(customDomains) },
310
527
  { name: "docker-compose.yml", content: generateDockerCompose(imageName, dockerConfig) },
311
528
  { name: ".dockerignore", content: DOCKERIGNORE },
312
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
+ }
313
536
  for (const file of files) {
314
537
  const filePath = join(dockerDir, file.name);
315
538
  if (existsSync(filePath) && !force) {
@@ -325,6 +548,58 @@ async function generateFiles(ralphDir, language, imageName, force = false, javaV
325
548
  }
326
549
  console.log(`Created ${DOCKER_DIR}/${file.name}`);
327
550
  }
551
+ // Generate Claude config files at project root
552
+ const projectRoot = process.cwd();
553
+ // Generate .mcp.json if MCP servers are configured
554
+ if (claudeConfig?.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0) {
555
+ const mcpJsonPath = join(projectRoot, '.mcp.json');
556
+ if (existsSync(mcpJsonPath) && !force) {
557
+ const overwrite = await promptConfirm('.mcp.json already exists. Overwrite?');
558
+ if (!overwrite) {
559
+ console.log('Skipped .mcp.json');
560
+ }
561
+ else {
562
+ writeFileSync(mcpJsonPath, generateMcpJson(claudeConfig.mcpServers));
563
+ console.log('Created .mcp.json');
564
+ }
565
+ }
566
+ else {
567
+ writeFileSync(mcpJsonPath, generateMcpJson(claudeConfig.mcpServers));
568
+ console.log('Created .mcp.json');
569
+ }
570
+ }
571
+ // Generate skill files if skills are configured
572
+ if (claudeConfig?.skills && claudeConfig.skills.length > 0) {
573
+ const commandsDir = join(projectRoot, '.claude', 'commands');
574
+ if (!existsSync(commandsDir)) {
575
+ mkdirSync(commandsDir, { recursive: true });
576
+ console.log('Created .claude/commands/');
577
+ }
578
+ for (const skill of claudeConfig.skills) {
579
+ const skillPath = join(commandsDir, `${skill.name}.md`);
580
+ if (existsSync(skillPath) && !force) {
581
+ const overwrite = await promptConfirm(`.claude/commands/${skill.name}.md already exists. Overwrite?`);
582
+ if (!overwrite) {
583
+ console.log(`Skipped .claude/commands/${skill.name}.md`);
584
+ continue;
585
+ }
586
+ }
587
+ writeFileSync(skillPath, generateSkillFile(skill));
588
+ console.log(`Created .claude/commands/${skill.name}.md`);
589
+ }
590
+ }
591
+ // Save config hash for change detection
592
+ const configForHash = {
593
+ language,
594
+ checkCommand: '',
595
+ testCommand: '',
596
+ javaVersion,
597
+ cliProvider,
598
+ docker: dockerConfig,
599
+ claude: claudeConfig,
600
+ };
601
+ const hash = computeConfigHash(configForHash);
602
+ saveConfigHash(dockerDir, hash);
328
603
  }
329
604
  async function buildImage(ralphDir) {
330
605
  const dockerDir = join(ralphDir, DOCKER_DIR);
@@ -332,9 +607,17 @@ async function buildImage(ralphDir) {
332
607
  console.error("Dockerfile not found. Run 'ralph docker' first.");
333
608
  process.exit(1);
334
609
  }
335
- console.log("Building Docker image...\n");
336
- // Get image name for compose project name
610
+ // Get config and check for changes
337
611
  const config = loadConfig();
612
+ // Check if config has changed since last docker init
613
+ if (hasConfigChanged(ralphDir, config)) {
614
+ const regenerate = await promptConfirm("Config has changed since last docker init. Regenerate Docker files?");
615
+ if (regenerate) {
616
+ 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);
617
+ console.log("");
618
+ }
619
+ }
620
+ console.log("Building Docker image...\n");
338
621
  const imageName = config.imageName || `ralph-${basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
339
622
  return new Promise((resolve, reject) => {
340
623
  // Use --no-cache and --pull to ensure we always get the latest CLI versions
@@ -398,15 +681,34 @@ function getCliProviderConfig(cliProvider) {
398
681
  modelConfig: provider.modelConfig,
399
682
  };
400
683
  }
401
- async function runContainer(ralphDir, imageName, language, javaVersion, cliProvider, dockerConfig) {
684
+ async function runContainer(ralphDir, imageName, language, javaVersion, cliProvider, dockerConfig, claudeConfig) {
402
685
  const dockerDir = join(ralphDir, DOCKER_DIR);
403
686
  const dockerfileExists = existsSync(join(dockerDir, "Dockerfile"));
404
687
  const hasImage = await imageExists(imageName);
688
+ // Check if config has changed since last docker init
689
+ if (dockerfileExists) {
690
+ const configForHash = {
691
+ language,
692
+ checkCommand: '',
693
+ testCommand: '',
694
+ javaVersion,
695
+ cliProvider,
696
+ docker: dockerConfig,
697
+ claude: claudeConfig,
698
+ };
699
+ if (hasConfigChanged(ralphDir, configForHash)) {
700
+ const regenerate = await promptConfirm("Config has changed since last docker init. Regenerate Docker files?");
701
+ if (regenerate) {
702
+ await generateFiles(ralphDir, language, imageName, true, javaVersion, cliProvider, dockerConfig, claudeConfig);
703
+ console.log("");
704
+ }
705
+ }
706
+ }
405
707
  // Auto-init and build if docker folder or image doesn't exist
406
708
  if (!dockerfileExists || !hasImage) {
407
709
  if (!dockerfileExists) {
408
710
  console.log("Docker folder not found. Initializing docker setup...\n");
409
- await generateFiles(ralphDir, language, imageName, true, javaVersion, cliProvider, dockerConfig);
711
+ await generateFiles(ralphDir, language, imageName, true, javaVersion, cliProvider, dockerConfig, claudeConfig);
410
712
  console.log("");
411
713
  }
412
714
  if (!hasImage) {
@@ -654,7 +956,7 @@ export async function dockerInit(silent = false) {
654
956
  console.log(`CLI provider: ${config.cliProvider}`);
655
957
  }
656
958
  console.log(`Image name: ${imageName}\n`);
657
- await generateFiles(ralphDir, config.language, imageName, true, config.javaVersion, config.cliProvider, config.docker);
959
+ await generateFiles(ralphDir, config.language, imageName, true, config.javaVersion, config.cliProvider, config.docker, config.claude);
658
960
  if (!silent) {
659
961
  console.log(`
660
962
  Docker files generated in .ralph/docker/
@@ -681,6 +983,7 @@ USAGE:
681
983
  ralph docker init -y Generate files, overwrite without prompting
682
984
  ralph docker build Build image (fetches latest CLI versions)
683
985
  ralph docker build --clean Clean existing image and rebuild from scratch
986
+ (alias: --no-cache)
684
987
  ralph docker run Run container (auto-init and build if needed)
685
988
  ralph docker clean Remove Docker image and associated resources
686
989
  ralph docker help Show this help message
@@ -735,14 +1038,15 @@ INSTALLING PACKAGES (works with Docker & Podman):
735
1038
  switch (subcommand) {
736
1039
  case "build":
737
1040
  // Handle build --clean combination: clean first, then build
738
- if (hasFlag("--clean")) {
1041
+ // Also support --no-cache as alias for --clean
1042
+ if (hasFlag("--clean") || hasFlag("--no-cache") || hasFlag("-no-cache")) {
739
1043
  await cleanImage(imageName, ralphDir);
740
1044
  console.log(""); // Add spacing between clean and build output
741
1045
  }
742
1046
  await buildImage(ralphDir);
743
1047
  break;
744
1048
  case "run":
745
- await runContainer(ralphDir, imageName, config.language, config.javaVersion, config.cliProvider, config.docker);
1049
+ await runContainer(ralphDir, imageName, config.language, config.javaVersion, config.cliProvider, config.docker, config.claude);
746
1050
  break;
747
1051
  case "clean":
748
1052
  await cleanImage(imageName, ralphDir);
@@ -761,7 +1065,7 @@ INSTALLING PACKAGES (works with Docker & Podman):
761
1065
  console.log(`CLI provider: ${config.cliProvider}`);
762
1066
  }
763
1067
  console.log(`Image name: ${imageName}\n`);
764
- await generateFiles(ralphDir, config.language, imageName, force, config.javaVersion, config.cliProvider, config.docker);
1068
+ await generateFiles(ralphDir, config.language, imageName, force, config.javaVersion, config.cliProvider, config.docker, config.claude);
765
1069
  console.log(`
766
1070
  Docker files generated in .ralph/docker/
767
1071
 
@@ -56,6 +56,7 @@ DOCKER SUBCOMMANDS:
56
56
 
57
57
  EXAMPLES:
58
58
  ralph init # Initialize ralph (interactive CLI, language, tech selection)
59
+ ralph init -y # Initialize with defaults (Claude + Node.js, no prompts)
59
60
  ralph once # Run single iteration
60
61
  ralph run # Run until all tasks complete (default)
61
62
  ralph run 5 # Run exactly 5 iterations
@@ -1 +1 @@
1
- export declare function init(_args: string[]): Promise<void>;
1
+ export declare function init(args: string[]): Promise<void>;