ralph-cli-sandboxed 0.6.2 → 0.6.3

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.
@@ -1,10 +1,9 @@
1
1
  import { execSync } from "child_process";
2
- import { existsSync, readFileSync, writeFileSync } from "fs";
3
- import { extname, join } from "path";
4
- import { getRalphDir, getPrdFiles, loadBranchState, getProjectName } from "../utils/config.js";
2
+ import { existsSync } from "fs";
3
+ import { join } from "path";
4
+ import { getPrdFiles, loadBranchState, getProjectName } from "../utils/config.js";
5
5
  import { readPrdFile, writePrdAuto } from "../utils/prd-validator.js";
6
6
  import { promptConfirm } from "../utils/prompt.js";
7
- import YAML from "yaml";
8
7
  /**
9
8
  * Converts a branch name to a worktree directory name, prefixed with the project name.
10
9
  * e.g., "feat/login" -> "myproject_feat-login"
@@ -37,11 +36,11 @@ function loadPrdEntries() {
37
36
  return { entries: parsed.content, prdPath: prdFiles.primary };
38
37
  }
39
38
  /**
40
- * Gets the base branch (the branch that /workspace is on).
39
+ * Gets the base branch (the current branch of the project).
41
40
  */
42
41
  function getBaseBranch() {
43
42
  try {
44
- return execSync("git -C /workspace rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
43
+ return execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
45
44
  }
46
45
  catch {
47
46
  return "main";
@@ -151,17 +150,17 @@ async function branchMerge(args) {
151
150
  console.log("Merge cancelled.");
152
151
  return;
153
152
  }
154
- // Perform the merge from /workspace (which is on the base branch)
153
+ // Perform the merge into the base branch
155
154
  try {
156
155
  console.log(`\nMerging "${branchName}" into "${baseBranch}"...`);
157
- execSync(`git -C /workspace merge "${branchName}" --no-edit`, { stdio: "pipe" });
156
+ execSync(`git merge "${branchName}" --no-edit`, { stdio: "pipe" });
158
157
  console.log(`\x1b[32mSuccessfully merged "${branchName}" into "${baseBranch}".\x1b[0m`);
159
158
  }
160
159
  catch (err) {
161
160
  // Check if this is a merge conflict
162
161
  let conflictingFiles = [];
163
162
  try {
164
- const status = execSync("git -C /workspace status --porcelain", { encoding: "utf-8" });
163
+ const status = execSync("git status --porcelain", { encoding: "utf-8" });
165
164
  conflictingFiles = status
166
165
  .split("\n")
167
166
  .filter((line) => line.startsWith("UU") || line.startsWith("AA") || line.startsWith("DD") || line.startsWith("AU") || line.startsWith("UA") || line.startsWith("DU") || line.startsWith("UD"))
@@ -179,7 +178,7 @@ async function branchMerge(args) {
179
178
  }
180
179
  // Abort the merge
181
180
  try {
182
- execSync("git -C /workspace merge --abort", { stdio: "pipe" });
181
+ execSync("git merge --abort", { stdio: "pipe" });
183
182
  console.error(`\n\x1b[36mMerge aborted.\x1b[0m`);
184
183
  }
185
184
  catch {
@@ -197,7 +196,7 @@ async function branchMerge(args) {
197
196
  console.error(`\x1b[31mMerge failed: ${message}\x1b[0m`);
198
197
  // Try to abort in case merge is in progress
199
198
  try {
200
- execSync("git -C /workspace merge --abort", { stdio: "pipe" });
199
+ execSync("git merge --abort", { stdio: "pipe" });
201
200
  }
202
201
  catch {
203
202
  // Ignore if nothing to abort
@@ -209,7 +208,7 @@ async function branchMerge(args) {
209
208
  if (existsSync(worktreePath)) {
210
209
  console.log(`\nCleaning up worktree at ${worktreePath}...`);
211
210
  try {
212
- execSync(`git -C /workspace worktree remove "${worktreePath}"`, { stdio: "pipe" });
211
+ execSync(`git worktree remove "${worktreePath}"`, { stdio: "pipe" });
213
212
  console.log(`\x1b[32mWorktree removed.\x1b[0m`);
214
213
  }
215
214
  catch (err) {
@@ -222,57 +221,31 @@ async function branchMerge(args) {
222
221
  console.log(`\n\x1b[32mDone!\x1b[0m Branch "${branchName}" has been merged into "${baseBranch}".`);
223
222
  }
224
223
  /**
225
- * Gets the PRD file path, preferring the primary if it exists.
224
+ * Create a pull request for a branch using the gh CLI on the host.
226
225
  */
227
- function getPrdPath() {
228
- const prdFiles = getPrdFiles();
229
- if (prdFiles.primary) {
230
- return prdFiles.primary;
226
+ async function branchPr(args) {
227
+ const branchName = args[0];
228
+ if (!branchName) {
229
+ console.error("Usage: ralph branch pr <branch-name>");
230
+ console.error("\nExample: ralph branch pr feat/login");
231
+ process.exit(1);
231
232
  }
232
- return join(getRalphDir(), "prd.json");
233
- }
234
- /**
235
- * Parses a PRD file (YAML or JSON) and returns the entries.
236
- */
237
- function parsePrdFile(path) {
238
- const content = readFileSync(path, "utf-8");
239
- const ext = extname(path).toLowerCase();
233
+ // Pre-flight: verify gh is installed
240
234
  try {
241
- let result;
242
- if (ext === ".yaml" || ext === ".yml") {
243
- result = YAML.parse(content);
244
- }
245
- else {
246
- result = JSON.parse(content);
247
- }
248
- return result ?? [];
235
+ execSync("gh --version", { stdio: "pipe" });
249
236
  }
250
237
  catch {
251
- console.error(`Error parsing ${path}. Run 'ralph fix-prd' to attempt automatic repair.`);
238
+ console.error("\x1b[31mError: 'gh' CLI is not installed.\x1b[0m");
239
+ console.error("Install it from https://cli.github.com/");
252
240
  process.exit(1);
253
241
  }
254
- }
255
- /**
256
- * Saves PRD entries to the PRD file (YAML or JSON based on extension).
257
- */
258
- function savePrd(entries) {
259
- const path = getPrdPath();
260
- const ext = extname(path).toLowerCase();
261
- if (ext === ".yaml" || ext === ".yml") {
262
- writeFileSync(path, YAML.stringify(entries));
263
- }
264
- else {
265
- writeFileSync(path, JSON.stringify(entries, null, 2) + "\n");
242
+ // Pre-flight: verify gh is authenticated
243
+ try {
244
+ execSync("gh auth status", { stdio: "pipe" });
266
245
  }
267
- }
268
- /**
269
- * Create a PRD item to open a pull request for a branch.
270
- */
271
- function branchPr(args) {
272
- const branchName = args[0];
273
- if (!branchName) {
274
- console.error("Usage: ralph branch pr <branch-name>");
275
- console.error("\nExample: ralph branch pr feat/login");
246
+ catch {
247
+ console.error("\x1b[31mError: Not authenticated with GitHub.\x1b[0m");
248
+ console.error("Run 'gh auth login' first.");
276
249
  process.exit(1);
277
250
  }
278
251
  // Verify the branch exists
@@ -280,26 +253,88 @@ function branchPr(args) {
280
253
  console.error(`\x1b[31mError: Branch "${branchName}" does not exist.\x1b[0m`);
281
254
  process.exit(1);
282
255
  }
256
+ // Verify a git remote exists
257
+ let remote;
258
+ try {
259
+ remote = execSync("git remote", { encoding: "utf-8" }).trim().split("\n")[0];
260
+ if (!remote)
261
+ throw new Error("no remote");
262
+ }
263
+ catch {
264
+ console.error("\x1b[31mError: No git remote configured.\x1b[0m");
265
+ process.exit(1);
266
+ }
283
267
  const baseBranch = getBaseBranch();
284
- const entry = {
285
- category: "feature",
286
- description: `Create a pull request from \`${branchName}\` into \`${baseBranch}\``,
287
- steps: [
288
- `Ensure all changes on \`${branchName}\` are committed`,
289
- `Push \`${branchName}\` to the remote if not already pushed`,
290
- `Create a pull request from \`${branchName}\` into \`${baseBranch}\` using the appropriate tool (e.g. gh pr create)`,
291
- "Include a descriptive title and summary of the changes in the PR",
292
- ],
293
- passes: false,
294
- branch: branchName,
295
- };
296
- const prdPath = getPrdPath();
297
- const prd = parsePrdFile(prdPath);
298
- prd.push(entry);
299
- savePrd(prd);
300
- console.log(`Added PRD entry #${prd.length}: Create PR for ${branchName} → ${baseBranch}`);
301
- console.log(`Branch field set to: ${branchName}`);
302
- console.log("Run 'ralph run' or 'ralph once' to execute.");
268
+ // Auto-push: if branch has no upstream tracking, push it
269
+ try {
270
+ execSync(`git rev-parse --abbrev-ref "${branchName}@{upstream}"`, { stdio: "pipe" });
271
+ }
272
+ catch {
273
+ console.log(`Pushing "${branchName}" to ${remote}...`);
274
+ try {
275
+ execSync(`git push -u "${remote}" "${branchName}"`, { stdio: "inherit" });
276
+ }
277
+ catch {
278
+ console.error(`\x1b[31mError: Failed to push "${branchName}" to ${remote}.\x1b[0m`);
279
+ process.exit(1);
280
+ }
281
+ }
282
+ // Build PR title from branch name
283
+ const prTitle = branchName;
284
+ // Build PR body
285
+ const bodyParts = [];
286
+ // PRD Items section
287
+ const result = loadPrdEntries();
288
+ if (result) {
289
+ const branchItems = result.entries.filter((e) => e.branch === branchName);
290
+ if (branchItems.length > 0) {
291
+ bodyParts.push("## PRD Items\n");
292
+ for (const item of branchItems) {
293
+ const check = item.passes ? "x" : " ";
294
+ bodyParts.push(`- [${check}] ${item.description}`);
295
+ }
296
+ bodyParts.push("");
297
+ }
298
+ }
299
+ // Commits section
300
+ try {
301
+ const log = execSync(`git log "${baseBranch}..${branchName}" --oneline --no-decorate`, {
302
+ encoding: "utf-8",
303
+ }).trim();
304
+ if (log) {
305
+ bodyParts.push("## Commits\n");
306
+ bodyParts.push(log);
307
+ bodyParts.push("");
308
+ }
309
+ }
310
+ catch {
311
+ // No commits or branch comparison failed — skip
312
+ }
313
+ const prBody = bodyParts.join("\n");
314
+ // Show summary and confirm
315
+ console.log(`\nCreate PR: ${branchName} → ${baseBranch}`);
316
+ console.log(`Title: ${prTitle}`);
317
+ if (prBody) {
318
+ console.log(`\n${prBody}`);
319
+ }
320
+ const confirmed = await promptConfirm("Create this pull request?", true);
321
+ if (!confirmed) {
322
+ console.log("Cancelled.");
323
+ return;
324
+ }
325
+ // Create the PR using gh, piping body via stdin to avoid shell escaping issues
326
+ try {
327
+ const prUrl = execSync(`gh pr create --base "${baseBranch}" --head "${branchName}" --title "${prTitle.replace(/"/g, '\\"')}" --body-file -`, {
328
+ encoding: "utf-8",
329
+ input: prBody,
330
+ }).trim();
331
+ console.log(`\n\x1b[32mPR created:\x1b[0m ${prUrl}`);
332
+ }
333
+ catch (err) {
334
+ const message = err instanceof Error ? err.message : String(err);
335
+ console.error(`\x1b[31mFailed to create PR: ${message}\x1b[0m`);
336
+ process.exit(1);
337
+ }
303
338
  }
304
339
  /**
305
340
  * Delete a branch: remove worktree, delete git branch, and untag PRD items.
@@ -344,7 +379,7 @@ async function branchDelete(args) {
344
379
  if (hasWorktree) {
345
380
  console.log(`\nRemoving worktree at ${worktreePath}...`);
346
381
  try {
347
- execSync(`git -C /workspace worktree remove "${worktreePath}" --force`, { stdio: "pipe" });
382
+ execSync(`git worktree remove "${worktreePath}" --force`, { stdio: "pipe" });
348
383
  console.log(`\x1b[32mWorktree removed.\x1b[0m`);
349
384
  }
350
385
  catch (err) {
@@ -356,7 +391,7 @@ async function branchDelete(args) {
356
391
  // Step 2: Delete the git branch
357
392
  console.log(`Deleting branch "${branchName}"...`);
358
393
  try {
359
- execSync(`git -C /workspace branch -D "${branchName}"`, { stdio: "pipe" });
394
+ execSync(`git branch -D "${branchName}"`, { stdio: "pipe" });
360
395
  console.log(`\x1b[32mBranch deleted.\x1b[0m`);
361
396
  }
362
397
  catch (err) {
@@ -394,7 +429,7 @@ export async function branch(args) {
394
429
  await branchDelete(args.slice(1));
395
430
  break;
396
431
  case "pr":
397
- branchPr(args.slice(1));
432
+ await branchPr(args.slice(1));
398
433
  break;
399
434
  default:
400
435
  console.error("Usage: ralph branch <subcommand>");
@@ -402,7 +437,7 @@ export async function branch(args) {
402
437
  console.error(" list List all branches and their status");
403
438
  console.error(" merge <name> Merge a branch worktree into the base branch");
404
439
  console.error(" delete <name> Delete a branch and its worktree");
405
- console.error(" pr <name> Create a PRD item to open a PR for a branch");
440
+ console.error(" pr <name> Create a pull request for a branch using gh CLI");
406
441
  process.exit(1);
407
442
  }
408
443
  }
@@ -6,6 +6,7 @@ import { existsSync, readFileSync, writeFileSync, watch } from "fs";
6
6
  import { join, basename, extname } from "path";
7
7
  import { execSync, spawn } from "child_process";
8
8
  import YAML from "yaml";
9
+ import { robustYamlParse } from "../utils/prd-validator.js";
9
10
  import { loadConfig, getRalphDir, isRunningInContainer, getPrdFiles, loadBranchState, getProjectName as getConfigProjectName } from "../utils/config.js";
10
11
  import { createTelegramClient } from "../providers/telegram.js";
11
12
  import { createSlackClient } from "../providers/slack.js";
@@ -62,7 +63,7 @@ function parsePrdContent(filePath, content) {
62
63
  try {
63
64
  let parsed;
64
65
  if (ext === ".yaml" || ext === ".yml") {
65
- parsed = YAML.parse(content);
66
+ parsed = robustYamlParse(content);
66
67
  }
67
68
  else {
68
69
  parsed = JSON.parse(content);
@@ -253,7 +254,7 @@ async function handleBranchList(chatId, client, state) {
253
254
  await client.sendMessage(chatId, lines.join("\n"));
254
255
  }
255
256
  /**
256
- * Handle /branch pr <name> — add a PRD item to create a pull request.
257
+ * Handle /branch pr <name> — create a pull request using gh CLI on the host.
257
258
  */
258
259
  async function handleBranchPr(args, chatId, client, state) {
259
260
  const branchName = args[0];
@@ -264,42 +265,101 @@ async function handleBranchPr(args, chatId, client, state) {
264
265
  await client.sendMessage(chatId, `${state.projectName}: Usage: ${usage}`);
265
266
  return;
266
267
  }
268
+ // Pre-flight: verify gh is installed
269
+ try {
270
+ execSync("gh --version", { stdio: "pipe" });
271
+ }
272
+ catch {
273
+ await client.sendMessage(chatId, `${state.projectName}: Error: 'gh' CLI is not installed.`);
274
+ return;
275
+ }
276
+ // Pre-flight: verify gh is authenticated
277
+ try {
278
+ execSync("gh auth status", { stdio: "pipe" });
279
+ }
280
+ catch {
281
+ await client.sendMessage(chatId, `${state.projectName}: Error: Not authenticated with GitHub. Run 'gh auth login' on the host.`);
282
+ return;
283
+ }
267
284
  if (!branchExists(branchName)) {
268
285
  await client.sendMessage(chatId, `${state.projectName}: Branch "${branchName}" does not exist.`);
269
286
  return;
270
287
  }
288
+ // Verify a git remote exists
289
+ let remote;
290
+ try {
291
+ remote = execSync("git remote", { encoding: "utf-8", cwd: process.cwd() }).trim().split("\n")[0];
292
+ if (!remote)
293
+ throw new Error("no remote");
294
+ }
295
+ catch {
296
+ await client.sendMessage(chatId, `${state.projectName}: Error: No git remote configured.`);
297
+ return;
298
+ }
271
299
  const baseBranch = getBaseBranch();
300
+ const cwd = process.cwd();
301
+ // Auto-push: if branch has no upstream tracking, push it
302
+ try {
303
+ execSync(`git rev-parse --abbrev-ref "${branchName}@{upstream}"`, { stdio: "pipe", cwd });
304
+ }
305
+ catch {
306
+ try {
307
+ execSync(`git push -u "${remote}" "${branchName}"`, { stdio: "pipe", cwd });
308
+ }
309
+ catch {
310
+ await client.sendMessage(chatId, `${state.projectName}: Error: Failed to push "${branchName}" to ${remote}.`);
311
+ return;
312
+ }
313
+ }
314
+ // Build PR body
315
+ const bodyParts = [];
316
+ // PRD Items section
272
317
  const prdFiles = getPrdFiles();
273
- if (prdFiles.none || !prdFiles.primary) {
274
- await client.sendMessage(chatId, `${state.projectName}: No PRD file found.`);
275
- return;
318
+ if (!prdFiles.none && prdFiles.primary) {
319
+ const content = readFileSync(prdFiles.primary, "utf-8");
320
+ const items = parsePrdContent(prdFiles.primary, content);
321
+ if (Array.isArray(items)) {
322
+ const branchItems = items.filter((e) => e.branch === branchName);
323
+ if (branchItems.length > 0) {
324
+ bodyParts.push("## PRD Items\n");
325
+ for (const item of branchItems) {
326
+ const check = item.passes ? "x" : " ";
327
+ bodyParts.push(`- [${check}] ${item.description}`);
328
+ }
329
+ bodyParts.push("");
330
+ }
331
+ }
276
332
  }
277
- const content = readFileSync(prdFiles.primary, "utf-8");
278
- const items = parsePrdContent(prdFiles.primary, content);
279
- if (!Array.isArray(items)) {
280
- await client.sendMessage(chatId, `${state.projectName}: Failed to parse PRD file.`);
281
- return;
333
+ // Commits section
334
+ try {
335
+ const log = execSync(`git log "${baseBranch}..${branchName}" --oneline --no-decorate`, {
336
+ encoding: "utf-8",
337
+ cwd,
338
+ }).trim();
339
+ if (log) {
340
+ bodyParts.push("## Commits\n");
341
+ bodyParts.push(log);
342
+ bodyParts.push("");
343
+ }
282
344
  }
283
- items.push({
284
- category: "feature",
285
- description: `Create a pull request from \`${branchName}\` into \`${baseBranch}\``,
286
- steps: [
287
- `Ensure all changes on \`${branchName}\` are committed`,
288
- `Push \`${branchName}\` to the remote if not already pushed`,
289
- `Create a pull request from \`${branchName}\` into \`${baseBranch}\` using the appropriate tool (e.g. gh pr create)`,
290
- "Include a descriptive title and summary of the changes in the PR",
291
- ],
292
- passes: false,
293
- branch: branchName,
294
- });
295
- const ext = extname(prdFiles.primary).toLowerCase();
296
- if (ext === ".yaml" || ext === ".yml") {
297
- writeFileSync(prdFiles.primary, YAML.stringify(items));
345
+ catch {
346
+ // No commits or branch comparison failed — skip
298
347
  }
299
- else {
300
- writeFileSync(prdFiles.primary, JSON.stringify(items, null, 2) + "\n");
348
+ const prBody = bodyParts.join("\n");
349
+ const prTitle = branchName;
350
+ // Create the PR
351
+ try {
352
+ const prUrl = execSync(`gh pr create --base "${baseBranch}" --head "${branchName}" --title "${prTitle.replace(/"/g, '\\"')}" --body-file -`, {
353
+ encoding: "utf-8",
354
+ input: prBody,
355
+ cwd,
356
+ }).trim();
357
+ await client.sendMessage(chatId, `${state.projectName}: PR created: ${prUrl}`);
358
+ }
359
+ catch (err) {
360
+ const message = err instanceof Error ? err.message : String(err);
361
+ await client.sendMessage(chatId, `${state.projectName}: Failed to create PR: ${message}`);
301
362
  }
302
- await client.sendMessage(chatId, `${state.projectName}: Added PRD entry: Create PR for ${branchName} -> ${baseBranch}`);
303
363
  }
304
364
  /**
305
365
  * Handle /branch merge <name> — merge branch into base branch.
@@ -731,11 +791,11 @@ async function handleCommand(command, client, config, state, debug) {
731
791
  default: {
732
792
  const usage = client.provider === "slack"
733
793
  ? `/ralph branch list - List branches
734
- /ralph branch pr <name> - Add PRD item to create PR
794
+ /ralph branch pr <name> - Create a GitHub PR
735
795
  /ralph branch merge <name> - Merge branch into base
736
796
  /ralph branch delete <name> - Delete branch and worktree`
737
797
  : `/branch list - List branches
738
- /branch pr <name> - Add PRD item to create PR
798
+ /branch pr <name> - Create a GitHub PR
739
799
  /branch merge <name> - Merge branch into base
740
800
  /branch delete <name> - Delete branch and worktree`;
741
801
  await client.sendMessage(chatId, `${state.projectName}: ${usage}`);
@@ -544,6 +544,116 @@ function generateSkillFile(skill) {
544
544
  lines.push("---", "", skill.instructions, "");
545
545
  return lines.join("\n");
546
546
  }
547
+ // Generate dangerous_patterns.txt content
548
+ // Each line is a grep-compatible pattern that will be matched against Bash commands.
549
+ // Lines starting with # are comments. Empty lines are ignored.
550
+ function generateDangerousPatterns() {
551
+ return `# Dangerous command patterns for Claude Code hooks
552
+ # Each line is a grep -E pattern matched against Bash commands.
553
+ # Lines starting with # are comments. Empty lines are ignored.
554
+ # Add your own patterns below to extend the blocklist.
555
+
556
+ # Destructive git operations
557
+ git clean -fd
558
+ git checkout \\.
559
+ git reset --hard
560
+ git push.*--force
561
+ git push.*-f
562
+ git branch -D
563
+
564
+ # Destructive file operations
565
+ rm -rf /
566
+ rm -rf \\*
567
+ rm -rf \\.
568
+ mkfs\\.
569
+ dd if=.*of=/dev/
570
+
571
+ # Database destruction
572
+ DROP TABLE
573
+ DROP DATABASE
574
+ TRUNCATE TABLE
575
+
576
+ # System-level destructive commands
577
+ :(){ :|:& };:
578
+ chmod -R 777 /
579
+ chown -R.*:/
580
+ `;
581
+ }
582
+ // Generate block-dangerous-commands.sh hook script
583
+ function generateBlockDangerousCommandsHook() {
584
+ return `#!/bin/bash
585
+ # Claude Code PreToolUse hook: blocks dangerous Bash commands
586
+ # Generated by ralph-cli
587
+ #
588
+ # This script reads dangerous patterns from dangerous_patterns.txt
589
+ # and blocks any Bash command that matches a pattern.
590
+
591
+ set -e
592
+
593
+ # Read the command from hook JSON input on stdin
594
+ INPUT=$(cat)
595
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
596
+
597
+ if [ -z "$COMMAND" ]; then
598
+ exit 0
599
+ fi
600
+
601
+ # Locate the dangerous_patterns.txt file next to this script
602
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
603
+ PATTERNS_FILE="$SCRIPT_DIR/dangerous_patterns.txt"
604
+
605
+ if [ ! -f "$PATTERNS_FILE" ]; then
606
+ exit 0
607
+ fi
608
+
609
+ # Build grep pattern from file (skip comments and empty lines)
610
+ PATTERNS=$(grep -v '^#' "$PATTERNS_FILE" | grep -v '^$' || true)
611
+
612
+ if [ -z "$PATTERNS" ]; then
613
+ exit 0
614
+ fi
615
+
616
+ # Check if command matches any dangerous pattern
617
+ MATCHED_PATTERN=$(echo "$PATTERNS" | while IFS= read -r pattern; do
618
+ if echo "$COMMAND" | grep -qE "$pattern"; then
619
+ echo "$pattern"
620
+ break
621
+ fi
622
+ done)
623
+
624
+ if [ -n "$MATCHED_PATTERN" ]; then
625
+ # Output JSON to deny the command
626
+ jq -n --arg reason "Blocked by safety hook: command matches dangerous pattern ($MATCHED_PATTERN)" '{
627
+ hookSpecificOutput: {
628
+ hookEventName: "PreToolUse",
629
+ permissionDecision: "deny",
630
+ permissionDecisionReason: $reason
631
+ }
632
+ }'
633
+ else
634
+ exit 0
635
+ fi
636
+ `;
637
+ }
638
+ // Generate .claude/settings.json with hooks configuration
639
+ function generateClaudeSettings() {
640
+ const settings = {
641
+ hooks: {
642
+ PreToolUse: [
643
+ {
644
+ matcher: "Bash",
645
+ hooks: [
646
+ {
647
+ type: "command",
648
+ command: "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous-commands.sh",
649
+ },
650
+ ],
651
+ },
652
+ ],
653
+ },
654
+ };
655
+ return JSON.stringify(settings, null, 2) + "\n";
656
+ }
547
657
  async function generateFiles(ralphDir, language, imageName, force = false, javaVersion, cliProvider, dockerConfig, claudeConfig) {
548
658
  const dockerDir = join(ralphDir, DOCKER_DIR);
549
659
  // Create docker directory
@@ -622,6 +732,62 @@ async function generateFiles(ralphDir, language, imageName, force = false, javaV
622
732
  console.log(`Created .claude/commands/${skill.name}.md`);
623
733
  }
624
734
  }
735
+ // Generate Claude Hooks for blocking dangerous commands
736
+ const hooksDir = join(projectRoot, ".claude", "hooks");
737
+ if (!existsSync(hooksDir)) {
738
+ mkdirSync(hooksDir, { recursive: true });
739
+ console.log("Created .claude/hooks/");
740
+ }
741
+ // Generate dangerous_patterns.txt (extendable list of blocked command patterns)
742
+ const patternsPath = join(hooksDir, "dangerous_patterns.txt");
743
+ if (existsSync(patternsPath) && !force) {
744
+ const overwrite = await promptConfirm(".claude/hooks/dangerous_patterns.txt already exists. Overwrite?");
745
+ if (overwrite) {
746
+ writeFileSync(patternsPath, generateDangerousPatterns());
747
+ console.log("Created .claude/hooks/dangerous_patterns.txt");
748
+ }
749
+ else {
750
+ console.log("Skipped .claude/hooks/dangerous_patterns.txt");
751
+ }
752
+ }
753
+ else {
754
+ writeFileSync(patternsPath, generateDangerousPatterns());
755
+ console.log("Created .claude/hooks/dangerous_patterns.txt");
756
+ }
757
+ // Generate block-dangerous-commands.sh hook script
758
+ const hookScriptPath = join(hooksDir, "block-dangerous-commands.sh");
759
+ if (existsSync(hookScriptPath) && !force) {
760
+ const overwrite = await promptConfirm(".claude/hooks/block-dangerous-commands.sh already exists. Overwrite?");
761
+ if (overwrite) {
762
+ writeFileSync(hookScriptPath, generateBlockDangerousCommandsHook());
763
+ chmodSync(hookScriptPath, 0o755);
764
+ console.log("Created .claude/hooks/block-dangerous-commands.sh");
765
+ }
766
+ else {
767
+ console.log("Skipped .claude/hooks/block-dangerous-commands.sh");
768
+ }
769
+ }
770
+ else {
771
+ writeFileSync(hookScriptPath, generateBlockDangerousCommandsHook());
772
+ chmodSync(hookScriptPath, 0o755);
773
+ console.log("Created .claude/hooks/block-dangerous-commands.sh");
774
+ }
775
+ // Generate .claude/settings.json with hooks configuration
776
+ const settingsPath = join(projectRoot, ".claude", "settings.json");
777
+ if (existsSync(settingsPath) && !force) {
778
+ const overwrite = await promptConfirm(".claude/settings.json already exists. Overwrite?");
779
+ if (overwrite) {
780
+ writeFileSync(settingsPath, generateClaudeSettings());
781
+ console.log("Created .claude/settings.json");
782
+ }
783
+ else {
784
+ console.log("Skipped .claude/settings.json");
785
+ }
786
+ }
787
+ else {
788
+ writeFileSync(settingsPath, generateClaudeSettings());
789
+ console.log("Created .claude/settings.json");
790
+ }
625
791
  // Save config hash for change detection
626
792
  const configForHash = {
627
793
  language,
@@ -1122,6 +1288,12 @@ FILES GENERATED:
1122
1288
  ├── docker-compose.yml Container orchestration
1123
1289
  └── .dockerignore Build exclusions
1124
1290
 
1291
+ .claude/
1292
+ ├── settings.json Hooks configuration
1293
+ └── hooks/
1294
+ ├── block-dangerous-commands.sh PreToolUse hook script
1295
+ └── dangerous_patterns.txt Extendable blocklist of patterns
1296
+
1125
1297
  AUTHENTICATION:
1126
1298
  Pro/Max users: Your ~/.claude credentials are mounted automatically.
1127
1299
  API key users: Uncomment ANTHROPIC_API_KEY in docker-compose.yml.
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, copyFileSync } from "fs";
2
2
  import { join, isAbsolute, extname } from "path";
3
3
  import { getPaths, getRalphDir, getPrdFiles } from "../utils/config.js";
4
4
  import { validatePrd, attemptRecovery, createBackup, findLatestBackup, createTemplatePrd, readPrdFile, writePrdAuto, } from "../utils/prd-validator.js";
5
- import YAML from "yaml";
5
+ import { robustYamlParse } from "../utils/prd-validator.js";
6
6
  /**
7
7
  * Resolves a backup path - can be absolute, relative, or just a filename.
8
8
  */
@@ -25,7 +25,7 @@ function resolveBackupPath(backupArg) {
25
25
  function parseBackupContent(backupPath, content) {
26
26
  const ext = extname(backupPath).toLowerCase();
27
27
  if (ext === ".yaml" || ext === ".yml") {
28
- return YAML.parse(content);
28
+ return robustYamlParse(content);
29
29
  }
30
30
  return JSON.parse(content);
31
31
  }