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 +29 -66
- package/dist/commands/docker.js +329 -25
- package/dist/commands/help.js +1 -0
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +156 -72
- package/dist/commands/once.js +251 -13
- package/dist/commands/run.js +233 -5
- package/dist/config/languages.json +1 -1
- package/dist/config/skills.json +12 -0
- package/dist/templates/prompts.d.ts +11 -0
- package/dist/templates/prompts.js +17 -0
- package/dist/utils/config.d.ts +35 -0
- package/dist/utils/prompt.d.ts +1 -1
- package/dist/utils/prompt.js +8 -2
- package/docs/DEVELOPMENT.md +161 -0
- package/docs/DOCKER.md +225 -0
- package/docs/HOW-TO-WRITE-PRDs.md +4 -2
- package/docs/PRD-GENERATOR.md +2 -1
- package/docs/SECURITY.md +78 -0
- package/docs/run-state-machine.md +73 -64
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,12 +85,28 @@ After running `ralph init`, you'll have:
|
|
|
85
85
|
|
|
86
86
|
### Supported Languages
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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: `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/commands/docker.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
261
|
-
.
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/commands/help.js
CHANGED
|
@@ -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
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function init(
|
|
1
|
+
export declare function init(args: string[]): Promise<void>;
|