ralph-cli-sandboxed 0.7.0 → 0.7.1
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 +21 -1
- package/dist/commands/docker.js +86 -14
- package/dist/commands/run.js +8 -4
- package/dist/config/languages.json +2 -1
- package/dist/config/responder-presets.json +10 -0
- package/dist/config/skills.json +8 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +366 -0
- package/dist/responders/claude-code-responder.js +24 -2
- package/dist/responders/cli-responder.js +1 -1
- package/dist/templates/prompts.d.ts +1 -0
- package/dist/utils/chat-client.js +1 -1
- package/dist/utils/config.d.ts +1 -0
- package/dist/utils/prd-validator.d.ts +1 -0
- package/dist/utils/prd-validator.js +16 -6
- package/dist/utils/responder-logger.d.ts +6 -5
- package/dist/utils/responder-presets.d.ts +1 -0
- package/dist/utils/responder-presets.js +2 -0
- package/dist/utils/responder.js +1 -4
- package/docs/MCP.md +192 -0
- package/package.json +9 -8
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`
|
package/dist/commands/docker.js
CHANGED
|
@@ -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
|
|
663
|
-
|
|
664
|
-
|
|
696
|
+
function generateClaudeSettings(language) {
|
|
697
|
+
const hooks = [
|
|
698
|
+
{
|
|
699
|
+
matcher: "Bash",
|
|
700
|
+
hooks: [
|
|
665
701
|
{
|
|
666
|
-
|
|
667
|
-
|
|
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(
|
|
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
|
package/dist/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
80
|
+
execFileSync("git", ["worktree", "add", worktreePath, branch], { stdio: "pipe" });
|
|
77
81
|
}
|
|
78
82
|
else {
|
|
79
83
|
// Create new branch from current HEAD
|
|
80
|
-
|
|
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=\"/
|
|
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": {
|
package/dist/config/skills.json
CHANGED
|
@@ -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,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",
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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, "");
|
|
@@ -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
|
-
//
|
|
87
|
+
// oxlint-disable-next-line no-control-regex
|
|
88
88
|
return text.replace(/\x1B\[[0-9;]*[mKJHfsu]/g, "");
|
|
89
89
|
}
|
|
90
90
|
/**
|
package/dist/utils/config.d.ts
CHANGED
|
@@ -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 = [
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create a log entry and optionally log to console.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createResponderLog(options: CreateResponderLogOptions): void;
|
|
@@ -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
|
/**
|
package/dist/utils/responder.js
CHANGED
|
@@ -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,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ralph-cli-sandboxed",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
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-mcp": "dist/mcp-server.js"
|
|
8
9
|
},
|
|
9
10
|
"main": "./dist/index.js",
|
|
10
11
|
"files": [
|
|
@@ -13,10 +14,10 @@
|
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
|
16
|
-
"build": "tsc && npm run copy-config",
|
|
17
|
+
"build": "npx tsc && npm run copy-config",
|
|
17
18
|
"copy-config": "mkdir -p dist/config && cp src/config/*.json dist/config/",
|
|
18
19
|
"dev": "npx tsx src/index.ts",
|
|
19
|
-
"typecheck": "tsc --noEmit",
|
|
20
|
+
"typecheck": "npx tsc --noEmit",
|
|
20
21
|
"lint": "oxlint src/",
|
|
21
22
|
"format": "oxfmt src/",
|
|
22
23
|
"format:check": "oxfmt --check src/",
|
|
@@ -26,8 +27,8 @@
|
|
|
26
27
|
"prepare": "npm run build"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"@anthropic-ai/sdk": "^0.
|
|
30
|
-
"@
|
|
30
|
+
"@anthropic-ai/sdk": "^0.82.0",
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
31
32
|
"@slack/bolt": "^4.6.0",
|
|
32
33
|
"@slack/web-api": "^7.15.0",
|
|
33
34
|
"discord.js": "^14.16.0",
|
|
@@ -35,8 +36,8 @@
|
|
|
35
36
|
"ink-text-input": "^6.0.0",
|
|
36
37
|
"openai": "^6.33.0",
|
|
37
38
|
"react": "^19.2.4",
|
|
38
|
-
"
|
|
39
|
-
"
|
|
39
|
+
"yaml": "^2.8.3",
|
|
40
|
+
"zod": "^3.25.76"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"@types/node": "^20.0.0",
|