ralph-cli-sandboxed 0.6.6 → 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/LICENSE +191 -0
- package/README.md +22 -2
- package/dist/commands/ask.d.ts +6 -0
- package/dist/commands/ask.js +140 -0
- package/dist/commands/branch.js +8 -4
- package/dist/commands/chat.js +11 -8
- package/dist/commands/docker.js +104 -17
- package/dist/commands/fix-config.js +0 -41
- package/dist/commands/help.js +10 -0
- package/dist/commands/run.js +17 -13
- package/dist/config/languages.json +2 -1
- package/dist/config/responder-presets.json +10 -0
- package/dist/config/skills.json +8 -0
- package/dist/index.js +2 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +366 -0
- package/dist/providers/telegram.js +1 -1
- package/dist/responders/claude-code-responder.js +24 -1
- package/dist/responders/cli-responder.js +1 -0
- package/dist/responders/llm-responder.js +1 -1
- package/dist/templates/macos-scripts.js +18 -18
- package/dist/templates/prompts.d.ts +1 -0
- package/dist/tui/components/JsonSnippetEditor.js +7 -7
- package/dist/tui/components/KeyValueEditor.js +5 -1
- package/dist/tui/components/LLMProvidersEditor.js +7 -9
- package/dist/tui/components/Preview.js +1 -1
- package/dist/tui/components/SectionNav.js +18 -2
- package/dist/utils/chat-client.js +1 -0
- package/dist/utils/config.d.ts +2 -0
- package/dist/utils/config.js +3 -1
- package/dist/utils/config.test.d.ts +1 -0
- package/dist/utils/config.test.js +424 -0
- package/dist/utils/notification.js +1 -1
- package/dist/utils/prd-validator.d.ts +1 -0
- package/dist/utils/prd-validator.js +32 -10
- package/dist/utils/prd-validator.test.d.ts +1 -0
- package/dist/utils/prd-validator.test.js +1095 -0
- 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 -1
- package/dist/utils/stream-json.test.d.ts +1 -0
- package/dist/utils/stream-json.test.js +1007 -0
- package/docs/DOCKER.md +14 -0
- package/docs/MCP.md +192 -0
- package/docs/PRD-GENERATOR.md +15 -0
- package/package.json +22 -18
|
@@ -186,47 +186,6 @@ function extractSectionFromCorrupt(content, section) {
|
|
|
186
186
|
}
|
|
187
187
|
return undefined;
|
|
188
188
|
}
|
|
189
|
-
/**
|
|
190
|
-
* Attempts to recover valid sections from a corrupt config.
|
|
191
|
-
*/
|
|
192
|
-
function recoverSections(corruptContent, parsedPartial) {
|
|
193
|
-
const defaultConfig = getDefaultConfig();
|
|
194
|
-
const recoveredConfig = {};
|
|
195
|
-
const result = {
|
|
196
|
-
recovered: [],
|
|
197
|
-
reset: [],
|
|
198
|
-
errors: [],
|
|
199
|
-
};
|
|
200
|
-
for (const section of CONFIG_SECTIONS) {
|
|
201
|
-
let value = undefined;
|
|
202
|
-
let source = "default";
|
|
203
|
-
// First, try to get from parsed partial (if JSON was partially valid)
|
|
204
|
-
if (parsedPartial && section in parsedPartial) {
|
|
205
|
-
value = parsedPartial[section];
|
|
206
|
-
source = "parsed";
|
|
207
|
-
}
|
|
208
|
-
// If not found or invalid, try regex extraction for simple string fields
|
|
209
|
-
if ((value === undefined || !validateSection(section, value)) &&
|
|
210
|
-
typeof corruptContent === "string") {
|
|
211
|
-
const extracted = extractSectionFromCorrupt(corruptContent, section);
|
|
212
|
-
if (extracted !== undefined && validateSection(section, extracted)) {
|
|
213
|
-
value = extracted;
|
|
214
|
-
source = "extracted";
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
// Validate the value
|
|
218
|
-
if (validateSection(section, value)) {
|
|
219
|
-
recoveredConfig[section] = value;
|
|
220
|
-
result.recovered.push(section);
|
|
221
|
-
}
|
|
222
|
-
else {
|
|
223
|
-
// Use default value
|
|
224
|
-
recoveredConfig[section] = defaultConfig[section];
|
|
225
|
-
result.reset.push(section);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return result;
|
|
229
|
-
}
|
|
230
189
|
/**
|
|
231
190
|
* Creates a backup of the config file.
|
|
232
191
|
*/
|
package/dist/commands/help.js
CHANGED
|
@@ -20,6 +20,7 @@ COMMANDS:
|
|
|
20
20
|
docker <sub> Manage Docker sandbox environment
|
|
21
21
|
daemon <sub> Host daemon for sandbox-to-host communication
|
|
22
22
|
notify [msg] Send notification to host from sandbox
|
|
23
|
+
ask <preset> msg Run a responder preset from the CLI
|
|
23
24
|
action [name] Execute host actions from config.json
|
|
24
25
|
chat <sub> Chat client integration (Telegram, etc.)
|
|
25
26
|
slack <sub> Slack app setup and management
|
|
@@ -102,6 +103,12 @@ NOTIFY OPTIONS:
|
|
|
102
103
|
--action, -a <name> Execute specific daemon action (default: notify)
|
|
103
104
|
--debug, -d Show debug output
|
|
104
105
|
|
|
106
|
+
ASK OPTIONS:
|
|
107
|
+
<preset> Name of a built-in preset or config responder
|
|
108
|
+
<message...> Message to send to the responder
|
|
109
|
+
--list, -l List available presets and configured responders
|
|
110
|
+
--help, -h Show ask help message
|
|
111
|
+
|
|
105
112
|
ACTION OPTIONS:
|
|
106
113
|
[name] Name of the action to execute
|
|
107
114
|
[args...] Arguments to pass to the action command
|
|
@@ -144,6 +151,9 @@ EXAMPLES:
|
|
|
144
151
|
ralph chat start # Start Telegram chat daemon
|
|
145
152
|
ralph chat test 123456 # Test chat connection
|
|
146
153
|
ralph slack setup # Create new Slack app for this project
|
|
154
|
+
ralph ask --list # List available responder presets
|
|
155
|
+
ralph ask qa "What does the config loader do?" # Ask a question
|
|
156
|
+
ralph ask reviewer diff # Review current git diff
|
|
147
157
|
ralph action --list # List available host actions
|
|
148
158
|
ralph action build # Execute 'build' action on host
|
|
149
159
|
ralph branch list # List all branches and their PRD status
|
package/dist/commands/run.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawn, execSync } from "child_process";
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync, appendFileSync, mkdirSync, copyFileSync } from "fs";
|
|
1
|
+
import { spawn, execSync, execFileSync } from "child_process";
|
|
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";
|
|
5
5
|
import { resolvePromptVariables, getCliProviders, GEMINI_MD } from "../templates/prompts.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) {
|
|
@@ -536,7 +540,11 @@ function validateAndRecoverPrd(prdPath, validPrd) {
|
|
|
536
540
|
if (mergeResult.warnings.length > 0) {
|
|
537
541
|
mergeResult.warnings.forEach((w) => console.log(` ${w}`));
|
|
538
542
|
}
|
|
539
|
-
return {
|
|
543
|
+
return {
|
|
544
|
+
recovered: true,
|
|
545
|
+
itemsUpdated: mergeResult.itemsUpdated,
|
|
546
|
+
newItemsPreserved: newItems.length,
|
|
547
|
+
};
|
|
540
548
|
}
|
|
541
549
|
/**
|
|
542
550
|
* Loads a valid copy of the PRD to keep in memory.
|
|
@@ -933,8 +941,6 @@ export async function run(args) {
|
|
|
933
941
|
}
|
|
934
942
|
let iterExitCode = 0;
|
|
935
943
|
let iterOutput = "";
|
|
936
|
-
let iterSterr = "";
|
|
937
|
-
let iterSyncTotal = 0;
|
|
938
944
|
// Get the base branch for branch state tracking
|
|
939
945
|
let baseBranch = "main";
|
|
940
946
|
const hasCommits = repoHasCommits();
|
|
@@ -1009,8 +1015,6 @@ export async function run(args) {
|
|
|
1009
1015
|
clearBranchState();
|
|
1010
1016
|
iterExitCode = result.exitCode;
|
|
1011
1017
|
iterOutput = result.output;
|
|
1012
|
-
iterSterr = result.stderr;
|
|
1013
|
-
iterSyncTotal += result.syncResult.count;
|
|
1014
1018
|
}
|
|
1015
1019
|
}
|
|
1016
1020
|
else if (targetBranch !== "" && !worktreesAvailable) {
|
|
@@ -1028,7 +1032,9 @@ export async function run(args) {
|
|
|
1028
1032
|
console.warn(`\x1b[33mSkipping ${branchItems.length} branch item(s).\x1b[0m\n`);
|
|
1029
1033
|
}
|
|
1030
1034
|
// Process no-branch items in /workspace (when target is no-branch, or branch was skipped)
|
|
1031
|
-
if (targetBranch === "" ||
|
|
1035
|
+
if (targetBranch === "" ||
|
|
1036
|
+
(!worktreesAvailable && targetBranch !== "") ||
|
|
1037
|
+
(!hasCommits && targetBranch !== "")) {
|
|
1032
1038
|
const noBranchItems = branchGroups.get("") || [];
|
|
1033
1039
|
if (noBranchItems.length > 0) {
|
|
1034
1040
|
const hasBranches = [...branchGroups.keys()].some((key) => key !== "");
|
|
@@ -1047,8 +1053,6 @@ export async function run(args) {
|
|
|
1047
1053
|
filteredPrdPath = null;
|
|
1048
1054
|
iterExitCode = result.exitCode;
|
|
1049
1055
|
iterOutput = result.output;
|
|
1050
|
-
iterSterr = result.stderr;
|
|
1051
|
-
iterSyncTotal += result.syncResult.count;
|
|
1052
1056
|
}
|
|
1053
1057
|
}
|
|
1054
1058
|
// Validate and recover PRD if the LLM corrupted it
|
|
@@ -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",
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { fixPrd } from "./commands/fix-prd.js";
|
|
|
13
13
|
import { fixConfig } from "./commands/fix-config.js";
|
|
14
14
|
import { daemon } from "./commands/daemon.js";
|
|
15
15
|
import { notify } from "./commands/notify.js";
|
|
16
|
+
import { ask } from "./commands/ask.js";
|
|
16
17
|
import { chat } from "./commands/chat.js";
|
|
17
18
|
import { listen } from "./commands/listen.js";
|
|
18
19
|
import { config } from "./commands/config.js";
|
|
@@ -38,6 +39,7 @@ const commands = {
|
|
|
38
39
|
docker,
|
|
39
40
|
daemon,
|
|
40
41
|
notify,
|
|
42
|
+
ask,
|
|
41
43
|
chat,
|
|
42
44
|
listen,
|
|
43
45
|
config,
|
|
@@ -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();
|
|
@@ -175,7 +175,7 @@ export class TelegramChatClient {
|
|
|
175
175
|
reject(new Error(`Telegram API error: ${response.description || "Unknown error"} (code: ${response.error_code})`));
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
-
catch
|
|
178
|
+
catch {
|
|
179
179
|
reject(new Error(`Failed to parse Telegram response: ${data}`));
|
|
180
180
|
}
|
|
181
181
|
});
|
|
@@ -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,6 +177,7 @@ export async function executeClaudeCodeResponder(prompt, responderConfig, option
|
|
|
155
177
|
*/
|
|
156
178
|
function formatClaudeCodeOutput(output) {
|
|
157
179
|
// Remove ANSI escape codes
|
|
180
|
+
// oxlint-disable-next-line no-control-regex
|
|
158
181
|
let cleaned = output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
|
|
159
182
|
// Remove carriage returns (used for progress overwriting)
|
|
160
183
|
cleaned = cleaned.replace(/\r/g, "");
|
|
@@ -244,6 +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
|
+
// oxlint-disable-next-line no-control-regex
|
|
247
248
|
let cleaned = output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
|
|
248
249
|
// Remove carriage returns (used for progress overwriting)
|
|
249
250
|
cleaned = cleaned.replace(/\r/g, "");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* LLM Responder - Sends messages to LLM providers and returns responses.
|
|
3
3
|
* Used by chat clients to respond to messages matched by the responder matcher.
|
|
4
4
|
*/
|
|
5
|
-
import { getLLMProviders, loadConfig
|
|
5
|
+
import { getLLMProviders, loadConfig } from "../utils/config.js";
|
|
6
6
|
import { createLLMClient } from "../utils/llm-client.js";
|
|
7
7
|
import { createResponderLog } from "../utils/responder-logger.js";
|
|
8
8
|
import { basename, resolve } from "path";
|