ralph-cli-sandboxed 0.4.0 → 0.4.2

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.
Files changed (80) hide show
  1. package/README.md +30 -0
  2. package/dist/commands/action.js +47 -20
  3. package/dist/commands/chat.d.ts +1 -1
  4. package/dist/commands/chat.js +325 -62
  5. package/dist/commands/config.js +2 -1
  6. package/dist/commands/daemon.d.ts +2 -5
  7. package/dist/commands/daemon.js +118 -49
  8. package/dist/commands/docker.js +110 -73
  9. package/dist/commands/fix-config.js +2 -1
  10. package/dist/commands/fix-prd.js +2 -2
  11. package/dist/commands/help.js +19 -3
  12. package/dist/commands/init.js +78 -17
  13. package/dist/commands/listen.js +116 -5
  14. package/dist/commands/logo.d.ts +5 -0
  15. package/dist/commands/logo.js +41 -0
  16. package/dist/commands/notify.js +1 -1
  17. package/dist/commands/once.js +19 -9
  18. package/dist/commands/prd.js +20 -2
  19. package/dist/commands/run.js +111 -27
  20. package/dist/commands/slack.d.ts +10 -0
  21. package/dist/commands/slack.js +333 -0
  22. package/dist/config/responder-presets.json +69 -0
  23. package/dist/index.js +6 -1
  24. package/dist/providers/discord.d.ts +82 -0
  25. package/dist/providers/discord.js +697 -0
  26. package/dist/providers/slack.d.ts +79 -0
  27. package/dist/providers/slack.js +715 -0
  28. package/dist/providers/telegram.d.ts +30 -0
  29. package/dist/providers/telegram.js +190 -7
  30. package/dist/responders/claude-code-responder.d.ts +48 -0
  31. package/dist/responders/claude-code-responder.js +203 -0
  32. package/dist/responders/cli-responder.d.ts +62 -0
  33. package/dist/responders/cli-responder.js +298 -0
  34. package/dist/responders/llm-responder.d.ts +135 -0
  35. package/dist/responders/llm-responder.js +582 -0
  36. package/dist/templates/macos-scripts.js +2 -4
  37. package/dist/templates/prompts.js +4 -2
  38. package/dist/tui/ConfigEditor.js +42 -5
  39. package/dist/tui/components/ArrayEditor.js +1 -1
  40. package/dist/tui/components/EditorPanel.js +10 -6
  41. package/dist/tui/components/HelpPanel.d.ts +1 -1
  42. package/dist/tui/components/HelpPanel.js +1 -1
  43. package/dist/tui/components/JsonSnippetEditor.js +8 -5
  44. package/dist/tui/components/KeyValueEditor.js +69 -5
  45. package/dist/tui/components/LLMProvidersEditor.d.ts +22 -0
  46. package/dist/tui/components/LLMProvidersEditor.js +357 -0
  47. package/dist/tui/components/ObjectEditor.js +1 -1
  48. package/dist/tui/components/Preview.js +1 -1
  49. package/dist/tui/components/RespondersEditor.d.ts +22 -0
  50. package/dist/tui/components/RespondersEditor.js +437 -0
  51. package/dist/tui/components/SectionNav.js +27 -3
  52. package/dist/tui/utils/presets.js +15 -2
  53. package/dist/utils/chat-client.d.ts +33 -4
  54. package/dist/utils/chat-client.js +20 -1
  55. package/dist/utils/config.d.ts +100 -1
  56. package/dist/utils/config.js +78 -1
  57. package/dist/utils/daemon-actions.d.ts +19 -0
  58. package/dist/utils/daemon-actions.js +111 -0
  59. package/dist/utils/daemon-client.d.ts +21 -0
  60. package/dist/utils/daemon-client.js +28 -1
  61. package/dist/utils/llm-client.d.ts +82 -0
  62. package/dist/utils/llm-client.js +185 -0
  63. package/dist/utils/message-queue.js +6 -6
  64. package/dist/utils/notification.d.ts +10 -2
  65. package/dist/utils/notification.js +111 -4
  66. package/dist/utils/prd-validator.js +60 -19
  67. package/dist/utils/prompt.js +22 -12
  68. package/dist/utils/responder-logger.d.ts +47 -0
  69. package/dist/utils/responder-logger.js +129 -0
  70. package/dist/utils/responder-presets.d.ts +92 -0
  71. package/dist/utils/responder-presets.js +156 -0
  72. package/dist/utils/responder.d.ts +88 -0
  73. package/dist/utils/responder.js +207 -0
  74. package/dist/utils/stream-json.js +6 -6
  75. package/docs/CHAT-CLIENTS.md +520 -0
  76. package/docs/CHAT-RESPONDERS.md +785 -0
  77. package/docs/DEVELOPMENT.md +25 -0
  78. package/docs/USEFUL_ACTIONS.md +815 -0
  79. package/docs/chat-architecture.md +251 -0
  80. package/package.json +14 -1
@@ -1,9 +1,9 @@
1
1
  import { spawn } from "child_process";
2
2
  import { existsSync, readFileSync, writeFileSync, unlinkSync, appendFileSync, mkdirSync } from "fs";
3
3
  import { join } from "path";
4
- import { checkFilesExist, loadConfig, loadPrompt, getPaths, getCliConfig, requireContainer } from "../utils/config.js";
4
+ import { checkFilesExist, loadConfig, loadPrompt, getPaths, getCliConfig, requireContainer, } from "../utils/config.js";
5
5
  import { resolvePromptVariables, getCliProviders } from "../templates/prompts.js";
6
- import { validatePrd, smartMerge, readPrdFile, writePrd, expandPrdFileReferences } from "../utils/prd-validator.js";
6
+ import { validatePrd, smartMerge, readPrdFile, writePrd, expandPrdFileReferences, } from "../utils/prd-validator.js";
7
7
  import { getStreamJsonParser } from "../utils/stream-json.js";
8
8
  import { sendNotificationWithDaemonEvents } from "../utils/notification.js";
9
9
  const CATEGORIES = ["ui", "feature", "bugfix", "setup", "development", "testing", "docs"];
@@ -32,10 +32,10 @@ function createFilteredPrd(prdPath, baseDir, category) {
32
32
  process.exit(1);
33
33
  }
34
34
  const items = parsed;
35
- let filteredItems = items.filter(item => item.passes === false);
35
+ let filteredItems = items.filter((item) => item.passes === false);
36
36
  // Apply category filter if specified
37
37
  if (category) {
38
- filteredItems = filteredItems.filter(item => item.category === category);
38
+ filteredItems = filteredItems.filter((item) => item.category === category);
39
39
  }
40
40
  // Expand @{filepath} references in description and steps
41
41
  const expandedItems = expandPrdFileReferences(filteredItems, baseDir);
@@ -44,7 +44,7 @@ function createFilteredPrd(prdPath, baseDir, category) {
44
44
  writeFileSync(tempPath, JSON.stringify(expandedItems, null, 2));
45
45
  return {
46
46
  tempPath,
47
- hasIncomplete: filteredItems.length > 0
47
+ hasIncomplete: filteredItems.length > 0,
48
48
  };
49
49
  }
50
50
  /**
@@ -67,7 +67,15 @@ function syncPassesFromTasks(tasksPath, prdPath) {
67
67
  }
68
68
  const tasks = tasksParsed;
69
69
  const prdContent = readFileSync(prdPath, "utf-8");
70
- const prdParsed = JSON.parse(prdContent);
70
+ let prdParsed;
71
+ try {
72
+ prdParsed = JSON.parse(prdContent);
73
+ }
74
+ catch {
75
+ console.warn("\x1b[33mWarning: prd.json contains invalid JSON - skipping sync.\x1b[0m");
76
+ console.warn("Run \x1b[36mralph fix-prd\x1b[0m after this session to repair.\n");
77
+ return { count: 0, taskNames: [] };
78
+ }
71
79
  if (!Array.isArray(prdParsed)) {
72
80
  console.warn("\x1b[33mWarning: prd.json is corrupted - skipping sync.\x1b[0m");
73
81
  console.warn("Run \x1b[36mralph fix-prd\x1b[0m after this session to repair.\n");
@@ -80,7 +88,7 @@ function syncPassesFromTasks(tasksPath, prdPath) {
80
88
  for (const task of tasks) {
81
89
  if (task.passes === true) {
82
90
  // Find matching item in prd by description
83
- const match = prd.find(item => item.description === task.description ||
91
+ const match = prd.find((item) => item.description === task.description ||
84
92
  item.description.includes(task.description) ||
85
93
  task.description.includes(item.description));
86
94
  if (match && !match.passes) {
@@ -108,9 +116,7 @@ async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig
108
116
  let jsonLogPath;
109
117
  let lineBuffer = ""; // Buffer for incomplete JSON lines
110
118
  // Build CLI arguments: config args + yolo args + model args + prompt args
111
- const cliArgs = [
112
- ...(cliConfig.args ?? []),
113
- ];
119
+ const cliArgs = [...(cliConfig.args ?? [])];
114
120
  // Only add yolo args when running in a container
115
121
  // Use yoloArgs from config if available, otherwise default to Claude's --dangerously-skip-permissions
116
122
  if (sandboxed) {
@@ -155,7 +161,7 @@ async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig
155
161
  }
156
162
  cliArgs.push(...promptArgs, promptValue);
157
163
  if (debug) {
158
- console.log(`[debug] ${cliConfig.command} ${cliArgs.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}\n`);
164
+ console.log(`[debug] ${cliConfig.command} ${cliArgs.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}\n`);
159
165
  if (jsonLogPath) {
160
166
  console.log(`[debug] Saving raw JSON to: ${jsonLogPath}\n`);
161
167
  }
@@ -246,7 +252,7 @@ async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig
246
252
  * Sleep for the specified number of milliseconds.
247
253
  */
248
254
  function sleep(ms) {
249
- return new Promise(resolve => setTimeout(resolve, ms));
255
+ return new Promise((resolve) => setTimeout(resolve, ms));
250
256
  }
251
257
  /**
252
258
  * Formats elapsed time in a human-readable format.
@@ -291,14 +297,14 @@ function countPrdItems(prdPath, category) {
291
297
  const items = parsed;
292
298
  let filteredItems = items;
293
299
  if (category) {
294
- filteredItems = items.filter(item => item.category === category);
300
+ filteredItems = items.filter((item) => item.category === category);
295
301
  }
296
- const complete = filteredItems.filter(item => item.passes === true).length;
297
- const incomplete = filteredItems.filter(item => item.passes === false).length;
302
+ const complete = filteredItems.filter((item) => item.passes === true).length;
303
+ const incomplete = filteredItems.filter((item) => item.passes === false).length;
298
304
  return {
299
305
  total: filteredItems.length,
300
306
  complete,
301
- incomplete
307
+ incomplete,
302
308
  };
303
309
  }
304
310
  /**
@@ -332,7 +338,7 @@ function validateAndRecoverPrd(prdPath, validPrd) {
332
338
  console.log("\x1b[32mRecovered: restored valid PRD structure.\x1b[0m");
333
339
  }
334
340
  if (mergeResult.warnings.length > 0) {
335
- mergeResult.warnings.forEach(w => console.log(` \x1b[33m${w}\x1b[0m`));
341
+ mergeResult.warnings.forEach((w) => console.log(` \x1b[33m${w}\x1b[0m`));
336
342
  }
337
343
  return { recovered: true, itemsUpdated: mergeResult.itemsUpdated };
338
344
  }
@@ -342,7 +348,14 @@ function validateAndRecoverPrd(prdPath, validPrd) {
342
348
  */
343
349
  function loadValidPrd(prdPath) {
344
350
  const content = readFileSync(prdPath, "utf-8");
345
- return JSON.parse(content);
351
+ try {
352
+ return JSON.parse(content);
353
+ }
354
+ catch {
355
+ console.error("\x1b[31mError: prd.json contains invalid JSON.\x1b[0m");
356
+ console.error("Run \x1b[36mralph fix-prd\x1b[0m to diagnose and repair the file.");
357
+ process.exit(1);
358
+ }
346
359
  }
347
360
  export async function run(args) {
348
361
  // Parse flags
@@ -419,13 +432,15 @@ export async function run(args) {
419
432
  // Only use provider's streamJsonArgs if defined, otherwise empty array (no special args)
420
433
  // This allows providers without JSON streaming to still have output displayed
421
434
  const streamJsonArgs = providerConfig?.streamJsonArgs ?? [];
422
- const streamJson = streamJsonConfig?.enabled ? {
423
- enabled: true,
424
- saveRawJson: streamJsonConfig.saveRawJson !== false, // default true
425
- outputDir: config.docker?.asciinema?.outputDir || ".recordings",
426
- args: streamJsonArgs,
427
- parser: getStreamJsonParser(config.cliProvider, debug),
428
- } : undefined;
435
+ const streamJson = streamJsonConfig?.enabled
436
+ ? {
437
+ enabled: true,
438
+ saveRawJson: streamJsonConfig.saveRawJson !== false, // default true
439
+ outputDir: config.docker?.asciinema?.outputDir || ".recordings",
440
+ args: streamJsonArgs,
441
+ parser: getStreamJsonParser(config.cliProvider, debug),
442
+ }
443
+ : undefined;
429
444
  // Progress tracking: stop only if no tasks complete after N iterations
430
445
  const MAX_ITERATIONS_WITHOUT_PROGRESS = 3;
431
446
  // Get requested iteration count (may be adjusted dynamically)
@@ -467,6 +482,56 @@ export async function run(args) {
467
482
  let lastCompletedCount = initialCounts.complete;
468
483
  let lastTotalCount = initialCounts.total;
469
484
  let iterationsWithoutProgress = 0;
485
+ // Create PID file to prevent multiple concurrent runs
486
+ const pidFilePath = join(paths.dir, "run.pid");
487
+ // Check if another instance is already running
488
+ if (existsSync(pidFilePath)) {
489
+ try {
490
+ const existingPid = parseInt(readFileSync(pidFilePath, "utf-8").trim(), 10);
491
+ if (!isNaN(existingPid)) {
492
+ try {
493
+ process.kill(existingPid, 0); // Check if process exists
494
+ console.error(`\x1b[31mError: Another ralph run is already running (PID ${existingPid})\x1b[0m`);
495
+ console.error("Use 'ralph stop' or '/stop' via Telegram to terminate it first.");
496
+ process.exit(1);
497
+ }
498
+ catch {
499
+ // Process doesn't exist, stale PID file - clean it up
500
+ unlinkSync(pidFilePath);
501
+ }
502
+ }
503
+ }
504
+ catch {
505
+ // Ignore errors reading PID file, proceed to overwrite
506
+ }
507
+ }
508
+ // Write our PID file
509
+ writeFileSync(pidFilePath, process.pid.toString());
510
+ // Ensure PID file is cleaned up on exit
511
+ const cleanupPidFile = () => {
512
+ try {
513
+ if (existsSync(pidFilePath)) {
514
+ const storedPid = parseInt(readFileSync(pidFilePath, "utf-8").trim(), 10);
515
+ // Only delete if it's our PID (in case another instance started)
516
+ if (storedPid === process.pid) {
517
+ unlinkSync(pidFilePath);
518
+ }
519
+ }
520
+ }
521
+ catch {
522
+ // Ignore cleanup errors
523
+ }
524
+ };
525
+ // Register cleanup handlers for various exit scenarios
526
+ process.on("exit", cleanupPidFile);
527
+ process.on("SIGINT", () => {
528
+ cleanupPidFile();
529
+ process.exit(130);
530
+ });
531
+ process.on("SIGTERM", () => {
532
+ cleanupPidFile();
533
+ process.exit(143);
534
+ });
470
535
  try {
471
536
  while (true) {
472
537
  iterationCount++;
@@ -550,6 +615,7 @@ export async function run(args) {
550
615
  command: config.notifyCommand,
551
616
  debug,
552
617
  daemonConfig: config.daemon,
618
+ chatConfig: config.chat,
553
619
  });
554
620
  break;
555
621
  }
@@ -564,6 +630,7 @@ export async function run(args) {
564
630
  command: config.notifyCommand,
565
631
  debug,
566
632
  daemonConfig: config.daemon,
633
+ chatConfig: config.chat,
567
634
  taskName,
568
635
  });
569
636
  }
@@ -598,7 +665,14 @@ export async function run(args) {
598
665
  console.log(`Status: ${progressCounts.complete}/${progressCounts.total} complete, ${progressCounts.incomplete} remaining.`);
599
666
  console.log("Check the PRD and task definitions for issues.");
600
667
  // Send notification about stopped run
601
- await sendNotificationWithDaemonEvents("run_stopped", `Ralph: Run stopped - no progress after ${MAX_ITERATIONS_WITHOUT_PROGRESS} iterations. ${progressCounts.incomplete} tasks remaining.`, { command: config.notifyCommand, debug, daemonConfig: config.daemon });
668
+ const stoppedMessage = `No progress after ${MAX_ITERATIONS_WITHOUT_PROGRESS} iterations. ${progressCounts.incomplete} tasks remaining.`;
669
+ await sendNotificationWithDaemonEvents("run_stopped", `Ralph: Run stopped - ${stoppedMessage}`, {
670
+ command: config.notifyCommand,
671
+ debug,
672
+ daemonConfig: config.daemon,
673
+ chatConfig: config.chat,
674
+ errorMessage: stoppedMessage,
675
+ });
602
676
  break;
603
677
  }
604
678
  }
@@ -617,7 +691,14 @@ export async function run(args) {
617
691
  console.error("This usually indicates a configuration error (e.g., missing API key).");
618
692
  console.error("Please check your CLI configuration and try again.");
619
693
  // Send notification about error
620
- await sendNotificationWithDaemonEvents("error", `Ralph: CLI failed ${consecutiveFailures} times with exit code ${exitCode}. Check configuration.`, { command: config.notifyCommand, debug, daemonConfig: config.daemon });
694
+ const errorMessage = `CLI failed ${consecutiveFailures} times with exit code ${exitCode}. Check configuration.`;
695
+ await sendNotificationWithDaemonEvents("error", `Ralph: ${errorMessage}`, {
696
+ command: config.notifyCommand,
697
+ debug,
698
+ daemonConfig: config.daemon,
699
+ chatConfig: config.chat,
700
+ errorMessage,
701
+ });
621
702
  break;
622
703
  }
623
704
  console.log("Continuing to next iteration...");
@@ -662,6 +743,7 @@ export async function run(args) {
662
743
  command: config.notifyCommand,
663
744
  debug,
664
745
  daemonConfig: config.daemon,
746
+ chatConfig: config.chat,
665
747
  });
666
748
  break;
667
749
  }
@@ -678,6 +760,8 @@ export async function run(args) {
678
760
  // Ignore cleanup errors
679
761
  }
680
762
  }
763
+ // Clean up PID file
764
+ cleanupPidFile();
681
765
  }
682
766
  const endTime = Date.now();
683
767
  const elapsed = formatElapsedTime(startTime, endTime);
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Slack setup command - automates Slack app creation for Ralph instances.
3
+ *
4
+ * Uses Slack's App Manifest API to programmatically create apps,
5
+ * ensuring each Ralph instance has its own dedicated Slack app.
6
+ */
7
+ /**
8
+ * Main command handler.
9
+ */
10
+ export declare function slack(args: string[]): Promise<void>;
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Slack setup command - automates Slack app creation for Ralph instances.
3
+ *
4
+ * Uses Slack's App Manifest API to programmatically create apps,
5
+ * ensuring each Ralph instance has its own dedicated Slack app.
6
+ */
7
+ import { existsSync, writeFileSync } from "fs";
8
+ import { join } from "path";
9
+ import { getRalphDir, loadConfig } from "../utils/config.js";
10
+ import { promptInput, promptConfirm } from "../utils/prompt.js";
11
+ const HELP_TEXT = `
12
+ ralph slack - Slack integration setup
13
+
14
+ Usage:
15
+ ralph slack setup Create a new Slack app for this Ralph instance
16
+ ralph slack status Show current Slack configuration
17
+ ralph slack help Show this help message
18
+
19
+ The setup command will:
20
+ 1. Create a new Slack app using the Slack App Manifest API
21
+ 2. Guide you through installing the app to your workspace
22
+ 3. Help you generate the required tokens
23
+ 4. Save the configuration to .ralph/config.json
24
+
25
+ Prerequisites:
26
+ - A Slack workspace where you have admin permissions
27
+ - A configuration token from https://api.slack.com/apps
28
+ → Click "Your App Configuration Tokens" → Generate Token
29
+ → Use the "Access Token" (not the Refresh Token) - both start with xoxe-
30
+ → This token is per-account, not per-app - you can reuse it
31
+
32
+ Why separate apps?
33
+ Slack Socket Mode only allows ONE connection per app. Using the same app
34
+ for multiple Ralph instances causes messages to be randomly delivered to
35
+ the wrong instance. Each Ralph chat needs its own Slack app.
36
+ `;
37
+ /**
38
+ * Slack App Manifest for Ralph bot.
39
+ * Includes all required scopes, slash commands, and Socket Mode support.
40
+ */
41
+ function createAppManifest(appName) {
42
+ return {
43
+ display_information: {
44
+ name: appName,
45
+ description: "Ralph AI assistant for software development",
46
+ background_color: "#1a1a2e",
47
+ },
48
+ features: {
49
+ bot_user: {
50
+ display_name: appName,
51
+ always_online: true,
52
+ },
53
+ slash_commands: [
54
+ {
55
+ command: "/ralph",
56
+ description: "Ralph AI assistant - run commands or chat with Claude",
57
+ usage_hint: "<command> [args] or <prompt for Claude>",
58
+ should_escape: false,
59
+ },
60
+ ],
61
+ },
62
+ oauth_config: {
63
+ scopes: {
64
+ bot: [
65
+ "app_mentions:read",
66
+ "chat:write",
67
+ "chat:write.public",
68
+ "commands",
69
+ "channels:history",
70
+ "groups:history",
71
+ "im:history",
72
+ "mpim:history",
73
+ "channels:read",
74
+ "groups:read",
75
+ "im:read",
76
+ "mpim:read",
77
+ "users:read",
78
+ ],
79
+ },
80
+ },
81
+ settings: {
82
+ event_subscriptions: {
83
+ bot_events: [
84
+ "message.channels",
85
+ "message.groups",
86
+ "message.im",
87
+ "message.mpim",
88
+ "app_mention",
89
+ ],
90
+ },
91
+ interactivity: {
92
+ is_enabled: true,
93
+ },
94
+ org_deploy_enabled: false,
95
+ socket_mode_enabled: true,
96
+ token_rotation_enabled: false,
97
+ },
98
+ };
99
+ }
100
+ /**
101
+ * Create a Slack app using the Manifest API.
102
+ */
103
+ async function createSlackApp(configToken, appName) {
104
+ const manifest = createAppManifest(appName);
105
+ try {
106
+ const response = await fetch("https://slack.com/api/apps.manifest.create", {
107
+ method: "POST",
108
+ headers: {
109
+ Authorization: `Bearer ${configToken}`,
110
+ "Content-Type": "application/json",
111
+ },
112
+ body: JSON.stringify({ manifest: JSON.stringify(manifest) }),
113
+ });
114
+ const data = (await response.json());
115
+ if (!data.ok) {
116
+ console.error(`\nError creating Slack app: ${data.error}`);
117
+ if (data.errors) {
118
+ console.error("Manifest errors:");
119
+ for (const err of data.errors) {
120
+ console.error(` - ${err.message} (at ${err.pointer})`);
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+ return {
126
+ appId: data.app_id,
127
+ clientId: data.credentials.client_id,
128
+ clientSecret: data.credentials.client_secret,
129
+ signingSecret: data.credentials.signing_secret,
130
+ };
131
+ }
132
+ catch (err) {
133
+ console.error(`\nNetwork error: ${err instanceof Error ? err.message : "Unknown error"}`);
134
+ return null;
135
+ }
136
+ }
137
+ /**
138
+ * Interactive setup flow for creating a new Slack app.
139
+ */
140
+ async function setupSlack() {
141
+ console.log("\n=== Ralph Slack Setup ===\n");
142
+ console.log("This wizard will create a new Slack app for your Ralph instance.");
143
+ console.log("Each Ralph instance needs its own Slack app to avoid message routing issues.\n");
144
+ // Check if config exists
145
+ const ralphDir = getRalphDir();
146
+ const configPath = join(ralphDir, "config.json");
147
+ if (!existsSync(configPath)) {
148
+ console.error("Error: .ralph/config.json not found. Run 'ralph init' first.");
149
+ process.exit(1);
150
+ }
151
+ // Step 1: Get configuration token
152
+ console.log("Step 1: Configuration Token\n");
153
+ console.log("You need a Slack configuration token to create apps programmatically.");
154
+ console.log("This token is tied to your Slack account (not per-app) - you can reuse it.");
155
+ console.log("\nGet one at: https://api.slack.com/apps");
156
+ console.log(" → Scroll down to 'Your App Configuration Tokens'");
157
+ console.log(" → Click 'Generate Token' and select your workspace");
158
+ console.log(" → Copy the 'Access Token' (not the Refresh Token)\n");
159
+ const configToken = await promptInput("Paste your configuration token (xoxe-...): ");
160
+ if (!configToken.startsWith("xoxe")) {
161
+ console.error("\nInvalid token format. Configuration tokens start with 'xoxe'.");
162
+ console.error("Note: This is different from bot tokens (xoxb-) or app tokens (xapp-).");
163
+ process.exit(1);
164
+ }
165
+ // Step 2: Choose app name
166
+ console.log("\nStep 2: App Name\n");
167
+ // Try to derive a name from the project directory
168
+ const projectDir = process.cwd().split("/").pop() || "ralph";
169
+ const suggestedName = `Ralph - ${projectDir}`;
170
+ let appName = await promptInput(`App name [${suggestedName}]: `);
171
+ if (!appName) {
172
+ appName = suggestedName;
173
+ }
174
+ // Validate app name (max 35 chars, must be unique)
175
+ if (appName.length > 35) {
176
+ appName = appName.substring(0, 35);
177
+ console.log(` (Truncated to: ${appName})`);
178
+ }
179
+ // Step 3: Create the app
180
+ console.log("\nStep 3: Creating Slack App...\n");
181
+ const appResult = await createSlackApp(configToken, appName);
182
+ if (!appResult) {
183
+ console.error("\nFailed to create Slack app. Please check your token and try again.");
184
+ process.exit(1);
185
+ }
186
+ console.log(`✓ Created Slack app: ${appName}`);
187
+ console.log(` App ID: ${appResult.appId}`);
188
+ // Step 4: Install the app
189
+ console.log("\nStep 4: Install the App to Your Workspace\n");
190
+ console.log("Open this URL in your browser to install the app:");
191
+ console.log(`\n https://api.slack.com/apps/${appResult.appId}/install-on-team\n`);
192
+ console.log("Click 'Install to Workspace' and authorize the app.");
193
+ await promptInput("Press Enter after you've installed the app...");
194
+ // Step 5: Get the Bot Token
195
+ console.log("\nStep 5: Bot Token (xoxb-...)\n");
196
+ console.log("After installation, get your Bot Token from:");
197
+ console.log(` https://api.slack.com/apps/${appResult.appId}/oauth`);
198
+ console.log("\nLook for 'Bot User OAuth Token' (starts with xoxb-).\n");
199
+ const botToken = await promptInput("Paste your Bot Token (xoxb-...): ");
200
+ if (!botToken.startsWith("xoxb-")) {
201
+ console.error("\nInvalid token format. Bot tokens start with 'xoxb-'.");
202
+ process.exit(1);
203
+ }
204
+ // Step 6: Generate App-Level Token for Socket Mode
205
+ console.log("\nStep 6: App-Level Token for Socket Mode\n");
206
+ console.log("Socket Mode requires an app-level token. Generate one at:");
207
+ console.log(` https://api.slack.com/apps/${appResult.appId}/general`);
208
+ console.log("\nScroll to 'App-Level Tokens' and click 'Generate Token and Scopes'.");
209
+ console.log(" → Name it something like 'socket-mode'");
210
+ console.log(" → Add the scope: connections:write");
211
+ console.log(" → Click 'Generate'\n");
212
+ const appToken = await promptInput("Paste your App Token (xapp-...): ");
213
+ if (!appToken.startsWith("xapp-")) {
214
+ console.error("\nInvalid token format. App tokens start with 'xapp-'.");
215
+ process.exit(1);
216
+ }
217
+ // Step 7: Get Channel ID (optional)
218
+ console.log("\nStep 7: Channel Configuration (Optional)\n");
219
+ console.log("For security, you can restrict Ralph to specific channels.");
220
+ console.log("To get a channel ID: right-click the channel → 'View channel details' → scroll down.\n");
221
+ const channelId = await promptInput("Channel ID to restrict to (leave empty for all): ");
222
+ // Step 8: Save configuration
223
+ console.log("\nStep 8: Saving Configuration...\n");
224
+ const config = loadConfig();
225
+ const updatedConfig = {
226
+ ...config,
227
+ chat: {
228
+ ...config.chat,
229
+ enabled: true,
230
+ provider: "slack",
231
+ slack: {
232
+ enabled: true,
233
+ botToken,
234
+ appToken,
235
+ signingSecret: appResult.signingSecret,
236
+ allowedChannelIds: channelId ? [channelId] : undefined,
237
+ },
238
+ },
239
+ };
240
+ writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2) + "\n");
241
+ console.log("✓ Configuration saved to .ralph/config.json\n");
242
+ // Step 9: Test connection
243
+ const shouldTest = await promptConfirm("Test the connection now?");
244
+ if (shouldTest && channelId) {
245
+ console.log("\nTesting connection...\n");
246
+ try {
247
+ // Dynamic import to avoid issues if @slack/web-api isn't installed
248
+ const dynamicImport = new Function("specifier", "return import(specifier)");
249
+ const { WebClient } = await dynamicImport("@slack/web-api");
250
+ const client = new WebClient(botToken);
251
+ // Test auth
252
+ const authResult = await client.auth.test();
253
+ console.log(`✓ Authenticated as @${authResult.user}`);
254
+ // Send test message
255
+ await client.chat.postMessage({
256
+ channel: channelId,
257
+ text: "👋 Ralph is now connected! Use `/ralph help` to see available commands.",
258
+ });
259
+ console.log(`✓ Test message sent to channel ${channelId}`);
260
+ }
261
+ catch (err) {
262
+ console.error(`\nConnection test failed: ${err instanceof Error ? err.message : "Unknown error"}`);
263
+ console.log("You can test later with: ralph chat test <channel_id>");
264
+ }
265
+ }
266
+ // Done!
267
+ console.log("\n=== Setup Complete ===\n");
268
+ console.log("Your Ralph instance now has its own Slack app!");
269
+ console.log("\nNext steps:");
270
+ console.log(" 1. Start the chat daemon: ralph chat start");
271
+ console.log(" 2. Invite the bot to your channel: /invite @" + appName);
272
+ console.log(" 3. Try a command: /ralph status");
273
+ console.log("\nImportant: Each Ralph project should have its own Slack app.");
274
+ console.log("Run 'ralph slack setup' in each project directory.\n");
275
+ }
276
+ /**
277
+ * Show current Slack configuration status.
278
+ */
279
+ function showStatus() {
280
+ console.log("\n=== Slack Configuration Status ===\n");
281
+ try {
282
+ const config = loadConfig();
283
+ const slack = config.chat?.slack;
284
+ if (!slack) {
285
+ console.log("Status: Not configured");
286
+ console.log("\nRun 'ralph slack setup' to configure Slack integration.\n");
287
+ return;
288
+ }
289
+ console.log(`Provider: ${config.chat?.provider || "not set"}`);
290
+ console.log(`Enabled: ${slack.enabled !== false ? "Yes" : "No"}`);
291
+ console.log(`Bot Token: ${slack.botToken ? maskToken(slack.botToken) : "not set"}`);
292
+ console.log(`App Token: ${slack.appToken ? maskToken(slack.appToken) : "not set"}`);
293
+ console.log(`Signing Secret: ${slack.signingSecret ? "••••••••" : "not set"}`);
294
+ console.log(`Allowed Channels: ${slack.allowedChannelIds?.join(", ") || "all"}`);
295
+ console.log();
296
+ }
297
+ catch (err) {
298
+ console.error("Error loading config:", err instanceof Error ? err.message : "Unknown error");
299
+ process.exit(1);
300
+ }
301
+ }
302
+ /**
303
+ * Mask a token for display, showing only prefix and last 4 chars.
304
+ */
305
+ function maskToken(token) {
306
+ if (token.length <= 10)
307
+ return "••••••••";
308
+ const prefix = token.substring(0, token.indexOf("-") + 1);
309
+ const suffix = token.substring(token.length - 4);
310
+ return `${prefix}••••${suffix}`;
311
+ }
312
+ /**
313
+ * Main command handler.
314
+ */
315
+ export async function slack(args) {
316
+ const subcommand = args[0];
317
+ if (!subcommand || subcommand === "help") {
318
+ console.log(HELP_TEXT);
319
+ return;
320
+ }
321
+ switch (subcommand) {
322
+ case "setup":
323
+ await setupSlack();
324
+ break;
325
+ case "status":
326
+ showStatus();
327
+ break;
328
+ default:
329
+ console.error(`Unknown subcommand: ${subcommand}`);
330
+ console.error("Run 'ralph slack help' for usage information.");
331
+ process.exit(1);
332
+ }
333
+ }