cralph 1.0.0-alpha.0 → 1.0.0-alpha.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.
package/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # cralph
2
2
 
3
- Claude in a loop. Point at refs, give it rules, let it cook.
3
+ <p align="center">
4
+ <img src="assets/ralph.png" alt="Ralph cooking" width="500">
5
+ </p>
6
+
7
+ Claude in a loop. Point at refs, give it a rule, let it cook.
4
8
 
5
9
  ```
6
- refs/ ──loop──> output/
7
- (source) │ (result)
10
+ refs/ ──loop──> ./
11
+ (source) │ (output in cwd)
8
12
 
9
- rules.md
13
+ rule.md
10
14
  ```
11
15
 
12
16
  ## What is Ralph?
@@ -34,37 +38,37 @@ npm install -g cralph
34
38
  ## Usage
35
39
 
36
40
  ```bash
37
- # Interactive - prompts for everything
41
+ # Run - auto-detects ralph.paths.json in cwd
38
42
  cralph
39
43
 
40
- # With flags
41
- cralph --refs ./source --rules ./rules.md --output ./out
44
+ # First run (no config) - interactive mode generates ralph.paths.json
45
+ cralph
42
46
 
43
- # With config file
44
- cralph --paths-file ralph.paths.json
47
+ # Override with flags
48
+ cralph --refs ./source --rule ./rule.md --output .
45
49
  ```
46
50
 
47
51
  ## Path Selection
48
52
 
49
- When prompted, choose how to specify each path:
53
+ Simple multiselect for all paths:
50
54
 
51
- | Mode | What it does |
52
- |------|--------------|
53
- | Select from cwd | Pick directories/files interactively |
54
- | Manual | Type the path |
55
- | Paths file | Load from JSON config |
55
+ - **Space** to toggle selection
56
+ - **Enter** to confirm
57
+ - **Ctrl+C** to exit
58
+ - Shows all directories up to 3 levels deep
59
+ - Pre-selects current values in edit mode
56
60
 
57
61
  ## Config File
58
62
 
59
63
  ```json
60
64
  {
61
65
  "refs": ["./refs", "./more-refs"],
62
- "rules": "./.cursor/rules/my-rules.mdc",
63
- "output": "./output"
66
+ "rule": "./.cursor/rules/my-rules.mdc",
67
+ "output": "."
64
68
  }
65
69
  ```
66
70
 
67
- Name it `ralph.paths.json` and cralph auto-detects it.
71
+ Name it `ralph.paths.json` and cralph auto-detects it. Output is typically `.` (current directory) since you'll run cralph in your repo.
68
72
 
69
73
  ## How It Works
70
74
 
@@ -73,6 +77,58 @@ Name it `ralph.paths.json` and cralph auto-detects it.
73
77
  3. Runs `claude -p --dangerously-skip-permissions` in a loop
74
78
  4. Stops when Claude outputs `<promise>COMPLETE</promise>`
75
79
 
80
+ ## Expected Behavior
81
+
82
+ **Auto-detect existing config:**
83
+ ```
84
+ ❯ Found ralph.paths.json. What would you like to do?
85
+ ● 🚀 Run with this config
86
+ ○ ✏️ Edit configuration
87
+ ```
88
+
89
+ **Interactive Mode (no config file):**
90
+ ```
91
+ ℹ Interactive configuration mode
92
+
93
+ ↑↓ Navigate • Space Toggle • Enter • Ctrl+C Exit
94
+ ❯ Select refs directories:
95
+ ◻ 📁 src
96
+ ◻ 📁 src/components
97
+ ◼ 📁 docs
98
+
99
+ ↑↓ Navigate • Space Toggle • Enter • Ctrl+C Exit
100
+ ❯ Select rule file:
101
+ ● 📄 .cursor/rules/my-rules.mdc (cursor rule)
102
+ ○ 📄 README.md
103
+
104
+ ↑↓ Navigate • Space Toggle • Enter • Ctrl+C Exit
105
+ ❯ Select output directory:
106
+ ● 📍 Current directory (.)
107
+ ○ 📁 docs
108
+ ```
109
+
110
+ **Save config after selection:**
111
+ ```
112
+ ? Save configuration to ralph.paths.json? (Y/n)
113
+ ✔ Saved ralph.paths.json
114
+ ```
115
+
116
+ **Cancellation:**
117
+ - Press `Ctrl+C` at any time to exit
118
+ - Running Claude processes are terminated cleanly
119
+
120
+ **Output Files:**
121
+ - `.ralph/ralph.log` - Session log with timestamps
122
+ - `.ralph/TODO.md` - Agent status tracker
123
+
124
+ ## Testing
125
+
126
+ ```bash
127
+ bun test
128
+ ```
129
+
130
+ Tests validate config loading, prompt building, and CLI behavior without calling Claude.
131
+
76
132
  ## Requirements
77
133
 
78
134
  - [Bun](https://bun.sh)
Binary file
package/index.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  export * from "./src/types";
3
3
  export { buildConfig, loadPathsFile, validateConfig } from "./src/paths";
4
4
  export { createPrompt, buildPrompt } from "./src/prompt";
5
- export { run } from "./src/runner";
5
+ export { run, cleanupSubprocess } from "./src/runner";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cralph",
3
- "version": "1.0.0-alpha.0",
4
- "description": "Claude in a loop. Point at refs, give it rules, let it cook.",
3
+ "version": "1.0.0-alpha.1",
4
+ "description": "Claude in a loop. Point at refs, give it a rule, let it cook.",
5
5
  "author": "mguleryuz",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -26,7 +26,8 @@
26
26
  "src",
27
27
  "index.ts",
28
28
  "README.md",
29
- "LICENSE"
29
+ "LICENSE",
30
+ "assets"
30
31
  ],
31
32
  "scripts": {
32
33
  "start": "bun run src/cli.ts"
package/src/cli.ts CHANGED
@@ -8,91 +8,146 @@ import {
8
8
  loadPathsFile,
9
9
  validateConfig,
10
10
  selectRefs,
11
- selectRules,
11
+ selectRule,
12
12
  selectOutput,
13
+ checkForPathsFile,
13
14
  } from "./paths";
14
- import { run } from "./runner";
15
+ import { run, cleanupSubprocess } from "./runner";
15
16
  import type { RalphConfig } from "./types";
16
17
 
18
+ // Graceful shutdown on Ctrl+C
19
+ function setupGracefulExit() {
20
+ let shuttingDown = false;
21
+
22
+ process.on("SIGINT", () => {
23
+ if (shuttingDown) {
24
+ // Force exit on second Ctrl+C
25
+ process.exit(1);
26
+ }
27
+ shuttingDown = true;
28
+ cleanupSubprocess();
29
+ console.log("\n");
30
+ consola.info("Cancelled.");
31
+ // Use setImmediate to ensure output is flushed
32
+ setImmediate(() => process.exit(0));
33
+ });
34
+
35
+ // Also handle SIGTERM
36
+ process.on("SIGTERM", () => {
37
+ cleanupSubprocess();
38
+ process.exit(0);
39
+ });
40
+ }
41
+
17
42
  const main = defineCommand({
18
43
  meta: {
19
44
  name: "cralph",
20
45
  version: "1.0.0",
21
- description: "Claude in a loop. Point at refs, give it rules, let it cook.",
46
+ description: "Claude in a loop. Point at refs, give it a rule, let it cook.",
22
47
  },
23
48
  args: {
24
49
  refs: {
25
50
  type: "string",
26
51
  description: "Comma-separated refs paths (source material)",
52
+ valueHint: "path1,path2",
53
+ alias: "r",
27
54
  required: false,
28
55
  },
29
- rules: {
56
+ rule: {
30
57
  type: "string",
31
- description: "Path to rules file (.mdc or .md)",
58
+ description: "Path to rule file (.mdc or .md)",
59
+ valueHint: "rule.md",
60
+ alias: "u",
32
61
  required: false,
33
62
  },
34
63
  output: {
35
64
  type: "string",
36
- description: "Output directory",
65
+ description: "Output directory where results will be written",
66
+ valueHint: ".",
67
+ alias: "o",
37
68
  required: false,
38
69
  },
39
- "paths-file": {
40
- type: "string",
41
- description: "Path to configuration file (JSON)",
70
+ help: {
71
+ type: "boolean",
72
+ description: "Show this help message",
73
+ alias: "h",
42
74
  required: false,
43
75
  },
44
76
  },
45
77
  async run({ args }) {
78
+ setupGracefulExit();
46
79
  const cwd = process.cwd();
47
80
  let config: RalphConfig;
48
81
 
49
82
  try {
50
- // If paths-file is provided, use it
51
- if (args["paths-file"]) {
52
- const pathsFilePath = resolve(cwd, args["paths-file"]);
53
- consola.info(`Loading config from ${pathsFilePath}`);
54
- const loaded = await loadPathsFile(pathsFilePath);
83
+ // Check for existing paths file in cwd
84
+ const pathsFileResult = await checkForPathsFile(cwd);
85
+
86
+ if (pathsFileResult?.action === "run") {
87
+ // Use existing config file
88
+ consola.info(`Loading config from ${pathsFileResult.path}`);
89
+ const loaded = await loadPathsFile(pathsFileResult.path);
55
90
  config = {
56
91
  refs: loaded.refs.map((r) => resolve(cwd, r)),
57
- rules: resolve(cwd, loaded.rules),
92
+ rule: resolve(cwd, loaded.rule),
58
93
  output: resolve(cwd, loaded.output),
59
94
  };
60
- }
61
- // If all args are provided via CLI flags
62
- else if (args.refs && args.rules && args.output) {
63
- config = {
64
- refs: args.refs.split(",").map((r) => resolve(cwd, r.trim())),
65
- rules: resolve(cwd, args.rules),
66
- output: resolve(cwd, args.output),
67
- };
68
- }
69
- // Interactive mode - some or no args provided
70
- else {
71
- consola.info("Interactive configuration mode\n");
72
-
73
- // Use provided args or prompt for missing ones
74
- let refs: string[];
75
- if (args.refs) {
76
- refs = args.refs.split(",").map((r) => resolve(cwd, r.trim()));
95
+ } else {
96
+ // Load existing config for edit mode defaults
97
+ let existingConfig: RalphConfig | null = null;
98
+ if (pathsFileResult?.action === "edit") {
99
+ consola.info("Edit configuration");
100
+ const candidates = ["ralph.paths.json", ".ralph.paths.json", "paths.json"];
101
+ for (const candidate of candidates) {
102
+ const filePath = resolve(cwd, candidate);
103
+ const file = Bun.file(filePath);
104
+ if (await file.exists()) {
105
+ const loaded = await loadPathsFile(filePath);
106
+ existingConfig = {
107
+ refs: loaded.refs.map((r) => resolve(cwd, r)),
108
+ rule: resolve(cwd, loaded.rule),
109
+ output: resolve(cwd, loaded.output),
110
+ };
111
+ break;
112
+ }
113
+ }
77
114
  } else {
78
- refs = await selectRefs(cwd);
115
+ consola.info("Interactive configuration mode");
79
116
  }
80
117
 
81
- let rules: string;
82
- if (args.rules) {
83
- rules = resolve(cwd, args.rules);
84
- } else {
85
- rules = await selectRules(cwd);
86
- }
118
+ // Interactive selection
119
+ const refs = args.refs
120
+ ? args.refs.split(",").map((r) => resolve(cwd, r.trim()))
121
+ : await selectRefs(cwd, existingConfig?.refs);
122
+
123
+ const rule = args.rule
124
+ ? resolve(cwd, args.rule)
125
+ : await selectRule(cwd, existingConfig?.rule);
126
+
127
+ const output = args.output
128
+ ? resolve(cwd, args.output)
129
+ : await selectOutput(cwd, existingConfig?.output);
87
130
 
88
- let output: string;
89
- if (args.output) {
90
- output = resolve(cwd, args.output);
91
- } else {
92
- output = await selectOutput(cwd);
93
- }
131
+ config = { refs, rule, output };
132
+
133
+ // Offer to save config
134
+ const saveConfig = await consola.prompt("Save configuration to ralph.paths.json?", {
135
+ type: "confirm",
136
+ initial: true,
137
+ });
94
138
 
95
- config = { refs, rules, output };
139
+ if (saveConfig === true) {
140
+ const pathsConfig = {
141
+ refs: config.refs.map((r) => "./" + r.replace(cwd + "/", "")),
142
+ rule: "./" + config.rule.replace(cwd + "/", ""),
143
+ output: config.output === cwd ? "." : "./" + config.output.replace(cwd + "/", ""),
144
+ };
145
+ await Bun.write(
146
+ resolve(cwd, "ralph.paths.json"),
147
+ JSON.stringify(pathsConfig, null, 2)
148
+ );
149
+ consola.success("Saved ralph.paths.json");
150
+ }
96
151
  }
97
152
 
98
153
  // Validate configuration
@@ -102,7 +157,7 @@ const main = defineCommand({
102
157
  // Show config summary
103
158
  consola.info("Configuration:");
104
159
  consola.info(` Refs: ${config.refs.join(", ")}`);
105
- consola.info(` Rules: ${config.rules}`);
160
+ consola.info(` Rule: ${config.rule}`);
106
161
  consola.info(` Output: ${config.output}`);
107
162
  console.log();
108
163
 
@@ -120,6 +175,13 @@ const main = defineCommand({
120
175
  // Run the main loop
121
176
  await run(config);
122
177
  } catch (error) {
178
+ // Handle graceful cancellation
179
+ if (error instanceof Error && error.message.includes("cancelled")) {
180
+ console.log();
181
+ consola.info("Cancelled.");
182
+ process.exit(0);
183
+ }
184
+
123
185
  if (error instanceof Error) {
124
186
  consola.error(error.message);
125
187
  } else {
package/src/paths.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { consola } from "consola";
2
- import { resolve, join, basename } from "path";
2
+ import { resolve, join } from "path";
3
3
  import { readdir, stat } from "fs/promises";
4
- import type { PathSelectionMode, PathsFileConfig, RalphConfig } from "./types";
4
+ import type { PathsFileConfig, RalphConfig } from "./types";
5
+
6
+ // Dim text helper
7
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
8
+ const CONTROLS = dim("↑↓ Navigate • Space Toggle • Enter • Ctrl+C Exit");
5
9
 
6
10
  /**
7
11
  * List directories in a given path
@@ -13,6 +17,52 @@ async function listDirectories(basePath: string): Promise<string[]> {
13
17
  .map((e) => e.name);
14
18
  }
15
19
 
20
+ /**
21
+ * Directories to exclude from listing
22
+ */
23
+ const EXCLUDED_DIRS = [
24
+ "node_modules",
25
+ "dist",
26
+ "build",
27
+ ".git",
28
+ ".next",
29
+ ".nuxt",
30
+ ".output",
31
+ "coverage",
32
+ "__pycache__",
33
+ "vendor",
34
+ ".cache",
35
+ ];
36
+
37
+ /**
38
+ * List directories recursively up to a certain depth
39
+ */
40
+ async function listDirectoriesRecursive(
41
+ basePath: string,
42
+ maxDepth: number = 3
43
+ ): Promise<string[]> {
44
+ const results: string[] = [];
45
+
46
+ async function walk(dir: string, depth: number) {
47
+ if (depth > maxDepth) return;
48
+
49
+ const entries = await readdir(dir, { withFileTypes: true });
50
+ for (const entry of entries) {
51
+ // Skip hidden and excluded directories
52
+ if (!entry.isDirectory()) continue;
53
+ if (entry.name.startsWith(".")) continue;
54
+ if (EXCLUDED_DIRS.includes(entry.name)) continue;
55
+
56
+ const fullPath = join(dir, entry.name);
57
+ results.push(fullPath);
58
+ await walk(fullPath, depth + 1);
59
+ }
60
+ }
61
+
62
+ await walk(basePath, 1);
63
+ return results;
64
+ }
65
+
16
66
  /**
17
67
  * List files matching patterns in a directory (recursive)
18
68
  */
@@ -26,7 +76,10 @@ async function listFilesRecursive(
26
76
  const entries = await readdir(dir, { withFileTypes: true });
27
77
  for (const entry of entries) {
28
78
  const fullPath = join(dir, entry.name);
29
- if (entry.isDirectory() && !entry.name.startsWith(".")) {
79
+ if (entry.isDirectory()) {
80
+ // Skip hidden and excluded directories
81
+ if (entry.name.startsWith(".")) continue;
82
+ if (EXCLUDED_DIRS.includes(entry.name)) continue;
30
83
  await walk(fullPath);
31
84
  } else if (entry.isFile()) {
32
85
  if (extensions.some((ext) => entry.name.endsWith(ext))) {
@@ -40,26 +93,6 @@ async function listFilesRecursive(
40
93
  return results;
41
94
  }
42
95
 
43
- /**
44
- * Prompt user for path selection mode
45
- */
46
- async function askSelectionMode(label: string): Promise<PathSelectionMode> {
47
- const mode = await consola.prompt(`How would you like to specify ${label}?`, {
48
- type: "select",
49
- options: [
50
- { label: "Select from current directory", value: "select" },
51
- { label: "Enter path manually", value: "manual" },
52
- { label: "Use paths file", value: "file" },
53
- ],
54
- });
55
-
56
- if (typeof mode === "symbol") {
57
- throw new Error("Selection cancelled");
58
- }
59
-
60
- return mode as PathSelectionMode;
61
- }
62
-
63
96
  /**
64
97
  * Load configuration from a paths file
65
98
  */
@@ -73,143 +106,149 @@ export async function loadPathsFile(filePath: string): Promise<PathsFileConfig>
73
106
  }
74
107
 
75
108
  /**
76
- * Prompt user to select refs directories
109
+ * Prompt user to select refs directories (simple multiselect)
77
110
  */
78
- export async function selectRefs(cwd: string): Promise<string[]> {
79
- const mode = await askSelectionMode("refs (reference material)");
80
-
81
- if (mode === "manual") {
82
- const input = await consola.prompt(
83
- "Enter refs paths (comma-separated):",
84
- { type: "text" }
85
- );
86
- if (typeof input === "symbol") throw new Error("Selection cancelled");
87
- return input
88
- .split(",")
89
- .map((p) => resolve(cwd, p.trim()))
90
- .filter(Boolean);
111
+ export async function selectRefs(cwd: string, defaults?: string[]): Promise<string[]> {
112
+ // Get all directories up to 3 levels deep
113
+ const allDirs = await listDirectoriesRecursive(cwd, 3);
114
+
115
+ if (allDirs.length === 0) {
116
+ consola.warn("No directories found");
117
+ throw new Error("No directories available to select");
91
118
  }
92
119
 
93
- if (mode === "select") {
94
- const dirs = await listDirectories(cwd);
95
- if (dirs.length === 0) {
96
- consola.warn("No directories found in current directory");
97
- return selectRefs(cwd); // retry
98
- }
120
+ // Convert to relative paths for display
121
+ const options = allDirs.map((d) => {
122
+ const relative = d.replace(cwd + "/", "");
123
+ const isDefault = defaults?.includes(d);
124
+ return {
125
+ label: `📁 ${relative}`,
126
+ value: d,
127
+ hint: isDefault ? "current" : undefined,
128
+ };
129
+ });
99
130
 
100
- const selected = await consola.prompt("Select refs directories:", {
101
- type: "multiselect",
102
- options: dirs.map((d) => ({ label: d, value: d })),
103
- });
131
+ // Get initial selections (indices of defaults)
132
+ const initialValues = defaults?.filter((d) => allDirs.includes(d)) || [];
104
133
 
105
- if (typeof selected === "symbol") throw new Error("Selection cancelled");
106
- return (selected as string[]).map((d) => resolve(cwd, d));
107
- }
134
+ console.log(CONTROLS);
135
+ const selected = await consola.prompt("Select refs directories:", {
136
+ type: "multiselect",
137
+ options,
138
+ initial: initialValues,
139
+ });
108
140
 
109
- // file mode - will be handled at config level
110
- throw new Error("Use loadPathsFile for file-based configuration");
141
+ // Handle cancel (symbol) or empty result
142
+ if (typeof selected === "symbol" || !selected || (Array.isArray(selected) && selected.length === 0)) {
143
+ throw new Error("Selection cancelled");
144
+ }
145
+
146
+ return selected as string[];
111
147
  }
112
148
 
113
149
  /**
114
- * Prompt user to select a rules file
150
+ * Prompt user to select a rule file
115
151
  */
116
- export async function selectRules(cwd: string): Promise<string> {
117
- const mode = await askSelectionMode("rules file");
118
-
119
- if (mode === "manual") {
120
- const input = await consola.prompt("Enter rules file path:", {
121
- type: "text",
122
- });
123
- if (typeof input === "symbol") throw new Error("Selection cancelled");
124
- return resolve(cwd, input.trim());
152
+ export async function selectRule(cwd: string, defaultRule?: string): Promise<string> {
153
+ const files = await listFilesRecursive(cwd, [".mdc", ".md"]);
154
+ if (files.length === 0) {
155
+ consola.warn("No .mdc or .md files found");
156
+ throw new Error("No rule files available to select");
125
157
  }
126
158
 
127
- if (mode === "select") {
128
- const files = await listFilesRecursive(cwd, [".mdc", ".md"]);
129
- if (files.length === 0) {
130
- consola.warn("No .mdc or .md files found");
131
- return selectRules(cwd); // retry
132
- }
133
-
134
- // Show relative paths for readability
135
- const options = files.map((f) => ({
136
- label: f.replace(cwd + "/", ""),
137
- value: f,
138
- }));
159
+ // Show relative paths for readability
160
+ const options = files.map((f) => ({
161
+ label: `📄 ${f.replace(cwd + "/", "")}`,
162
+ value: f,
163
+ hint: f === defaultRule ? "current" : (f.endsWith(".mdc") ? "cursor rule" : "markdown"),
164
+ }));
139
165
 
140
- const selected = await consola.prompt("Select rules file:", {
141
- type: "select",
142
- options,
143
- });
166
+ // Find index of default for initial selection
167
+ const initialIndex = defaultRule ? files.findIndex((f) => f === defaultRule) : 0;
144
168
 
145
- if (typeof selected === "symbol") throw new Error("Selection cancelled");
146
- return selected as string;
147
- }
169
+ console.log(CONTROLS);
170
+ const selected = await consola.prompt("Select rule file:", {
171
+ type: "select",
172
+ options,
173
+ initial: initialIndex >= 0 ? initialIndex : 0,
174
+ });
148
175
 
149
- throw new Error("Use loadPathsFile for file-based configuration");
176
+ if (typeof selected === "symbol") throw new Error("Selection cancelled");
177
+ return selected as string;
150
178
  }
151
179
 
152
180
  /**
153
181
  * Prompt user to select output directory
154
182
  */
155
- export async function selectOutput(cwd: string): Promise<string> {
156
- const mode = await askSelectionMode("output directory");
157
-
158
- if (mode === "manual") {
159
- const input = await consola.prompt("Enter output directory path:", {
160
- type: "text",
161
- default: "./docs",
162
- });
163
- if (typeof input === "symbol") throw new Error("Selection cancelled");
164
- return resolve(cwd, input.trim());
183
+ export async function selectOutput(cwd: string, defaultOutput?: string): Promise<string> {
184
+ const dirs = await listDirectories(cwd);
185
+
186
+ // Determine default value for matching
187
+ const defaultDir = defaultOutput === cwd ? "." : defaultOutput?.replace(cwd + "/", "");
188
+
189
+ const options = [
190
+ { label: "📍 Current directory (.)", value: ".", hint: defaultDir === "." ? "current" : "Output here" },
191
+ ...dirs.map((d) => ({
192
+ label: `📁 ${d}`,
193
+ value: d,
194
+ hint: d === defaultDir ? "current" : undefined,
195
+ })),
196
+ ];
197
+
198
+ // Find initial index
199
+ let initialIndex = 0;
200
+ if (defaultDir) {
201
+ const idx = defaultDir === "." ? 0 : dirs.findIndex((d) => d === defaultDir) + 1;
202
+ if (idx >= 0) initialIndex = idx;
165
203
  }
166
204
 
167
- if (mode === "select") {
168
- const dirs = await listDirectories(cwd);
169
- const options = [
170
- { label: "(create new)", value: "__new__" },
171
- ...dirs.map((d) => ({ label: d, value: d })),
172
- ];
173
-
174
- const selected = await consola.prompt("Select output directory:", {
175
- type: "select",
176
- options,
177
- });
178
-
179
- if (typeof selected === "symbol") throw new Error("Selection cancelled");
180
-
181
- if (selected === "__new__") {
182
- const newDir = await consola.prompt("Enter new directory name:", {
183
- type: "text",
184
- default: "docs",
185
- });
186
- if (typeof newDir === "symbol") throw new Error("Selection cancelled");
187
- return resolve(cwd, newDir.trim());
188
- }
205
+ console.log(CONTROLS);
206
+ const selected = await consola.prompt("Select output directory:", {
207
+ type: "select",
208
+ options,
209
+ initial: initialIndex,
210
+ });
189
211
 
190
- return resolve(cwd, selected as string);
212
+ if (typeof selected === "symbol") throw new Error("Selection cancelled");
213
+
214
+ if (selected === ".") {
215
+ return cwd;
191
216
  }
192
217
 
193
- throw new Error("Use loadPathsFile for file-based configuration");
218
+ return resolve(cwd, selected as string);
194
219
  }
195
220
 
196
221
  /**
197
222
  * Check if a paths file exists and offer to use it
223
+ * Returns: { action: "run", path: string } | { action: "edit" } | null
198
224
  */
199
- export async function checkForPathsFile(cwd: string): Promise<string | null> {
225
+ export async function checkForPathsFile(cwd: string): Promise<{ action: "run"; path: string } | { action: "edit" } | null> {
200
226
  const candidates = ["ralph.paths.json", ".ralph.paths.json", "paths.json"];
201
227
 
202
228
  for (const candidate of candidates) {
203
229
  const filePath = join(cwd, candidate);
204
230
  const file = Bun.file(filePath);
205
231
  if (await file.exists()) {
206
- const useIt = await consola.prompt(
207
- `Found ${candidate}. Use it for configuration?`,
208
- { type: "confirm", initial: true }
232
+ console.log(CONTROLS);
233
+ const action = await consola.prompt(
234
+ `Found ${candidate}. What would you like to do?`,
235
+ {
236
+ type: "select",
237
+ options: [
238
+ { label: "🚀 Run with this config", value: "run" },
239
+ { label: "✏️ Edit configuration", value: "edit" },
240
+ ],
241
+ }
209
242
  );
210
- if (useIt === true) {
211
- return filePath;
243
+
244
+ if (typeof action === "symbol") {
245
+ throw new Error("Selection cancelled");
246
+ }
247
+
248
+ if (action === "run") {
249
+ return { action: "run", path: filePath };
212
250
  }
251
+ return { action: "edit" };
213
252
  }
214
253
  }
215
254
 
@@ -229,10 +268,10 @@ export async function validateConfig(config: RalphConfig): Promise<void> {
229
268
  }
230
269
  }
231
270
 
232
- // Check rules file
233
- const rulesFile = Bun.file(config.rules);
234
- if (!(await rulesFile.exists())) {
235
- throw new Error(`Rules file does not exist: ${config.rules}`);
271
+ // Check rule file
272
+ const ruleFile = Bun.file(config.rule);
273
+ if (!(await ruleFile.exists())) {
274
+ throw new Error(`Rule file does not exist: ${config.rule}`);
236
275
  }
237
276
 
238
277
  // Output directory will be created if needed
@@ -249,15 +288,15 @@ export async function buildConfig(cwd: string): Promise<RalphConfig> {
249
288
  const loaded = await loadPathsFile(pathsFile);
250
289
  return {
251
290
  refs: loaded.refs.map((r) => resolve(cwd, r)),
252
- rules: resolve(cwd, loaded.rules),
291
+ rule: resolve(cwd, loaded.rule),
253
292
  output: resolve(cwd, loaded.output),
254
293
  };
255
294
  }
256
295
 
257
296
  // Interactive selection
258
297
  const refs = await selectRefs(cwd);
259
- const rules = await selectRules(cwd);
298
+ const rule = await selectRule(cwd);
260
299
  const output = await selectOutput(cwd);
261
300
 
262
- return { refs, rules, output };
301
+ return { refs, rule, output };
263
302
  }
package/src/prompt.ts CHANGED
@@ -49,11 +49,11 @@ ${rulesContent}
49
49
  }
50
50
 
51
51
  /**
52
- * Read rules file and build complete prompt
52
+ * Read rule file and build complete prompt
53
53
  */
54
54
  export async function createPrompt(config: RalphConfig): Promise<string> {
55
- const rulesFile = Bun.file(config.rules);
56
- const rulesContent = await rulesFile.text();
55
+ const ruleFile = Bun.file(config.rule);
56
+ const ruleContent = await ruleFile.text();
57
57
 
58
- return buildPrompt(config, rulesContent);
58
+ return buildPrompt(config, ruleContent);
59
59
  }
package/src/runner.ts CHANGED
@@ -85,6 +85,23 @@ async function countRefs(refs: string[]): Promise<number> {
85
85
  return count;
86
86
  }
87
87
 
88
+ // Track current subprocess for cleanup
89
+ let currentProc: ReturnType<typeof Bun.spawn> | null = null;
90
+
91
+ /**
92
+ * Kill any running subprocess on exit
93
+ */
94
+ export function cleanupSubprocess() {
95
+ if (currentProc) {
96
+ try {
97
+ currentProc.kill();
98
+ } catch {
99
+ // Process may have already exited
100
+ }
101
+ currentProc = null;
102
+ }
103
+ }
104
+
88
105
  /**
89
106
  * Run a single Claude iteration
90
107
  */
@@ -105,11 +122,15 @@ async function runIteration(
105
122
  stderr: "pipe",
106
123
  cwd,
107
124
  });
125
+
126
+ currentProc = proc;
108
127
 
109
128
  // Collect output
110
129
  const stdout = await new Response(proc.stdout).text();
111
130
  const stderr = await new Response(proc.stderr).text();
112
131
  const exitCode = await proc.exited;
132
+
133
+ currentProc = null;
113
134
 
114
135
  const output = stdout + stderr;
115
136
 
package/src/types.ts CHANGED
@@ -1,14 +1,9 @@
1
- /**
2
- * Path selection mode for CLI prompts
3
- */
4
- export type PathSelectionMode = "manual" | "select" | "file";
5
-
6
1
  /**
7
2
  * Configuration loaded from a paths file (e.g., ralph.paths.json)
8
3
  */
9
4
  export interface PathsFileConfig {
10
5
  refs: string[];
11
- rules: string;
6
+ rule: string;
12
7
  output: string;
13
8
  }
14
9
 
@@ -18,8 +13,8 @@ export interface PathsFileConfig {
18
13
  export interface RalphConfig {
19
14
  /** Paths to reference material directories/files */
20
15
  refs: string[];
21
- /** Path to the rules file (.mdc or .md) */
22
- rules: string;
16
+ /** Path to the rule file (.mdc or .md) */
17
+ rule: string;
23
18
  /** Output directory for generated docs */
24
19
  output: string;
25
20
  }