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.
- package/dist/commands/branch.js +113 -78
- package/dist/commands/chat.js +90 -30
- package/dist/commands/docker.js +172 -0
- package/dist/commands/fix-prd.js +2 -2
- package/dist/commands/help.js +2 -2
- package/dist/commands/prd.js +22 -2
- package/dist/commands/progress.js +2 -1
- package/dist/commands/run.js +17 -0
- package/dist/utils/prd-validator.d.ts +5 -0
- package/dist/utils/prd-validator.js +75 -6
- package/dist/utils/vcs-git.d.ts +36 -0
- package/dist/utils/vcs-git.js +193 -0
- package/dist/utils/vcs-jj.d.ts +36 -0
- package/dist/utils/vcs-jj.js +214 -0
- package/dist/utils/vcs.d.ts +85 -0
- package/dist/utils/vcs.js +56 -0
- package/docs/PRD-GENERATOR.md +2 -0
- package/package.json +1 -1
package/dist/commands/branch.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
|
-
import { existsSync
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
39
|
+
* Gets the base branch (the current branch of the project).
|
|
41
40
|
*/
|
|
42
41
|
function getBaseBranch() {
|
|
43
42
|
try {
|
|
44
|
-
return execSync("git
|
|
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
|
|
153
|
+
// Perform the merge into the base branch
|
|
155
154
|
try {
|
|
156
155
|
console.log(`\nMerging "${branchName}" into "${baseBranch}"...`);
|
|
157
|
-
execSync(`git
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
224
|
+
* Create a pull request for a branch using the gh CLI on the host.
|
|
226
225
|
*/
|
|
227
|
-
function
|
|
228
|
-
const
|
|
229
|
-
if (
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
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
|
|
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
|
|
440
|
+
console.error(" pr <name> Create a pull request for a branch using gh CLI");
|
|
406
441
|
process.exit(1);
|
|
407
442
|
}
|
|
408
443
|
}
|
package/dist/commands/chat.js
CHANGED
|
@@ -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 =
|
|
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> —
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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> -
|
|
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> -
|
|
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}`);
|
package/dist/commands/docker.js
CHANGED
|
@@ -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.
|
package/dist/commands/fix-prd.js
CHANGED
|
@@ -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
|
|
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
|
|
28
|
+
return robustYamlParse(content);
|
|
29
29
|
}
|
|
30
30
|
return JSON.parse(content);
|
|
31
31
|
}
|