ralph-cli-sandboxed 0.6.0 → 0.6.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.
@@ -4,9 +4,9 @@
4
4
  */
5
5
  import { existsSync, readFileSync, writeFileSync, watch } from "fs";
6
6
  import { join, basename, extname } from "path";
7
- import { spawn } from "child_process";
7
+ import { execSync, spawn } from "child_process";
8
8
  import YAML from "yaml";
9
- import { loadConfig, getRalphDir, isRunningInContainer, getPrdFiles } from "../utils/config.js";
9
+ import { loadConfig, getRalphDir, isRunningInContainer, getPrdFiles, loadBranchState, getProjectName as getConfigProjectName } from "../utils/config.js";
10
10
  import { createTelegramClient } from "../providers/telegram.js";
11
11
  import { createSlackClient } from "../providers/slack.js";
12
12
  import { createDiscordClient } from "../providers/discord.js";
@@ -163,6 +163,290 @@ function addPrdTask(description) {
163
163
  return false;
164
164
  }
165
165
  }
166
+ /**
167
+ * Get the current git branch in the project directory.
168
+ */
169
+ function getBaseBranch() {
170
+ try {
171
+ return execSync("git rev-parse --abbrev-ref HEAD", {
172
+ encoding: "utf-8",
173
+ cwd: process.cwd(),
174
+ }).trim();
175
+ }
176
+ catch {
177
+ return "main";
178
+ }
179
+ }
180
+ /**
181
+ * Check if a git branch exists.
182
+ */
183
+ function branchExists(branch) {
184
+ try {
185
+ execSync(`git rev-parse --verify "${branch}"`, {
186
+ stdio: "pipe",
187
+ cwd: process.cwd(),
188
+ });
189
+ return true;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
195
+ /**
196
+ * Convert branch name to worktree directory name.
197
+ */
198
+ function branchToWorktreeName(branch) {
199
+ const projectName = getConfigProjectName();
200
+ return `${projectName}_${branch.replace(/\//g, "-")}`;
201
+ }
202
+ /**
203
+ * Handle /branch list — show branches from PRD grouped by branch field.
204
+ */
205
+ async function handleBranchList(chatId, client, state) {
206
+ const prdFiles = getPrdFiles();
207
+ if (prdFiles.none || !prdFiles.primary) {
208
+ await client.sendMessage(chatId, `${state.projectName}: No PRD file found.`);
209
+ return;
210
+ }
211
+ const content = readFileSync(prdFiles.primary, "utf-8");
212
+ const items = parsePrdContent(prdFiles.primary, content);
213
+ if (!Array.isArray(items) || items.length === 0) {
214
+ await client.sendMessage(chatId, `${state.projectName}: No PRD items found.`);
215
+ return;
216
+ }
217
+ const activeBranch = loadBranchState();
218
+ // Group items by branch
219
+ const branchGroups = new Map();
220
+ const noBranchItems = [];
221
+ for (const item of items) {
222
+ if (item.branch) {
223
+ const group = branchGroups.get(item.branch) || [];
224
+ group.push(item);
225
+ branchGroups.set(item.branch, group);
226
+ }
227
+ else {
228
+ noBranchItems.push(item);
229
+ }
230
+ }
231
+ if (branchGroups.size === 0 && noBranchItems.length === 0) {
232
+ await client.sendMessage(chatId, `${state.projectName}: No PRD items found.`);
233
+ return;
234
+ }
235
+ const lines = [`${state.projectName}: Branches\n`];
236
+ const sortedBranches = [...branchGroups.keys()].sort();
237
+ for (const branchName of sortedBranches) {
238
+ const branchItems = branchGroups.get(branchName);
239
+ const passing = branchItems.filter((e) => e.passes === true).length;
240
+ const total = branchItems.length;
241
+ const allPassing = passing === total;
242
+ const isActive = activeBranch?.currentBranch === branchName;
243
+ const icon = allPassing ? "[OK]" : "[ ]";
244
+ const active = isActive ? " << active" : "";
245
+ lines.push(` ${icon} ${branchName} ${passing}/${total}${active}`);
246
+ }
247
+ if (noBranchItems.length > 0) {
248
+ const passing = noBranchItems.filter((e) => e.passes === true).length;
249
+ const total = noBranchItems.length;
250
+ const icon = passing === total ? "[OK]" : "[ ]";
251
+ lines.push(` ${icon} (no branch) ${passing}/${total}`);
252
+ }
253
+ await client.sendMessage(chatId, lines.join("\n"));
254
+ }
255
+ /**
256
+ * Handle /branch pr <name> — add a PRD item to create a pull request.
257
+ */
258
+ async function handleBranchPr(args, chatId, client, state) {
259
+ const branchName = args[0];
260
+ if (!branchName) {
261
+ const usage = client.provider === "slack"
262
+ ? "/ralph branch pr <branch-name>"
263
+ : "/branch pr <branch-name>";
264
+ await client.sendMessage(chatId, `${state.projectName}: Usage: ${usage}`);
265
+ return;
266
+ }
267
+ if (!branchExists(branchName)) {
268
+ await client.sendMessage(chatId, `${state.projectName}: Branch "${branchName}" does not exist.`);
269
+ return;
270
+ }
271
+ const baseBranch = getBaseBranch();
272
+ const prdFiles = getPrdFiles();
273
+ if (prdFiles.none || !prdFiles.primary) {
274
+ await client.sendMessage(chatId, `${state.projectName}: No PRD file found.`);
275
+ return;
276
+ }
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;
282
+ }
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));
298
+ }
299
+ else {
300
+ writeFileSync(prdFiles.primary, JSON.stringify(items, null, 2) + "\n");
301
+ }
302
+ await client.sendMessage(chatId, `${state.projectName}: Added PRD entry: Create PR for ${branchName} -> ${baseBranch}`);
303
+ }
304
+ /**
305
+ * Handle /branch merge <name> — merge branch into base branch.
306
+ * Skips confirmation (user explicitly typed the command in chat).
307
+ */
308
+ async function handleBranchMerge(args, chatId, client, state) {
309
+ const branchName = args[0];
310
+ if (!branchName) {
311
+ const usage = client.provider === "slack"
312
+ ? "/ralph branch merge <branch-name>"
313
+ : "/branch merge <branch-name>";
314
+ await client.sendMessage(chatId, `${state.projectName}: Usage: ${usage}`);
315
+ return;
316
+ }
317
+ if (!branchExists(branchName)) {
318
+ await client.sendMessage(chatId, `${state.projectName}: Branch "${branchName}" does not exist.`);
319
+ return;
320
+ }
321
+ const baseBranch = getBaseBranch();
322
+ const cwd = process.cwd();
323
+ try {
324
+ execSync(`git merge "${branchName}" --no-edit`, { stdio: "pipe", cwd });
325
+ await client.sendMessage(chatId, `${state.projectName}: Merged "${branchName}" into "${baseBranch}".`);
326
+ }
327
+ catch {
328
+ // Check for merge conflicts
329
+ let conflictingFiles = [];
330
+ try {
331
+ const status = execSync("git status --porcelain", { encoding: "utf-8", cwd });
332
+ conflictingFiles = status
333
+ .split("\n")
334
+ .filter((line) => line.startsWith("UU") || line.startsWith("AA") || line.startsWith("DD") ||
335
+ line.startsWith("AU") || line.startsWith("UA") || line.startsWith("DU") ||
336
+ line.startsWith("UD"))
337
+ .map((line) => line.substring(3).trim());
338
+ }
339
+ catch {
340
+ // Ignore status errors
341
+ }
342
+ if (conflictingFiles.length > 0) {
343
+ // Abort the merge
344
+ try {
345
+ execSync("git merge --abort", { stdio: "pipe", cwd });
346
+ }
347
+ catch {
348
+ // Ignore abort errors
349
+ }
350
+ await client.sendMessage(chatId, `${state.projectName}: Merge conflict! Conflicting files:\n${conflictingFiles.join("\n")}\nMerge aborted.`);
351
+ }
352
+ else {
353
+ try {
354
+ execSync("git merge --abort", { stdio: "pipe", cwd });
355
+ }
356
+ catch {
357
+ // Ignore
358
+ }
359
+ await client.sendMessage(chatId, `${state.projectName}: Merge of "${branchName}" failed. Merge aborted.`);
360
+ }
361
+ return;
362
+ }
363
+ // Clean up worktree if it exists
364
+ const dirName = branchToWorktreeName(branchName);
365
+ const config = loadConfig();
366
+ const worktreesPath = config.docker?.worktreesPath;
367
+ if (worktreesPath) {
368
+ const worktreePath = join(worktreesPath, dirName);
369
+ if (existsSync(worktreePath)) {
370
+ try {
371
+ execSync(`git worktree remove "${worktreePath}"`, { stdio: "pipe", cwd });
372
+ }
373
+ catch {
374
+ // Non-critical, ignore
375
+ }
376
+ }
377
+ }
378
+ }
379
+ /**
380
+ * Handle /branch delete <name> — delete branch, worktree, and untag PRD items.
381
+ * Skips confirmation (user explicitly typed the command in chat).
382
+ */
383
+ async function handleBranchDelete(args, chatId, client, state) {
384
+ const branchName = args[0];
385
+ if (!branchName) {
386
+ const usage = client.provider === "slack"
387
+ ? "/ralph branch delete <branch-name>"
388
+ : "/branch delete <branch-name>";
389
+ await client.sendMessage(chatId, `${state.projectName}: Usage: ${usage}`);
390
+ return;
391
+ }
392
+ if (!branchExists(branchName)) {
393
+ await client.sendMessage(chatId, `${state.projectName}: Branch "${branchName}" does not exist.`);
394
+ return;
395
+ }
396
+ const cwd = process.cwd();
397
+ const results = [];
398
+ // Step 1: Remove worktree if it exists
399
+ const dirName = branchToWorktreeName(branchName);
400
+ const config = loadConfig();
401
+ const worktreesPath = config.docker?.worktreesPath;
402
+ if (worktreesPath) {
403
+ const worktreePath = join(worktreesPath, dirName);
404
+ if (existsSync(worktreePath)) {
405
+ try {
406
+ execSync(`git worktree remove "${worktreePath}" --force`, { stdio: "pipe", cwd });
407
+ results.push("Worktree removed.");
408
+ }
409
+ catch {
410
+ results.push("Warning: Could not remove worktree.");
411
+ }
412
+ }
413
+ }
414
+ // Step 2: Delete the git branch
415
+ try {
416
+ execSync(`git branch -D "${branchName}"`, { stdio: "pipe", cwd });
417
+ results.push("Branch deleted.");
418
+ }
419
+ catch {
420
+ results.push("Warning: Could not delete git branch.");
421
+ }
422
+ // Step 3: Remove branch tag from PRD items
423
+ const prdFiles = getPrdFiles();
424
+ if (!prdFiles.none && prdFiles.primary) {
425
+ const content = readFileSync(prdFiles.primary, "utf-8");
426
+ const items = parsePrdContent(prdFiles.primary, content);
427
+ if (Array.isArray(items)) {
428
+ const taggedCount = items.filter((e) => e.branch === branchName).length;
429
+ if (taggedCount > 0) {
430
+ const updatedItems = items.map((item) => {
431
+ if (item.branch === branchName) {
432
+ const { branch: _, ...rest } = item;
433
+ return rest;
434
+ }
435
+ return item;
436
+ });
437
+ const ext = extname(prdFiles.primary).toLowerCase();
438
+ if (ext === ".yaml" || ext === ".yml") {
439
+ writeFileSync(prdFiles.primary, YAML.stringify(updatedItems));
440
+ }
441
+ else {
442
+ writeFileSync(prdFiles.primary, JSON.stringify(updatedItems, null, 2) + "\n");
443
+ }
444
+ results.push(`${taggedCount} PRD item(s) untagged.`);
445
+ }
446
+ }
447
+ }
448
+ await client.sendMessage(chatId, `${state.projectName}: Deleted "${branchName}". ${results.join(" ")}`);
449
+ }
166
450
  /**
167
451
  * Execute a shell command and return the output.
168
452
  */
@@ -428,6 +712,37 @@ async function handleCommand(command, client, config, state, debug) {
428
712
  }
429
713
  break;
430
714
  }
715
+ case "branch": {
716
+ const subCmd = args[0]?.toLowerCase();
717
+ const branchArgs = args.slice(1);
718
+ switch (subCmd) {
719
+ case "list":
720
+ await handleBranchList(chatId, client, state);
721
+ break;
722
+ case "pr":
723
+ await handleBranchPr(branchArgs, chatId, client, state);
724
+ break;
725
+ case "merge":
726
+ await handleBranchMerge(branchArgs, chatId, client, state);
727
+ break;
728
+ case "delete":
729
+ await handleBranchDelete(branchArgs, chatId, client, state);
730
+ break;
731
+ default: {
732
+ const usage = client.provider === "slack"
733
+ ? `/ralph branch list - List branches
734
+ /ralph branch pr <name> - Add PRD item to create PR
735
+ /ralph branch merge <name> - Merge branch into base
736
+ /ralph branch delete <name> - Delete branch and worktree`
737
+ : `/branch list - List branches
738
+ /branch pr <name> - Add PRD item to create PR
739
+ /branch merge <name> - Merge branch into base
740
+ /branch delete <name> - Delete branch and worktree`;
741
+ await client.sendMessage(chatId, `${state.projectName}: ${usage}`);
742
+ }
743
+ }
744
+ break;
745
+ }
431
746
  case "help": {
432
747
  const isSlack = client.provider === "slack";
433
748
  const helpText = isSlack
@@ -438,6 +753,7 @@ async function handleCommand(command, client, config, state, debug) {
438
753
  /ralph add [desc] - Add task
439
754
  /ralph exec [cmd] - Shell command
440
755
  /ralph action [name] - Run action
756
+ /ralph branch ... - Manage branches
441
757
  /ralph <prompt> - Run Claude Code`
442
758
  : `/help - This help
443
759
  /status - PRD progress
@@ -446,6 +762,7 @@ async function handleCommand(command, client, config, state, debug) {
446
762
  /add [desc] - Add task
447
763
  /exec [cmd] - Shell command
448
764
  /action [name] - Run action
765
+ /branch ... - Manage branches
449
766
  /claude [prompt] - Run Claude Code`;
450
767
  await client.sendMessage(chatId, helpText);
451
768
  break;
@@ -640,6 +957,7 @@ async function startChat(config, debug) {
640
957
  console.log(" /ralph add ... - Add new task to PRD");
641
958
  console.log(" /ralph exec ... - Execute shell command");
642
959
  console.log(" /ralph action ... - Run daemon action");
960
+ console.log(" /ralph branch ... - Manage branches");
643
961
  console.log(" /ralph <prompt> - Run Claude Code with prompt");
644
962
  }
645
963
  else {
@@ -187,26 +187,30 @@ RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/
187
187
  -a "export HISTFILE=/commandhistory/.zsh_history" \\
188
188
  -a 'alias ll="ls -la"'
189
189
 
190
- # Set custom prompt for node user (after oh-my-zsh to avoid override)
190
+ # Copy oh-my-zsh config to node user (after oh-my-zsh to avoid override)
191
191
  RUN cp -r /root/.oh-my-zsh /home/node/.oh-my-zsh && chown -R node:node /home/node/.oh-my-zsh && \\
192
192
  cp /root/.zshrc /home/node/.zshrc && chown node:node /home/node/.zshrc && \\
193
- sed -i 's|/root/.oh-my-zsh|/home/node/.oh-my-zsh|g' /home/node/.zshrc && \\
194
- echo 'PROMPT="%K{yellow}%F{black}[ralph]%f%k%K{yellow}%F{black}%d%f%k\\$ "' >> /home/node/.zshrc && \\
195
- echo '' >> /home/node/.zshrc && \\
196
- echo '# Ralph ASCII art banner' >> /home/node/.zshrc && \\
197
- echo 'if [ -z "$RALPH_BANNER_SHOWN" ]; then' >> /home/node/.zshrc && \\
198
- echo ' export RALPH_BANNER_SHOWN=1' >> /home/node/.zshrc && \\
199
- echo ' echo ""' >> /home/node/.zshrc && \\
200
- echo ' echo "\\033[38;2;255;245;157m██████╗ █████╗ ██╗ ██████╗ ██╗ ██╗ ██████╗██╗ ██╗\\033[0m"' >> /home/node/.zshrc && \\
201
- echo ' echo "\\033[38;2;255;238;88m██╔══██╗██╔══██╗██║ ██╔══██╗██║ ██║ ██╔════╝██║ ██║\\033[0m"' >> /home/node/.zshrc && \\
202
- echo ' echo "\\033[38;2;255;235;59m██████╔╝███████║██║ ██████╔╝███████║ ██║ ██║ ██║ sandboxed\\033[0m"' >> /home/node/.zshrc && \\
203
- echo ' echo "\\033[38;2;253;216;53m██╔══██╗██╔══██║██║ ██╔═══╝ ██╔══██║ ██║ ██║ ██║\\033[0m"' >> /home/node/.zshrc && \\
204
- echo ' echo "\\033[38;2;251;192;45m██║ ██║██║ ██║███████╗██║ ██║ ██║ ╚██████╗███████╗██║\\033[0m"' >> /home/node/.zshrc && \\
205
- echo ' echo "\\033[38;2;249;168;37m╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝\\033[0m"' >> /home/node/.zshrc && \\
206
- echo ' RALPH_VERSION=$(ralph --version 2>/dev/null | head -1 || echo "unknown")' >> /home/node/.zshrc && \\
207
- echo ' echo "\\033[38;5;248mv$RALPH_VERSION\\033[0m"' >> /home/node/.zshrc && \\
208
- echo ' echo ""' >> /home/node/.zshrc && \\
209
- echo 'fi' >> /home/node/.zshrc
193
+ sed -i 's|/root/.oh-my-zsh|/home/node/.oh-my-zsh|g' /home/node/.zshrc
194
+
195
+ # Set custom prompt and Ralph ASCII art banner
196
+ RUN cat >> /home/node/.zshrc <<'RALPH_BANNER'
197
+ PROMPT="%K{yellow}%F{black}[ralph]%f%k%K{yellow}%F{black}%d%f%k\\$ "
198
+
199
+ # Ralph ASCII art banner
200
+ if [ -z "$RALPH_BANNER_SHOWN" ]; then
201
+ export RALPH_BANNER_SHOWN=1
202
+ echo ""
203
+ echo "\\033[38;2;255;245;157m██████╗ █████╗ ██╗ ██████╗ ██╗ ██╗ ██████╗██╗ ██╗\\033[0m"
204
+ echo "\\033[38;2;255;238;88m██╔══██╗██╔══██╗██║ ██╔══██╗██║ ██║ ██╔════╝██║ ██║\\033[0m"
205
+ echo "\\033[38;2;255;235;59m██████╔╝███████║██║ ██████╔╝███████║ ██║ ██║ ██║ sandboxed\\033[0m"
206
+ echo "\\033[38;2;253;216;53m██╔══██╗██╔══██║██║ ██╔═══╝ ██╔══██║ ██║ ██║ ██║\\033[0m"
207
+ echo "\\033[38;2;251;192;45m██║ ██║██║ ██║███████╗██║ ██║ ██║ ╚██████╗███████╗██║\\033[0m"
208
+ echo "\\033[38;2;249;168;37m╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝\\033[0m"
209
+ RALPH_VERSION=$(ralph --version 2>/dev/null | head -1 || echo "unknown")
210
+ echo "\\033[38;5;248mv$RALPH_VERSION\\033[0m"
211
+ echo ""
212
+ fi
213
+ RALPH_BANNER
210
214
 
211
215
  ${cliSnippet}
212
216
 
@@ -475,7 +475,7 @@ export class SlackChatClient {
475
475
  // Handle the unified /ralph command
476
476
  // Subcommands: help, status, run, stop, add, exec, action
477
477
  // Anything else is treated as a prompt for Claude
478
- const knownSubcommands = ["help", "status", "run", "stop", "add", "exec", "action"];
478
+ const knownSubcommands = ["help", "status", "run", "stop", "add", "exec", "action", "branch"];
479
479
  if (this.debug) {
480
480
  console.log(`[slack] Registering command: /ralph`);
481
481
  }
@@ -38,6 +38,7 @@ export function parseCommand(text, message) {
38
38
  "start",
39
39
  "action",
40
40
  "claude",
41
+ "branch",
41
42
  ];
42
43
  // Check for slash command format: /command [args...]
43
44
  if (trimmed.startsWith("/")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-cli-sandboxed",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "AI-driven development automation CLI for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {