ralph-cli-sandboxed 0.7.0 → 0.7.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.
package/README.md CHANGED
@@ -518,9 +518,10 @@ ralph docker run
518
518
 
519
519
  Features:
520
520
  - Based on [Claude Code devcontainer](https://github.com/anthropics/claude-code/tree/main/.devcontainer)
521
- - Network sandboxing (firewall allows only GitHub, npm, Anthropic API)
521
+ - Network sandboxing (firewall allows only GitHub, npm, Anthropic API, plus language-specific domains — e.g., `deno.land`, `jsr.io`, `esm.sh` for Deno projects)
522
522
  - Your `~/.claude` credentials mounted automatically (Pro/Max OAuth)
523
523
  - Language-specific tooling pre-installed
524
+ - Language-specific Claude Code hooks (e.g., Deno projects auto-install a `PreToolUse` hook that blocks `npm`/`npx`/`yarn`/`pnpm` commands)
524
525
 
525
526
  See [docs/DOCKER.md](docs/DOCKER.md) for detailed Docker configuration, customization, and troubleshooting.
526
527
 
@@ -554,6 +555,25 @@ Responders handle messages and can answer questions about your codebase:
554
555
 
555
556
  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.
556
557
 
558
+ ## MCP Server
559
+
560
+ Ralph provides an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that exposes PRD management as tools for AI assistants. This lets MCP-compatible clients like Claude Code list, add, toggle, and check the status of PRD entries directly through tool calls.
561
+
562
+ Add to your `.mcp.json`:
563
+
564
+ ```json
565
+ {
566
+ "mcpServers": {
567
+ "ralph": {
568
+ "command": "ralph-mcp",
569
+ "args": []
570
+ }
571
+ }
572
+ }
573
+ ```
574
+
575
+ See [docs/MCP.md](docs/MCP.md) for available tools, parameters, return values, and example conversations.
576
+
557
577
  ## How It Works
558
578
 
559
579
  1. **Read PRD**: Claude reads your requirements from `prd.json`
@@ -657,22 +657,70 @@ else
657
657
  fi
658
658
  `;
659
659
  }
660
+ // Generate block-npm-commands.sh hook script for Deno projects
661
+ function generateBlockNpmCommandsHook() {
662
+ return `#!/bin/bash
663
+ # Claude Code PreToolUse hook: blocks npm/npx/yarn/pnpm commands in Deno projects
664
+ # Generated by ralph-cli
665
+ #
666
+ # This project uses Deno. npm/npx/yarn/pnpm should not be used for
667
+ # package management or script execution. Use deno commands instead.
668
+
669
+ set -e
670
+
671
+ # Read the command from hook JSON input on stdin
672
+ INPUT=$(cat)
673
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
674
+
675
+ if [ -z "$COMMAND" ]; then
676
+ exit 0
677
+ fi
678
+
679
+ # Match package-manager invocations at shell-command boundaries.
680
+ # Examples matched: npm test, cd app && pnpm install
681
+ # Examples ignored: grep "npm test" README.md
682
+ if echo "$COMMAND" | grep -qE '(^|[;&|][&|]?[[:space:]]*)(npm|npx|yarn|pnpm)([[:space:]]|$)'; then
683
+ jq -n --arg reason "Blocked: This is a Deno project. Use 'deno' commands instead of npm/npx/yarn/pnpm. Examples: 'deno task' instead of 'npm run', 'deno test' instead of 'npm test', 'deno add' or import maps instead of 'npm install'." '{
684
+ hookSpecificOutput: {
685
+ hookEventName: "PreToolUse",
686
+ permissionDecision: "deny",
687
+ permissionDecisionReason: $reason
688
+ }
689
+ }'
690
+ else
691
+ exit 0
692
+ fi
693
+ `;
694
+ }
660
695
  // Generate .claude/settings.json with hooks configuration
661
- function generateClaudeSettings() {
662
- const settings = {
663
- hooks: {
664
- PreToolUse: [
696
+ function generateClaudeSettings(language) {
697
+ const hooks = [
698
+ {
699
+ matcher: "Bash",
700
+ hooks: [
665
701
  {
666
- matcher: "Bash",
667
- hooks: [
668
- {
669
- type: "command",
670
- command: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/block-dangerous-commands.sh',
671
- },
672
- ],
702
+ type: "command",
703
+ command: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/block-dangerous-commands.sh',
673
704
  },
674
705
  ],
675
706
  },
707
+ ];
708
+ // Add Deno-specific hook to block npm commands
709
+ if (language === "deno") {
710
+ hooks.push({
711
+ matcher: "Bash",
712
+ hooks: [
713
+ {
714
+ type: "command",
715
+ command: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/block-npm-commands.sh',
716
+ },
717
+ ],
718
+ });
719
+ }
720
+ const settings = {
721
+ hooks: {
722
+ PreToolUse: hooks,
723
+ },
676
724
  };
677
725
  return JSON.stringify(settings, null, 2) + "\n";
678
726
  }
@@ -683,13 +731,17 @@ async function generateFiles(ralphDir, language, imageName, force = false, javaV
683
731
  mkdirSync(dockerDir, { recursive: true });
684
732
  console.log(`Created ${DOCKER_DIR}/`);
685
733
  }
734
+ // Merge custom firewall domains with language-specific domains
686
735
  const customDomains = dockerConfig?.firewall?.allowedDomains || [];
736
+ const languagesJson = getLanguagesJson();
737
+ const langFirewallDomains = languagesJson.languages[language]?.docker?.firewallDomains || [];
738
+ const allFirewallDomains = [...new Set([...customDomains, ...langFirewallDomains])];
687
739
  const files = [
688
740
  {
689
741
  name: "Dockerfile",
690
742
  content: generateDockerfile(language, javaVersion, cliProvider, dockerConfig, cliModel),
691
743
  },
692
- { name: "init-firewall.sh", content: generateFirewallScript(customDomains) },
744
+ { name: "init-firewall.sh", content: generateFirewallScript(allFirewallDomains) },
693
745
  { name: "docker-compose.yml", content: generateDockerCompose(imageName, dockerConfig) },
694
746
  { name: ".dockerignore", content: DOCKERIGNORE },
695
747
  ];
@@ -794,12 +846,32 @@ async function generateFiles(ralphDir, language, imageName, force = false, javaV
794
846
  chmodSync(hookScriptPath, 0o755);
795
847
  console.log("Created .claude/hooks/block-dangerous-commands.sh");
796
848
  }
849
+ // Generate Deno-specific hook to block npm commands
850
+ if (language === "deno") {
851
+ const npmHookPath = join(hooksDir, "block-npm-commands.sh");
852
+ if (existsSync(npmHookPath) && !force) {
853
+ const overwrite = await promptConfirm(".claude/hooks/block-npm-commands.sh already exists. Overwrite?");
854
+ if (overwrite) {
855
+ writeFileSync(npmHookPath, generateBlockNpmCommandsHook());
856
+ chmodSync(npmHookPath, 0o755);
857
+ console.log("Created .claude/hooks/block-npm-commands.sh");
858
+ }
859
+ else {
860
+ console.log("Skipped .claude/hooks/block-npm-commands.sh");
861
+ }
862
+ }
863
+ else {
864
+ writeFileSync(npmHookPath, generateBlockNpmCommandsHook());
865
+ chmodSync(npmHookPath, 0o755);
866
+ console.log("Created .claude/hooks/block-npm-commands.sh");
867
+ }
868
+ }
797
869
  // Generate .claude/settings.json with hooks configuration
798
870
  const settingsPath = join(projectRoot, ".claude", "settings.json");
799
871
  if (existsSync(settingsPath) && !force) {
800
872
  const overwrite = await promptConfirm(".claude/settings.json already exists. Overwrite?");
801
873
  if (overwrite) {
802
- writeFileSync(settingsPath, generateClaudeSettings());
874
+ writeFileSync(settingsPath, generateClaudeSettings(language));
803
875
  console.log("Created .claude/settings.json");
804
876
  }
805
877
  else {
@@ -807,7 +879,7 @@ async function generateFiles(ralphDir, language, imageName, force = false, javaV
807
879
  }
808
880
  }
809
881
  else {
810
- writeFileSync(settingsPath, generateClaudeSettings());
882
+ writeFileSync(settingsPath, generateClaudeSettings(language));
811
883
  console.log("Created .claude/settings.json");
812
884
  }
813
885
  // Save config hash for change detection
@@ -1,4 +1,4 @@
1
- import { spawn, execSync } from "child_process";
1
+ import { spawn, execSync, execFileSync } from "child_process";
2
2
  import { existsSync, readFileSync, writeFileSync, unlinkSync, appendFileSync, mkdirSync, copyFileSync, } from "fs";
3
3
  import { extname, join } from "path";
4
4
  import { checkFilesExist, loadConfig, loadPrompt, getPaths, getCliConfig, requireContainer, saveBranchState, loadBranchState, clearBranchState, getProjectName, } from "../utils/config.js";
@@ -62,10 +62,14 @@ function ensureWorktree(branch, worktreesBase) {
62
62
  return worktreePath;
63
63
  }
64
64
  console.log(`\x1b[90m[ralph] Creating worktree for branch "${branch}" at ${worktreePath}\x1b[0m`);
65
+ // Validate branch name to prevent command injection
66
+ if (!/^[a-zA-Z0-9_\-./]+$/.test(branch)) {
67
+ throw new Error(`Invalid branch name "${branch}": contains disallowed characters`);
68
+ }
65
69
  // Check if the branch already exists
66
70
  let branchExists = false;
67
71
  try {
68
- execSync(`git rev-parse --verify "${branch}"`, { stdio: "pipe" });
72
+ execFileSync("git", ["rev-parse", "--verify", branch], { stdio: "pipe" });
69
73
  branchExists = true;
70
74
  }
71
75
  catch {
@@ -73,11 +77,11 @@ function ensureWorktree(branch, worktreesBase) {
73
77
  }
74
78
  try {
75
79
  if (branchExists) {
76
- execSync(`git worktree add "${worktreePath}" "${branch}"`, { stdio: "pipe" });
80
+ execFileSync("git", ["worktree", "add", worktreePath, branch], { stdio: "pipe" });
77
81
  }
78
82
  else {
79
83
  // Create new branch from current HEAD
80
- execSync(`git worktree add -b "${branch}" "${worktreePath}"`, { stdio: "pipe" });
84
+ execFileSync("git", ["worktree", "add", "-b", branch, worktreePath], { stdio: "pipe" });
81
85
  }
82
86
  }
83
87
  catch (err) {
@@ -376,7 +376,8 @@
376
376
  "checkCommand": "deno check **/*.ts",
377
377
  "testCommand": "deno test",
378
378
  "docker": {
379
- "install": "# Install Deno\nRUN curl -fsSL https://deno.land/install.sh | sh\nENV DENO_INSTALL=\"/root/.deno\"\nENV PATH=\"${DENO_INSTALL}/bin:${PATH}\""
379
+ "install": "# Install Deno (as node user so it installs to /home/node/.deno)\nRUN su - node -c 'curl -fsSL https://deno.land/install.sh | sh'\nENV DENO_INSTALL=\"/home/node/.deno\"\nENV PATH=\"${DENO_INSTALL}/bin:${PATH}\"",
380
+ "firewallDomains": ["deno.land", "jsr.io", "esm.sh"]
380
381
  },
381
382
  "technologies": [
382
383
  { "name": "Fresh", "description": "Next-gen web framework for Deno" },
@@ -47,6 +47,16 @@
47
47
  "trigger": "@code",
48
48
  "timeout": 300000,
49
49
  "maxLength": 2000
50
+ },
51
+ "audit": {
52
+ "name": "Security Auditor",
53
+ "description": "Claude Code responder for running npm audit and fixing vulnerabilities",
54
+ "type": "claude-code",
55
+ "trigger": "@audit",
56
+ "systemPrompt": "You are a security auditor for the {{project}} project. Your task is to:\n\n1. Run `npm audit` to identify security vulnerabilities\n2. Analyze the severity and impact of each vulnerability\n3. Run `npm audit fix` to apply safe, non-breaking fixes\n4. For remaining issues that require `--force`, evaluate whether the breaking changes are acceptable and apply them if safe\n5. For vulnerabilities that cannot be auto-fixed, investigate alternatives:\n - Check if a newer major version of the package resolves the issue\n - Look for alternative packages without vulnerabilities\n - Add overrides in package.json if the vulnerability is not exploitable in context\n6. Report a summary of what was fixed and what remains\n\nAlways run `npm audit` again after fixes to verify the final state. Prefer safe fixes over forced ones. Do not remove packages without confirming they are unused.\n\nIMPORTANT: After all fixes are applied, run `npm audit` one final time to verify the result. Include the full audit output in your response so the success pattern can be validated.",
57
+ "timeout": 300000,
58
+ "maxLength": 3000,
59
+ "successPattern": "found 0 vulnerabilities"
50
60
  }
51
61
  },
52
62
  "bundles": {
@@ -8,6 +8,14 @@
8
8
  "userInvocable": false
9
9
  }
10
10
  ],
11
+ "deno": [
12
+ {
13
+ "name": "deno-no-npm",
14
+ "description": "Prevents using npm/npx, yarn, or pnpm in Deno projects — use deno commands instead",
15
+ "instructions": "IMPORTANT: This project uses Deno as its runtime. Do NOT use npm, npx, yarn, or pnpm for package management or script execution.\n\nAVOID these commands:\n- `npm install`, `npm run`, `npm test`, `npm start`\n- `npx <package>`\n- `yarn add`, `yarn run`\n- `pnpm install`, `pnpm run`\n- Do NOT create or modify `package.json` for dependency management\n\nINSTEAD, use Deno's built-in tools:\n- Dependencies: Use `import` with URLs or `deno.json` imports map\n - `import { serve } from \"https://deno.land/std/http/server.ts\";`\n - Or add to `deno.json`: `{ \"imports\": { \"std/\": \"https://deno.land/std/\" } }`\n - Use `jsr:` imports for JSR packages: `import { z } from \"jsr:@zod/zod\";`\n- Run scripts: `deno run`, `deno task`\n- Type checking: `deno check`\n- Testing: `deno test`\n- Formatting: `deno fmt`\n- Linting: `deno lint`\n- Install tools: `deno install`\n- Compile: `deno compile`\n\nDeno uses `deno.json` (or `deno.jsonc`) for project configuration, NOT `package.json`.\n\nIf you need an npm package that has no Deno equivalent, use the `npm:` specifier:\n `import express from \"npm:express\";`\nThis uses Deno's built-in npm compatibility — no need to run `npm install`.",
16
+ "userInvocable": false
17
+ }
18
+ ],
11
19
  "swift": [
12
20
  {
13
21
  "name": "swift-main-naming",
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
5
+ import { dirname, extname, join } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { z } from "zod";
8
+ import YAML from "yaml";
9
+ import { getRalphDir, getPrdFiles } from "./utils/config.js";
10
+ import { DEFAULT_PRD_YAML } from "./templates/prompts.js";
11
+ import { VALID_CATEGORIES } from "./utils/prd-validator.js";
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ const CATEGORIES = VALID_CATEGORIES;
15
+ // Structured error codes for MCP tool error responses
16
+ const ErrorCode = {
17
+ PRD_NOT_FOUND: "PRD_NOT_FOUND",
18
+ PRD_PARSE_ERROR: "PRD_PARSE_ERROR",
19
+ PRD_WRITE_ERROR: "PRD_WRITE_ERROR",
20
+ INVALID_INDEX: "INVALID_INDEX",
21
+ INTERNAL_ERROR: "INTERNAL_ERROR",
22
+ };
23
+ function classifyError(err) {
24
+ const message = err instanceof Error ? err.message : String(err);
25
+ if (message.includes("No PRD file found") || message.includes("ENOENT")) {
26
+ return { code: ErrorCode.PRD_NOT_FOUND, message };
27
+ }
28
+ if (message.includes("does not contain an array") ||
29
+ message.includes("JSON") ||
30
+ message.includes("YAML")) {
31
+ return { code: ErrorCode.PRD_PARSE_ERROR, message };
32
+ }
33
+ if (message.includes("EACCES") || message.includes("EPERM")) {
34
+ return { code: ErrorCode.PRD_WRITE_ERROR, message };
35
+ }
36
+ return { code: ErrorCode.INTERNAL_ERROR, message };
37
+ }
38
+ function errorResponse(code, message) {
39
+ return {
40
+ content: [
41
+ {
42
+ type: "text",
43
+ text: JSON.stringify({ code, message }),
44
+ },
45
+ ],
46
+ isError: true,
47
+ };
48
+ }
49
+ const PRD_FILE_JSON = "prd.json";
50
+ /**
51
+ * Saves PRD entries to disk, auto-detecting format from file extension.
52
+ */
53
+ function savePrd(entries) {
54
+ const prdFiles = getPrdFiles();
55
+ const path = prdFiles.primary ?? join(getRalphDir(), PRD_FILE_JSON);
56
+ const ext = extname(path).toLowerCase();
57
+ try {
58
+ if (ext === ".yaml" || ext === ".yml") {
59
+ writeFileSync(path, YAML.stringify(entries));
60
+ }
61
+ else {
62
+ writeFileSync(path, JSON.stringify(entries, null, 2) + "\n");
63
+ }
64
+ // One-time migration: if a secondary PRD file exists, remove it now that
65
+ // the merged entries have been written to the primary file.
66
+ if (prdFiles.secondary && existsSync(prdFiles.secondary)) {
67
+ unlinkSync(prdFiles.secondary);
68
+ }
69
+ }
70
+ catch (err) {
71
+ const msg = err instanceof Error ? err.message : String(err);
72
+ throw new Error(`Failed to save PRD file at ${path}: ${msg}`);
73
+ }
74
+ }
75
+ function getVersion() {
76
+ try {
77
+ const packagePath = join(__dirname, "..", "package.json");
78
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf-8"));
79
+ return packageJson.version ?? "unknown";
80
+ }
81
+ catch {
82
+ return "unknown";
83
+ }
84
+ }
85
+ /**
86
+ * Parses a PRD file based on its extension (MCP-safe version that throws instead of process.exit).
87
+ */
88
+ function parsePrdFile(path) {
89
+ const content = readFileSync(path, "utf-8");
90
+ const ext = extname(path).toLowerCase();
91
+ let result;
92
+ if (ext === ".yaml" || ext === ".yml") {
93
+ result = YAML.parse(content);
94
+ }
95
+ else {
96
+ result = JSON.parse(content);
97
+ }
98
+ if (result == null)
99
+ return [];
100
+ if (!Array.isArray(result)) {
101
+ throw new Error(`${path} does not contain an array`);
102
+ }
103
+ return result.map((entry, index) => {
104
+ if (typeof entry !== "object" || entry === null) {
105
+ throw new Error(`${path}[${index}]: entry must be an object`);
106
+ }
107
+ const obj = entry;
108
+ if (typeof obj.category !== "string" || !CATEGORIES.includes(obj.category)) {
109
+ throw new Error(`${path}[${index}]: missing or invalid "category" (expected one of: ${CATEGORIES.join(", ")})`);
110
+ }
111
+ if (typeof obj.description !== "string" || obj.description.trim().length === 0) {
112
+ throw new Error(`${path}[${index}]: missing or invalid "description" (expected string)`);
113
+ }
114
+ if (!Array.isArray(obj.steps) || !obj.steps.every((s) => typeof s === "string")) {
115
+ throw new Error(`${path}[${index}]: missing or invalid "steps" (expected string array)`);
116
+ }
117
+ if (typeof obj.passes !== "boolean") {
118
+ throw new Error(`${path}[${index}]: missing or invalid "passes" (expected boolean)`);
119
+ }
120
+ if (obj.branch !== undefined && typeof obj.branch !== "string") {
121
+ throw new Error(`${path}[${index}]: invalid "branch" (expected string)`);
122
+ }
123
+ return {
124
+ category: obj.category,
125
+ description: obj.description,
126
+ steps: obj.steps,
127
+ passes: obj.passes,
128
+ ...(obj.branch !== undefined && { branch: obj.branch }),
129
+ };
130
+ });
131
+ }
132
+ /**
133
+ * Loads PRD entries (MCP-safe version that throws instead of process.exit).
134
+ */
135
+ function loadPrd() {
136
+ const prdFiles = getPrdFiles();
137
+ if (prdFiles.none) {
138
+ const ralphDir = getRalphDir();
139
+ const prdPath = join(ralphDir, "prd.yaml");
140
+ try {
141
+ if (!existsSync(ralphDir)) {
142
+ mkdirSync(ralphDir, { recursive: true });
143
+ }
144
+ writeFileSync(prdPath, DEFAULT_PRD_YAML);
145
+ }
146
+ catch (err) {
147
+ const msg = err instanceof Error ? err.message : String(err);
148
+ throw new Error(`Failed to save PRD file at ${prdPath}: ${msg}`);
149
+ }
150
+ return parsePrdFile(prdPath);
151
+ }
152
+ if (!prdFiles.primary) {
153
+ throw new Error("No PRD file found. Run `ralph init` to create one.");
154
+ }
155
+ const primary = parsePrdFile(prdFiles.primary);
156
+ if (prdFiles.both && prdFiles.secondary) {
157
+ const secondary = parsePrdFile(prdFiles.secondary);
158
+ return [...primary, ...secondary];
159
+ }
160
+ return primary;
161
+ }
162
+ const server = new McpServer({
163
+ name: "ralph-mcp",
164
+ version: getVersion(),
165
+ });
166
+ // ralph_prd_list tool
167
+ server.tool("ralph_prd_list", "List PRD entries with optional category and status filters", {
168
+ category: z.enum(CATEGORIES).optional().describe("Filter by category"),
169
+ status: z
170
+ .enum(["all", "passing", "failing"])
171
+ .optional()
172
+ .describe("Filter by status: all (default), passing, or failing"),
173
+ }, async ({ category, status }) => {
174
+ try {
175
+ const prd = loadPrd();
176
+ let filtered = prd.map((entry, i) => ({ ...entry, index: i + 1 }));
177
+ if (category) {
178
+ filtered = filtered.filter((entry) => entry.category === category);
179
+ }
180
+ if (status === "passing") {
181
+ filtered = filtered.filter((entry) => entry.passes);
182
+ }
183
+ else if (status === "failing") {
184
+ filtered = filtered.filter((entry) => !entry.passes);
185
+ }
186
+ return {
187
+ content: [
188
+ {
189
+ type: "text",
190
+ text: JSON.stringify(filtered, null, 2),
191
+ },
192
+ ],
193
+ };
194
+ }
195
+ catch (err) {
196
+ const { code, message } = classifyError(err);
197
+ return errorResponse(code, message);
198
+ }
199
+ });
200
+ // ralph_prd_add tool
201
+ server.tool("ralph_prd_add", "Add a new PRD entry with category, description, and verification steps", {
202
+ category: z.enum(CATEGORIES).describe("Category for the new entry"),
203
+ description: z.string().min(1).describe("Description of the requirement"),
204
+ steps: z
205
+ .array(z.string().min(1))
206
+ .min(1)
207
+ .describe("Verification steps to check if requirement is met"),
208
+ branch: z.string().optional().describe("Git branch associated with this entry"),
209
+ }, async ({ category, description, steps, branch }) => {
210
+ try {
211
+ const entry = {
212
+ category,
213
+ description,
214
+ steps,
215
+ passes: false,
216
+ };
217
+ if (branch) {
218
+ entry.branch = branch;
219
+ }
220
+ const prd = loadPrd();
221
+ prd.push(entry);
222
+ savePrd(prd);
223
+ return {
224
+ content: [
225
+ {
226
+ type: "text",
227
+ text: JSON.stringify({
228
+ message: `Added entry #${prd.length}: "${description}"`,
229
+ entry: { ...entry, index: prd.length },
230
+ }, null, 2),
231
+ },
232
+ ],
233
+ };
234
+ }
235
+ catch (err) {
236
+ const { code, message } = classifyError(err);
237
+ return errorResponse(code, message);
238
+ }
239
+ });
240
+ // ralph_prd_status tool
241
+ server.tool("ralph_prd_status", "Get PRD completion status with counts, percentage, per-category breakdown, and remaining items", {}, async () => {
242
+ try {
243
+ const prd = loadPrd();
244
+ if (prd.length === 0) {
245
+ return {
246
+ content: [
247
+ {
248
+ type: "text",
249
+ text: JSON.stringify({ passing: 0, total: 0, percentage: 0, categories: {}, remaining: [] }, null, 2),
250
+ },
251
+ ],
252
+ };
253
+ }
254
+ const passing = prd.filter((e) => e.passes).length;
255
+ const total = prd.length;
256
+ const percentage = Math.round((passing / total) * 100);
257
+ const categories = {};
258
+ prd.forEach((entry) => {
259
+ if (!categories[entry.category]) {
260
+ categories[entry.category] = { passing: 0, total: 0 };
261
+ }
262
+ categories[entry.category].total++;
263
+ if (entry.passes)
264
+ categories[entry.category].passing++;
265
+ });
266
+ const remaining = prd.reduce((acc, entry, i) => {
267
+ if (!entry.passes) {
268
+ acc.push({ index: i + 1, category: entry.category, description: entry.description });
269
+ }
270
+ return acc;
271
+ }, []);
272
+ return {
273
+ content: [
274
+ {
275
+ type: "text",
276
+ text: JSON.stringify({ passing, total, percentage, categories, remaining }, null, 2),
277
+ },
278
+ ],
279
+ };
280
+ }
281
+ catch (err) {
282
+ const { code, message } = classifyError(err);
283
+ return errorResponse(code, message);
284
+ }
285
+ });
286
+ // ralph_prd_toggle tool
287
+ server.tool("ralph_prd_toggle", "Toggle completion status (passes) for PRD entries by 1-based index", {
288
+ indices: z
289
+ .array(z.number().int().min(1))
290
+ .min(1)
291
+ .describe("1-based indices of PRD entries to toggle"),
292
+ }, async ({ indices }) => {
293
+ try {
294
+ const prd = loadPrd();
295
+ // Validate all indices are in range
296
+ for (const index of indices) {
297
+ if (index > prd.length) {
298
+ return errorResponse(ErrorCode.INVALID_INDEX, `Invalid entry number: ${index}. Must be 1-${prd.length}`);
299
+ }
300
+ }
301
+ // Deduplicate and sort
302
+ const uniqueIndices = [...new Set(indices)].sort((a, b) => a - b);
303
+ const toggled = uniqueIndices.map((index) => {
304
+ const entry = prd[index - 1];
305
+ entry.passes = !entry.passes;
306
+ return {
307
+ index,
308
+ description: entry.description,
309
+ passes: entry.passes,
310
+ };
311
+ });
312
+ savePrd(prd);
313
+ return {
314
+ content: [
315
+ {
316
+ type: "text",
317
+ text: JSON.stringify({ message: `Toggled ${toggled.length} entry/entries`, toggled }, null, 2),
318
+ },
319
+ ],
320
+ };
321
+ }
322
+ catch (err) {
323
+ const { code, message } = classifyError(err);
324
+ return errorResponse(code, message);
325
+ }
326
+ });
327
+ function isConnectionError(error) {
328
+ const message = error instanceof Error ? error.message : String(error);
329
+ return (message.includes("EPIPE") ||
330
+ message.includes("ECONNRESET") ||
331
+ message.includes("ECONNREFUSED") ||
332
+ message.includes("ERR_USE_AFTER_CLOSE") ||
333
+ message.includes("write after end") ||
334
+ message.includes("This socket has been ended") ||
335
+ message.includes("transport") ||
336
+ message.includes("broken pipe"));
337
+ }
338
+ async function main() {
339
+ try {
340
+ const transport = new StdioServerTransport();
341
+ await server.connect(transport);
342
+ }
343
+ catch (error) {
344
+ if (isConnectionError(error)) {
345
+ console.error("MCP connection error: transport closed or unavailable.");
346
+ process.exit(0);
347
+ }
348
+ console.error("Server error:", error);
349
+ process.exit(1);
350
+ }
351
+ }
352
+ process.on("uncaughtException", (error) => {
353
+ if (isConnectionError(error)) {
354
+ process.exit(0);
355
+ }
356
+ console.error("Uncaught exception:", error);
357
+ process.exit(1);
358
+ });
359
+ process.on("unhandledRejection", (reason, promise) => {
360
+ if (isConnectionError(reason)) {
361
+ process.exit(0);
362
+ }
363
+ console.error("Unhandled rejection at:", promise, "reason:", reason);
364
+ process.exit(1);
365
+ });
366
+ main();
@@ -40,8 +40,15 @@ export async function executeClaudeCodeResponder(prompt, responderConfig, option
40
40
  let killed = false;
41
41
  let lastProgressSent = 0;
42
42
  let progressTimer = null;
43
+ // Build effective prompt: prepend systemPrompt if configured
44
+ let effectivePrompt = prompt;
45
+ if (responderConfig.systemPrompt) {
46
+ effectivePrompt = prompt
47
+ ? `${responderConfig.systemPrompt}\n\nUser request: ${prompt}`
48
+ : responderConfig.systemPrompt;
49
+ }
43
50
  // Build the command arguments
44
- const args = ["-p", prompt, "--dangerously-skip-permissions", "--print"];
51
+ const args = ["-p", effectivePrompt, "--dangerously-skip-permissions", "--print"];
45
52
  // Spawn claude process
46
53
  let proc;
47
54
  try {
@@ -115,6 +122,21 @@ export async function executeClaudeCodeResponder(prompt, responderConfig, option
115
122
  if (code === 0 || code === null) {
116
123
  // Success - format and truncate output
117
124
  const output = formatClaudeCodeOutput(stdout);
125
+ // Check successPattern if configured - output must match for run to succeed
126
+ if (responderConfig.successPattern) {
127
+ const pattern = new RegExp(responderConfig.successPattern, "i");
128
+ if (!pattern.test(output)) {
129
+ const { text, truncated, originalLength } = truncateResponse(output, maxLength);
130
+ resolve({
131
+ success: false,
132
+ response: text,
133
+ error: `Output did not match required success pattern: ${responderConfig.successPattern}`,
134
+ truncated,
135
+ originalLength: truncated ? originalLength : undefined,
136
+ });
137
+ return;
138
+ }
139
+ }
118
140
  const { text, truncated, originalLength } = truncateResponse(output, maxLength);
119
141
  resolve({
120
142
  success: true,
@@ -155,7 +177,7 @@ export async function executeClaudeCodeResponder(prompt, responderConfig, option
155
177
  */
156
178
  function formatClaudeCodeOutput(output) {
157
179
  // Remove ANSI escape codes
158
- // eslint-disable-next-line no-control-regex
180
+ // oxlint-disable-next-line no-control-regex
159
181
  let cleaned = output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
160
182
  // Remove carriage returns (used for progress overwriting)
161
183
  cleaned = cleaned.replace(/\r/g, "");
@@ -244,7 +244,7 @@ function formatCLIOutput(stdout, stderr) {
244
244
  output = output.trim() + "\n\n[stderr]\n" + stderr.trim();
245
245
  }
246
246
  // Remove ANSI escape codes
247
- // eslint-disable-next-line no-control-regex
247
+ // oxlint-disable-next-line no-control-regex
248
248
  let cleaned = output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
249
249
  // Remove carriage returns (used for progress overwriting)
250
250
  cleaned = cleaned.replace(/\r/g, "");
@@ -8,6 +8,7 @@ export interface DockerConfig {
8
8
  versionConfigurable?: boolean;
9
9
  gradleVersion?: string;
10
10
  kotlinVersion?: string;
11
+ firewallDomains?: string[];
11
12
  }
12
13
  export interface LanguageConfigJson {
13
14
  name: string;
@@ -84,7 +84,7 @@ export function escapeHtml(text) {
84
84
  */
85
85
  export function stripAnsiCodes(text) {
86
86
  // Match ANSI escape sequences: ESC[...m (SGR), ESC[...K (EL), etc.
87
- // eslint-disable-next-line no-control-regex
87
+ // oxlint-disable-next-line no-control-regex
88
88
  return text.replace(/\x1B\[[0-9;]*[mKJHfsu]/g, "");
89
89
  }
90
90
  /**
@@ -130,6 +130,7 @@ export interface ResponderConfig {
130
130
  command?: string;
131
131
  timeout?: number;
132
132
  maxLength?: number;
133
+ successPattern?: string;
133
134
  }
134
135
  /**
135
136
  * Named responders configuration.
@@ -19,6 +19,7 @@ interface ExtractedItem {
19
19
  description: string;
20
20
  passes: boolean;
21
21
  }
22
+ export declare const VALID_CATEGORIES: readonly ["ui", "feature", "bugfix", "setup", "development", "testing", "docs"];
22
23
  /**
23
24
  * Validates that a PRD structure is correct.
24
25
  * Returns validation result with parsed data if valid.
@@ -1,7 +1,15 @@
1
1
  import { existsSync, readFileSync, writeFileSync, readdirSync } from "fs";
2
2
  import { join, dirname, extname } from "path";
3
3
  import YAML from "yaml";
4
- const VALID_CATEGORIES = ["ui", "feature", "bugfix", "setup", "development", "testing", "docs"];
4
+ export const VALID_CATEGORIES = [
5
+ "ui",
6
+ "feature",
7
+ "bugfix",
8
+ "setup",
9
+ "development",
10
+ "testing",
11
+ "docs",
12
+ ];
5
13
  /**
6
14
  * Validates that a PRD structure is correct.
7
15
  * Returns validation result with parsed data if valid.
@@ -49,6 +57,11 @@ export function validatePrd(content) {
49
57
  if (entry.branch !== undefined && typeof entry.branch !== "string") {
50
58
  errors.push(`${prefix} 'branch' field must be a string if provided`);
51
59
  }
60
+ else if (typeof entry.branch === "string" &&
61
+ entry.branch !== "" &&
62
+ !/^[a-zA-Z0-9_\-./]+$/.test(entry.branch)) {
63
+ errors.push(`${prefix} 'branch' field contains invalid characters (only alphanumeric, hyphens, underscores, dots, and slashes allowed)`);
64
+ }
52
65
  // If no errors for this item, add to valid data
53
66
  if (errors.filter((e) => e.startsWith(prefix)).length === 0) {
54
67
  const validEntry = {
@@ -91,9 +104,7 @@ export function extractPassingItems(corrupted) {
91
104
  // Handle object with wrapped arrays
92
105
  if (typeof corrupted === "object") {
93
106
  const obj = corrupted;
94
- // Common wrapper keys LLMs might use
95
- const wrapperKeys = ["features", "items", "entries", "prd", "tasks", "requirements"];
96
- for (const key of wrapperKeys) {
107
+ for (const key of PRD_WRAPPER_KEYS) {
97
108
  if (Array.isArray(obj[key])) {
98
109
  for (const item of obj[key]) {
99
110
  const extracted = extractFromItem(item);
@@ -234,8 +245,7 @@ export function attemptRecovery(corrupted) {
234
245
  // Strategy 1: Unwrap from common wrapper objects
235
246
  if (typeof corrupted === "object" && corrupted !== null && !Array.isArray(corrupted)) {
236
247
  const obj = corrupted;
237
- const wrapperKeys = ["features", "items", "entries", "prd", "tasks", "requirements"];
238
- for (const key of wrapperKeys) {
248
+ for (const key of PRD_WRAPPER_KEYS) {
239
249
  if (Array.isArray(obj[key])) {
240
250
  const result = attemptArrayRecovery(obj[key]);
241
251
  if (result)
@@ -28,10 +28,7 @@ export declare function logResponderCall(entry: ResponderLogEntry): void;
28
28
  * Log a responder call to console (for debug mode).
29
29
  */
30
30
  export declare function logResponderCallToConsole(entry: ResponderLogEntry): void;
31
- /**
32
- * Create a log entry and optionally log to console.
33
- */
34
- export declare function createResponderLog(options: {
31
+ export interface CreateResponderLogOptions {
35
32
  responderName?: string;
36
33
  responderType?: string;
37
34
  trigger?: string;
@@ -44,4 +41,8 @@ export declare function createResponderLog(options: {
44
41
  message: string;
45
42
  systemPrompt?: string;
46
43
  debug?: boolean;
47
- }): void;
44
+ }
45
+ /**
46
+ * Create a log entry and optionally log to console.
47
+ */
48
+ export declare function createResponderLog(options: CreateResponderLogOptions): void;
@@ -12,6 +12,7 @@ export interface ResponderPreset {
12
12
  command?: string;
13
13
  timeout?: number;
14
14
  maxLength?: number;
15
+ successPattern?: string;
15
16
  }
16
17
  /**
17
18
  * Bundle of presets for quick setup.
@@ -83,6 +83,8 @@ export function presetToResponderConfig(preset) {
83
83
  config.timeout = preset.timeout;
84
84
  if (preset.maxLength)
85
85
  config.maxLength = preset.maxLength;
86
+ if (preset.successPattern)
87
+ config.successPattern = preset.successPattern;
86
88
  return config;
87
89
  }
88
90
  /**
@@ -109,10 +109,7 @@ export class ResponderMatcher {
109
109
  // Extract args: everything after the trigger
110
110
  const triggerIndex = match.index + match[0].indexOf(match[1]);
111
111
  const afterTrigger = message.slice(triggerIndex + match[1].length);
112
- const args = afterTrigger
113
- .replace(/^[:]\s*/, "")
114
- .replace(/^\s+/, "")
115
- .trim();
112
+ const args = this.extractArgsAfterTrigger(afterTrigger, 0);
116
113
  return { name: responderName, responder, args };
117
114
  }
118
115
  }
package/docs/MCP.md ADDED
@@ -0,0 +1,192 @@
1
+ # MCP Server
2
+
3
+ Ralph includes a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that exposes PRD management tools to any MCP-compatible client. This allows AI assistants like Claude to read, update, and track your PRD directly through tool calls.
4
+
5
+ ## Overview
6
+
7
+ The MCP server runs over **stdio** transport and provides four tools:
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+ | `ralph_prd_list` | List PRD entries with optional filters |
12
+ | `ralph_prd_add` | Add a new PRD entry |
13
+ | `ralph_prd_status` | Get completion status and breakdown |
14
+ | `ralph_prd_toggle` | Toggle pass/fail status for entries |
15
+
16
+ ## Installation
17
+
18
+ The MCP server is included with ralph. Install ralph globally or use npx:
19
+
20
+ ```bash
21
+ npm install -g ralph-cli-sandboxed
22
+ ```
23
+
24
+ The server binary is available as `ralph-mcp` after installation.
25
+
26
+ ## MCP Client Configuration
27
+
28
+ ### Claude Code
29
+
30
+ Add the following to your project's `.mcp.json` file (or `~/.claude/mcp.json` for global access):
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "ralph": {
36
+ "command": "ralph-mcp",
37
+ "args": []
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ If ralph is installed locally (not globally), use npx:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "ralph": {
49
+ "command": "npx",
50
+ "args": ["--package", "ralph-cli-sandboxed", "ralph-mcp"]
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ ### Other MCP Clients
57
+
58
+ Any MCP client that supports stdio transport can connect to the ralph MCP server. Point your client at the `ralph-mcp` binary with no arguments.
59
+
60
+ ## Available Tools
61
+
62
+ ### ralph_prd_list
63
+
64
+ List PRD entries with optional category and status filters.
65
+
66
+ **Parameters:**
67
+
68
+ | Parameter | Type | Required | Description |
69
+ |-----------|------|----------|-------------|
70
+ | `category` | enum | No | Filter by category: `ui`, `feature`, `bugfix`, `setup`, `development`, `testing`, `docs` |
71
+ | `status` | enum | No | Filter by status: `all` (default), `passing`, `failing` |
72
+
73
+ **Returns:** JSON array of entries, each containing:
74
+
75
+ ```json
76
+ [
77
+ {
78
+ "category": "feature",
79
+ "description": "Add user authentication",
80
+ "steps": ["Create login form", "Implement JWT tokens"],
81
+ "passes": false,
82
+ "index": 1
83
+ }
84
+ ]
85
+ ```
86
+
87
+ **Examples:**
88
+ - List all entries: `ralph_prd_list({})`
89
+ - List failing features: `ralph_prd_list({ category: "feature", status: "failing" })`
90
+ - List passing entries: `ralph_prd_list({ status: "passing" })`
91
+
92
+ ### ralph_prd_add
93
+
94
+ Add a new PRD entry with category, description, and verification steps.
95
+
96
+ **Parameters:**
97
+
98
+ | Parameter | Type | Required | Description |
99
+ |-----------|------|----------|-------------|
100
+ | `category` | enum | Yes | Category: `ui`, `feature`, `bugfix`, `setup`, `development`, `testing`, `docs` |
101
+ | `description` | string | Yes | Description of the requirement |
102
+ | `steps` | string[] | Yes | Non-empty array of verification steps |
103
+ | `branch` | string | No | Git branch associated with this entry |
104
+
105
+ **Returns:** JSON with confirmation message and the added entry including its 1-based index.
106
+
107
+ ```json
108
+ {
109
+ "message": "Added entry #3: \"Add dark mode support\"",
110
+ "entry": {
111
+ "category": "feature",
112
+ "description": "Add dark mode support",
113
+ "steps": ["Add theme toggle", "Implement dark CSS variables"],
114
+ "passes": false,
115
+ "index": 3
116
+ }
117
+ }
118
+ ```
119
+
120
+ ### ralph_prd_status
121
+
122
+ Get PRD completion status with counts, percentage, per-category breakdown, and remaining items. Takes no parameters.
123
+
124
+ **Returns:**
125
+
126
+ ```json
127
+ {
128
+ "passing": 5,
129
+ "total": 8,
130
+ "percentage": 63,
131
+ "categories": {
132
+ "feature": { "passing": 3, "total": 5 },
133
+ "setup": { "passing": 2, "total": 2 },
134
+ "docs": { "passing": 0, "total": 1 }
135
+ },
136
+ "remaining": [
137
+ { "index": 3, "category": "feature", "description": "Add search functionality" },
138
+ { "index": 6, "category": "feature", "description": "Add notifications" },
139
+ { "index": 8, "category": "docs", "description": "Write API documentation" }
140
+ ]
141
+ }
142
+ ```
143
+
144
+ ### ralph_prd_toggle
145
+
146
+ Toggle the completion status (`passes`) for one or more PRD entries by their 1-based index.
147
+
148
+ **Parameters:**
149
+
150
+ | Parameter | Type | Required | Description |
151
+ |-----------|------|----------|-------------|
152
+ | `indices` | number[] | Yes | 1-based indices of PRD entries to toggle |
153
+
154
+ **Returns:** JSON with confirmation and list of toggled entries.
155
+
156
+ ```json
157
+ {
158
+ "message": "Toggled 2 entry/entries",
159
+ "toggled": [
160
+ { "index": 1, "description": "Add login page", "passes": true },
161
+ { "index": 3, "description": "Add search", "passes": false }
162
+ ]
163
+ }
164
+ ```
165
+
166
+ Out-of-range indices return an error. Duplicate indices are deduplicated before toggling.
167
+
168
+ ## Example Conversations
169
+
170
+ ### Checking project progress
171
+
172
+ > **User:** What's the status of our PRD?
173
+ >
174
+ > **Assistant:** *(calls `ralph_prd_status`)* Your PRD is 63% complete (5 of 8 tasks passing). You have 3 remaining items: search functionality, notifications, and API documentation.
175
+
176
+ ### Adding a new requirement
177
+
178
+ > **User:** We need to add internationalization support.
179
+ >
180
+ > **Assistant:** *(calls `ralph_prd_add` with category "feature", description "Add internationalization (i18n) support", steps ["Install i18n library", "Extract user-facing strings", "Add language switcher"])* Done! Added as entry #9 in the feature category.
181
+
182
+ ### Marking work as complete
183
+
184
+ > **User:** I've finished the search functionality (item 3).
185
+ >
186
+ > **Assistant:** *(calls `ralph_prd_toggle` with indices [3])* Toggled entry #3 "Add search functionality" to passing.
187
+
188
+ ### Filtering the PRD
189
+
190
+ > **User:** Show me all the incomplete feature tasks.
191
+ >
192
+ > **Assistant:** *(calls `ralph_prd_list` with category "feature", status "failing")* You have 2 incomplete feature tasks: notifications (#6) and i18n support (#9).
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "ralph-cli-sandboxed",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "AI-driven development automation CLI for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
7
- "ralph": "dist/index.js"
7
+ "ralph": "dist/index.js",
8
+ "ralph-cli-sandboxed": "dist/index.js",
9
+ "ralph-mcp": "dist/mcp-server.js"
8
10
  },
9
11
  "main": "./dist/index.js",
10
12
  "files": [
@@ -13,10 +15,10 @@
13
15
  "README.md"
14
16
  ],
15
17
  "scripts": {
16
- "build": "tsc && npm run copy-config",
18
+ "build": "npx tsc && npm run copy-config",
17
19
  "copy-config": "mkdir -p dist/config && cp src/config/*.json dist/config/",
18
20
  "dev": "npx tsx src/index.ts",
19
- "typecheck": "tsc --noEmit",
21
+ "typecheck": "npx tsc --noEmit",
20
22
  "lint": "oxlint src/",
21
23
  "format": "oxfmt src/",
22
24
  "format:check": "oxfmt --check src/",
@@ -26,8 +28,8 @@
26
28
  "prepare": "npm run build"
27
29
  },
28
30
  "dependencies": {
29
- "@anthropic-ai/sdk": "^0.80.0",
30
- "@inkjs/ui": "^2.0.0",
31
+ "@anthropic-ai/sdk": "^0.82.0",
32
+ "@modelcontextprotocol/sdk": "^1.26.0",
31
33
  "@slack/bolt": "^4.6.0",
32
34
  "@slack/web-api": "^7.15.0",
33
35
  "discord.js": "^14.16.0",
@@ -35,8 +37,8 @@
35
37
  "ink-text-input": "^6.0.0",
36
38
  "openai": "^6.33.0",
37
39
  "react": "^19.2.4",
38
- "readline": "^1.3.0",
39
- "yaml": "^2.8.3"
40
+ "yaml": "^2.8.3",
41
+ "zod": "^3.25.76"
40
42
  },
41
43
  "devDependencies": {
42
44
  "@types/node": "^20.0.0",