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.
- package/README.md +30 -0
- package/dist/commands/action.js +47 -20
- package/dist/commands/chat.d.ts +1 -1
- package/dist/commands/chat.js +325 -62
- package/dist/commands/config.js +2 -1
- package/dist/commands/daemon.d.ts +2 -5
- package/dist/commands/daemon.js +118 -49
- package/dist/commands/docker.js +110 -73
- package/dist/commands/fix-config.js +2 -1
- package/dist/commands/fix-prd.js +2 -2
- package/dist/commands/help.js +19 -3
- package/dist/commands/init.js +78 -17
- package/dist/commands/listen.js +116 -5
- package/dist/commands/logo.d.ts +5 -0
- package/dist/commands/logo.js +41 -0
- package/dist/commands/notify.js +1 -1
- package/dist/commands/once.js +19 -9
- package/dist/commands/prd.js +20 -2
- package/dist/commands/run.js +111 -27
- package/dist/commands/slack.d.ts +10 -0
- package/dist/commands/slack.js +333 -0
- package/dist/config/responder-presets.json +69 -0
- package/dist/index.js +6 -1
- package/dist/providers/discord.d.ts +82 -0
- package/dist/providers/discord.js +697 -0
- package/dist/providers/slack.d.ts +79 -0
- package/dist/providers/slack.js +715 -0
- package/dist/providers/telegram.d.ts +30 -0
- package/dist/providers/telegram.js +190 -7
- package/dist/responders/claude-code-responder.d.ts +48 -0
- package/dist/responders/claude-code-responder.js +203 -0
- package/dist/responders/cli-responder.d.ts +62 -0
- package/dist/responders/cli-responder.js +298 -0
- package/dist/responders/llm-responder.d.ts +135 -0
- package/dist/responders/llm-responder.js +582 -0
- package/dist/templates/macos-scripts.js +2 -4
- package/dist/templates/prompts.js +4 -2
- package/dist/tui/ConfigEditor.js +42 -5
- package/dist/tui/components/ArrayEditor.js +1 -1
- package/dist/tui/components/EditorPanel.js +10 -6
- package/dist/tui/components/HelpPanel.d.ts +1 -1
- package/dist/tui/components/HelpPanel.js +1 -1
- package/dist/tui/components/JsonSnippetEditor.js +8 -5
- package/dist/tui/components/KeyValueEditor.js +69 -5
- package/dist/tui/components/LLMProvidersEditor.d.ts +22 -0
- package/dist/tui/components/LLMProvidersEditor.js +357 -0
- package/dist/tui/components/ObjectEditor.js +1 -1
- package/dist/tui/components/Preview.js +1 -1
- package/dist/tui/components/RespondersEditor.d.ts +22 -0
- package/dist/tui/components/RespondersEditor.js +437 -0
- package/dist/tui/components/SectionNav.js +27 -3
- package/dist/tui/utils/presets.js +15 -2
- package/dist/utils/chat-client.d.ts +33 -4
- package/dist/utils/chat-client.js +20 -1
- package/dist/utils/config.d.ts +100 -1
- package/dist/utils/config.js +78 -1
- package/dist/utils/daemon-actions.d.ts +19 -0
- package/dist/utils/daemon-actions.js +111 -0
- package/dist/utils/daemon-client.d.ts +21 -0
- package/dist/utils/daemon-client.js +28 -1
- package/dist/utils/llm-client.d.ts +82 -0
- package/dist/utils/llm-client.js +185 -0
- package/dist/utils/message-queue.js +6 -6
- package/dist/utils/notification.d.ts +10 -2
- package/dist/utils/notification.js +111 -4
- package/dist/utils/prd-validator.js +60 -19
- package/dist/utils/prompt.js +22 -12
- package/dist/utils/responder-logger.d.ts +47 -0
- package/dist/utils/responder-logger.js +129 -0
- package/dist/utils/responder-presets.d.ts +92 -0
- package/dist/utils/responder-presets.js +156 -0
- package/dist/utils/responder.d.ts +88 -0
- package/dist/utils/responder.js +207 -0
- package/dist/utils/stream-json.js +6 -6
- package/docs/CHAT-CLIENTS.md +520 -0
- package/docs/CHAT-RESPONDERS.md +785 -0
- package/docs/DEVELOPMENT.md +25 -0
- package/docs/USEFUL_ACTIONS.md +815 -0
- package/docs/chat-architecture.md +251 -0
- package/package.json +14 -1
package/dist/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|