cralph 1.0.0-beta.1 → 1.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,7 @@ bun add -g cralph
36
36
  ## Quick Start
37
37
 
38
38
  ```bash
39
- # In an empty directory - creates starter structure
39
+ # In any directory without .ralph/ - creates starter structure
40
40
  cralph
41
41
 
42
42
  # Edit rule.md with your instructions, then run again
@@ -59,10 +59,11 @@ cralph --yes
59
59
  ## How It Works
60
60
 
61
61
  1. Checks Claude CLI auth (cached for 6 hours)
62
- 2. Loads config from `.ralph/paths.json`
63
- 3. Runs `claude -p --dangerously-skip-permissions` in a loop
64
- 4. Claude updates `.ralph/TODO.md` after each iteration
65
- 5. Stops when Claude outputs `<promise>COMPLETE</promise>`
62
+ 2. Looks for `.ralph/` in current directory only (not subdirectories)
63
+ 3. Loads config from `.ralph/paths.json` or creates starter structure
64
+ 4. Runs `claude -p --dangerously-skip-permissions` in a loop
65
+ 5. Claude updates `.ralph/TODO.md` after each iteration
66
+ 6. Stops when Claude outputs `<promise>COMPLETE</promise>`
66
67
 
67
68
  ## Config
68
69
 
@@ -102,23 +103,31 @@ Claude maintains this structure:
102
103
  Any relevant context
103
104
  ```
104
105
 
105
- ## First Run (Empty Directory)
106
+ ## First Run (No .ralph/ in cwd)
106
107
 
107
108
  ```
108
- ? No directories found. Create starter structure in /path/to/dir? (Y/n)
109
+ No .ralph/ found in /path/to/dir
110
+ ● 📦 Create starter structure
111
+ ○ ⚙️ Configure manually
112
+ ```
113
+
114
+ Select **Create starter structure** to generate the default config:
109
115
 
116
+ ```
110
117
  ℹ Created .ralph/refs/ directory
111
118
  ℹ Created .ralph/rule.md with starter template
112
119
  ℹ Created .ralph/paths.json
113
120
 
114
- ╭──────────────────────────────────────────────╮
115
- 1. Add source files to .ralph/refs/
116
- 2. Edit .ralph/rule.md with your instructions│
117
- 3. Run cralph again
118
- ╰──────────────────────────────────────────────╯
121
+ ╭─────────────────────────────────────────────────╮
122
+ 1. Add source files to .ralph/refs/
123
+ 2. Edit .ralph/rule.md with your instructions
124
+ 3. Run cralph again
125
+ ╰─────────────────────────────────────────────────╯
119
126
  ```
120
127
 
121
- Use `--yes` to skip confirmation (for CI/automation).
128
+ Select **Configure manually** to skip starter creation and pick your own refs/rule/output.
129
+
130
+ Use `--yes` to auto-create starter structure (for CI/automation).
122
131
 
123
132
  ## Prompts
124
133
 
@@ -140,11 +149,17 @@ Use `--yes` to skip confirmation (for CI/automation).
140
149
  - **Enter** - Confirm
141
150
  - **Ctrl+C** - Exit
142
151
 
143
- ## Platform Notes
152
+ ## Platform Support
153
+
154
+ cralph works on **macOS**, **Linux**, and **Windows** with platform-specific handling:
144
155
 
145
- ### macOS Protected Directories
156
+ | Platform | Protected Directories Skipped |
157
+ |----------|------------------------------|
158
+ | macOS | Library, Photos Library, Photo Booth Library |
159
+ | Linux | lost+found, proc, sys |
160
+ | Windows | System Volume Information, $Recycle.Bin, Windows |
146
161
 
147
- cralph gracefully handles macOS permission errors (`EPERM`, `EACCES`) when scanning directories. Protected locations like `~/Pictures/Photo Booth Library` or iCloud folders are silently skipped, allowing the CLI to run from any directory including root (`/`).
162
+ Permission errors (`EPERM`, `EACCES`) are handled gracefully on all platforms, allowing the CLI to run from any directory.
148
163
 
149
164
  ## Testing
150
165
 
@@ -152,7 +167,7 @@ cralph gracefully handles macOS permission errors (`EPERM`, `EACCES`) when scann
152
167
  bun test
153
168
  ```
154
169
 
155
- - **Unit tests** - Config, prompt building, CLI, access error handling
170
+ - **Unit tests** - Config, prompt building, CLI, access error handling, platform detection, shutdown state
156
171
  - **E2E tests** - Full loop with Claude (requires auth)
157
172
 
158
173
  ## Requirements
package/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // Re-export for programmatic usage
2
2
  export * from "./src/types";
3
- export { buildConfig, loadPathsFile, validateConfig } from "./src/paths";
3
+ export { loadPathsFile, validateConfig, resolvePathsConfig, toRelativePath } from "./src/paths";
4
4
  export { createPrompt, buildPrompt } from "./src/prompt";
5
- export { run, cleanupSubprocess } from "./src/runner";
5
+ export { run, checkClaudeAuth } from "./src/runner";
6
+ export { cleanupSubprocess, setShuttingDown, isShuttingDown } from "./src/state";
7
+ export { getPlatform, isClaudeInstalled, checkClaudeInstallation } from "./src/platform";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cralph",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.3",
4
4
  "description": "Claude in a loop. Point at refs, give it a rule, let it cook.",
5
5
  "author": "mguleryuz",
6
6
  "license": "MIT",
@@ -30,7 +30,8 @@
30
30
  "assets"
31
31
  ],
32
32
  "scripts": {
33
- "start": "bun run src/cli.ts"
33
+ "start": "bun run src/cli.ts",
34
+ "type-check": "tsgo --noEmit --pretty"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@types/bun": "latest"
package/src/cli.ts CHANGED
@@ -5,38 +5,41 @@ import { consola } from "consola";
5
5
  import { resolve, join } from "path";
6
6
  import { mkdir } from "fs/promises";
7
7
  import {
8
- buildConfig,
9
8
  loadPathsFile,
10
9
  validateConfig,
11
10
  selectRefs,
12
11
  selectRule,
13
12
  selectOutput,
14
13
  checkForPathsFile,
14
+ resolvePathsConfig,
15
+ toRelativePath,
15
16
  } from "./paths";
16
- import { run, cleanupSubprocess, checkClaudeAuth } from "./runner";
17
+ import { run, checkClaudeAuth } from "./runner";
17
18
  import type { RalphConfig } from "./types";
19
+ import { setShuttingDown, isShuttingDown, cleanupSubprocess, throwIfCancelled } from "./state";
20
+ import { checkClaudeInstallation } from "./platform";
18
21
 
19
22
  // Graceful shutdown on Ctrl+C
20
23
  function setupGracefulExit() {
21
- let shuttingDown = false;
24
+ const exit = (code: number) => process.exit(code);
22
25
 
23
26
  process.on("SIGINT", () => {
24
- if (shuttingDown) {
27
+ if (isShuttingDown()) {
25
28
  // Force exit on second Ctrl+C
26
- process.exit(1);
29
+ exit(1);
27
30
  }
28
- shuttingDown = true;
31
+ setShuttingDown();
29
32
  cleanupSubprocess();
30
33
  console.log("\n");
31
34
  consola.info("Cancelled.");
32
- // Use setImmediate to ensure output is flushed
33
- setImmediate(() => process.exit(0));
35
+ exit(0);
34
36
  });
35
37
 
36
38
  // Also handle SIGTERM
37
39
  process.on("SIGTERM", () => {
40
+ setShuttingDown();
38
41
  cleanupSubprocess();
39
- process.exit(0);
42
+ exit(0);
40
43
  });
41
44
  }
42
45
 
@@ -87,7 +90,17 @@ const main = defineCommand({
87
90
  let config: RalphConfig;
88
91
 
89
92
  try {
90
- // Check Claude authentication first - before any prompts
93
+ // Check Claude CLI is installed first
94
+ const claudeCheck = await checkClaudeInstallation();
95
+
96
+ if (!claudeCheck.installed) {
97
+ consola.error("Claude CLI is not installed\n");
98
+ consola.box(claudeCheck.installInstructions);
99
+ consola.info("After installing, run cralph again.");
100
+ process.exit(1);
101
+ }
102
+
103
+ // Check Claude authentication
91
104
  consola.start("Checking Claude authentication...");
92
105
  const isAuthed = await checkClaudeAuth();
93
106
 
@@ -109,11 +122,7 @@ const main = defineCommand({
109
122
  // Use existing config file
110
123
  consola.info(`Loading config from ${pathsFileResult.path}`);
111
124
  const loaded = await loadPathsFile(pathsFileResult.path);
112
- config = {
113
- refs: loaded.refs.map((r) => resolve(cwd, r)),
114
- rule: resolve(cwd, loaded.rule),
115
- output: resolve(cwd, loaded.output),
116
- };
125
+ config = resolvePathsConfig(loaded, cwd);
117
126
  } else {
118
127
  // Load existing config for edit mode defaults
119
128
  let existingConfig: RalphConfig | null = null;
@@ -123,11 +132,7 @@ const main = defineCommand({
123
132
  const file = Bun.file(filePath);
124
133
  if (await file.exists()) {
125
134
  const loaded = await loadPathsFile(filePath);
126
- existingConfig = {
127
- refs: loaded.refs.map((r) => resolve(cwd, r)),
128
- rule: resolve(cwd, loaded.rule),
129
- output: resolve(cwd, loaded.output),
130
- };
135
+ existingConfig = resolvePathsConfig(loaded, cwd);
131
136
  }
132
137
  } else {
133
138
  consola.info("Interactive configuration mode");
@@ -151,17 +156,20 @@ const main = defineCommand({
151
156
  // Offer to save config
152
157
  const saveConfig = await consola.prompt("Save configuration to .ralph/paths.json?", {
153
158
  type: "confirm",
159
+ cancel: "symbol",
154
160
  initial: true,
155
161
  });
162
+
163
+ throwIfCancelled(saveConfig);
156
164
 
157
165
  if (saveConfig === true) {
158
166
  const ralphDir = join(cwd, ".ralph");
159
167
  await mkdir(ralphDir, { recursive: true });
160
168
 
161
169
  const pathsConfig = {
162
- refs: config.refs.map((r) => "./" + r.replace(cwd + "/", "")),
163
- rule: "./" + config.rule.replace(cwd + "/", ""),
164
- output: config.output === cwd ? "." : "./" + config.output.replace(cwd + "/", ""),
170
+ refs: config.refs.map((r) => toRelativePath(r, cwd)),
171
+ rule: toRelativePath(config.rule, cwd),
172
+ output: toRelativePath(config.output, cwd),
165
173
  };
166
174
  await Bun.write(
167
175
  join(ralphDir, "paths.json"),
@@ -186,8 +194,11 @@ const main = defineCommand({
186
194
  if (!args.yes) {
187
195
  const proceed = await consola.prompt("Start processing?", {
188
196
  type: "confirm",
197
+ cancel: "symbol",
189
198
  initial: true,
190
199
  });
200
+
201
+ throwIfCancelled(proceed);
191
202
 
192
203
  if (proceed !== true) {
193
204
  consola.info("Cancelled.");
package/src/paths.ts CHANGED
@@ -1,12 +1,48 @@
1
1
  import { consola } from "consola";
2
2
  import { resolve, join } from "path";
3
3
  import { readdir, stat, mkdir } from "fs/promises";
4
+ import type { Dirent } from "fs";
4
5
  import type { PathsFileConfig, RalphConfig } from "./types";
6
+ import { isAccessError, shouldExcludeDir } from "./platform";
7
+ import { throwIfCancelled } from "./state";
8
+
9
+ // Starter rule template for new projects
10
+ const STARTER_RULE = `I want a file named hello.txt
11
+ `;
5
12
 
6
13
  // Dim text helper
7
14
  const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
8
15
  const CONTROLS = dim("↑↓ Navigate • Space Toggle • Enter • Ctrl+C Exit");
9
16
 
17
+ /**
18
+ * Convert a PathsFileConfig to a resolved RalphConfig
19
+ */
20
+ export function resolvePathsConfig(loaded: PathsFileConfig, cwd: string): RalphConfig {
21
+ return {
22
+ refs: loaded.refs.map((r) => resolve(cwd, r)),
23
+ rule: resolve(cwd, loaded.rule),
24
+ output: resolve(cwd, loaded.output),
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Convert an absolute path to a relative path for config storage
30
+ */
31
+ export function toRelativePath(absolutePath: string, cwd: string): string {
32
+ if (absolutePath === cwd) return ".";
33
+ return "./" + absolutePath.replace(cwd + "/", "");
34
+ }
35
+
36
+ /**
37
+ * Check if a directory entry should be skipped during traversal
38
+ */
39
+ function shouldSkipDirectory(entry: Dirent): boolean {
40
+ if (!entry.isDirectory()) return true;
41
+ if (entry.name.startsWith(".")) return true;
42
+ if (shouldExcludeDir(entry.name)) return true;
43
+ return false;
44
+ }
45
+
10
46
  /**
11
47
  * List directories in a given path
12
48
  */
@@ -17,7 +53,7 @@ async function listDirectories(basePath: string): Promise<string[]> {
17
53
  .filter((e) => e.isDirectory() && !e.name.startsWith("."))
18
54
  .map((e) => e.name);
19
55
  } catch (error) {
20
- // Silently skip directories we can't access (EPERM, EACCES)
56
+ // Silently skip directories we can't access
21
57
  if (isAccessError(error)) {
22
58
  return [];
23
59
  }
@@ -25,34 +61,6 @@ async function listDirectories(basePath: string): Promise<string[]> {
25
61
  }
26
62
  }
27
63
 
28
- /**
29
- * Check if an error is a permission/access error
30
- */
31
- export function isAccessError(error: unknown): boolean {
32
- if (error && typeof error === "object" && "code" in error) {
33
- const code = (error as { code: string }).code;
34
- return code === "EPERM" || code === "EACCES";
35
- }
36
- return false;
37
- }
38
-
39
- /**
40
- * Directories to exclude from listing
41
- */
42
- const EXCLUDED_DIRS = [
43
- "node_modules",
44
- "dist",
45
- "build",
46
- ".git",
47
- ".next",
48
- ".nuxt",
49
- ".output",
50
- "coverage",
51
- "__pycache__",
52
- "vendor",
53
- ".cache",
54
- ];
55
-
56
64
  /**
57
65
  * List directories recursively up to a certain depth
58
66
  */
@@ -69,7 +77,7 @@ export async function listDirectoriesRecursive(
69
77
  try {
70
78
  entries = await readdir(dir, { withFileTypes: true });
71
79
  } catch (error) {
72
- // Silently skip directories we can't access (EPERM, EACCES)
80
+ // Silently skip directories we can't access
73
81
  if (isAccessError(error)) {
74
82
  return;
75
83
  }
@@ -77,10 +85,7 @@ export async function listDirectoriesRecursive(
77
85
  }
78
86
 
79
87
  for (const entry of entries) {
80
- // Skip hidden and excluded directories
81
- if (!entry.isDirectory()) continue;
82
- if (entry.name.startsWith(".")) continue;
83
- if (EXCLUDED_DIRS.includes(entry.name)) continue;
88
+ if (shouldSkipDirectory(entry)) continue;
84
89
 
85
90
  const fullPath = join(dir, entry.name);
86
91
  results.push(fullPath);
@@ -106,7 +111,7 @@ export async function listFilesRecursive(
106
111
  try {
107
112
  entries = await readdir(dir, { withFileTypes: true });
108
113
  } catch (error) {
109
- // Silently skip directories we can't access (EPERM, EACCES)
114
+ // Silently skip directories we can't access
110
115
  if (isAccessError(error)) {
111
116
  return;
112
117
  }
@@ -116,9 +121,7 @@ export async function listFilesRecursive(
116
121
  for (const entry of entries) {
117
122
  const fullPath = join(dir, entry.name);
118
123
  if (entry.isDirectory()) {
119
- // Skip hidden and excluded directories
120
- if (entry.name.startsWith(".")) continue;
121
- if (EXCLUDED_DIRS.includes(entry.name)) continue;
124
+ if (shouldSkipDirectory(entry)) continue;
122
125
  await walk(fullPath);
123
126
  } else if (entry.isFile()) {
124
127
  if (extensions.some((ext) => entry.name.endsWith(ext))) {
@@ -179,27 +182,52 @@ export async function createStarterStructure(cwd: string): Promise<void> {
179
182
  * @param autoConfirm - If true, skip confirmation prompts
180
183
  */
181
184
  export async function selectRefs(cwd: string, defaults?: string[], autoConfirm?: boolean): Promise<string[]> {
182
- // Get all directories up to 3 levels deep
183
- let allDirs = await listDirectoriesRecursive(cwd, 3);
184
-
185
- if (allDirs.length === 0) {
185
+ // Check if .ralph/ exists in cwd - if not, offer to create starter structure
186
+ const ralphDir = join(cwd, ".ralph");
187
+ let ralphExists = false;
188
+ try {
189
+ await stat(ralphDir);
190
+ ralphExists = true;
191
+ } catch {
192
+ ralphExists = false;
193
+ }
194
+
195
+ if (!ralphExists) {
186
196
  // Ask before creating starter structure (skip if autoConfirm)
187
197
  if (!autoConfirm) {
188
- const confirm = await consola.prompt(
189
- `No directories found. Create starter structure in ${cwd}?`,
198
+ console.log(CONTROLS);
199
+ const action = await consola.prompt(
200
+ `No .ralph/ found in ${cwd}`,
190
201
  {
191
- type: "confirm",
192
- initial: true,
202
+ type: "select",
203
+ cancel: "symbol",
204
+ options: [
205
+ { label: "📦 Create starter structure", value: "create" },
206
+ { label: "⚙️ Configure manually", value: "manual" },
207
+ ],
193
208
  }
194
209
  );
195
210
 
196
- if (confirm !== true) {
197
- throw new Error("Setup cancelled");
211
+ throwIfCancelled(action);
212
+
213
+ if (action === "create") {
214
+ await createStarterStructure(cwd);
215
+ process.exit(0);
198
216
  }
217
+
218
+ // action === "manual" - continue to directory selection
219
+ } else {
220
+ // Auto-confirm mode: create starter structure
221
+ await createStarterStructure(cwd);
222
+ process.exit(0);
199
223
  }
200
-
201
- await createStarterStructure(cwd);
202
- process.exit(0);
224
+ }
225
+
226
+ // Get all directories up to 3 levels deep
227
+ let allDirs = await listDirectoriesRecursive(cwd, 3);
228
+
229
+ if (allDirs.length === 0) {
230
+ throw new Error("No directories found to select from");
203
231
  }
204
232
 
205
233
  // Convert to relative paths for display
@@ -219,21 +247,21 @@ export async function selectRefs(cwd: string, defaults?: string[], autoConfirm?:
219
247
  console.log(CONTROLS);
220
248
  const selected = await consola.prompt("Select refs directories:", {
221
249
  type: "multiselect",
250
+ cancel: "symbol",
222
251
  options,
223
252
  initial: initialValues,
224
253
  });
225
254
 
226
- // Handle cancel (symbol) or empty result
227
- if (typeof selected === "symbol" || !selected || (Array.isArray(selected) && selected.length === 0)) {
255
+ // Handle cancel (symbol), shutdown, or empty result
256
+ throwIfCancelled(selected);
257
+ if (!selected || (Array.isArray(selected) && selected.length === 0)) {
228
258
  throw new Error("Selection cancelled");
229
259
  }
230
260
 
231
- return selected as string[];
261
+ // Cast is safe: multiselect with string values returns string[]
262
+ return selected as unknown as string[];
232
263
  }
233
264
 
234
- const STARTER_RULE = `I want a file named hello.txt
235
- `;
236
-
237
265
  /**
238
266
  * Prompt user to select a rule file
239
267
  */
@@ -255,17 +283,18 @@ export async function selectRule(cwd: string, defaultRule?: string): Promise<str
255
283
  hint: f === defaultRule ? "current" : (f.endsWith(".mdc") ? "cursor rule" : "markdown"),
256
284
  }));
257
285
 
258
- // Find index of default for initial selection
259
- const initialIndex = defaultRule ? files.findIndex((f) => f === defaultRule) : 0;
286
+ // Find initial value for default selection
287
+ const initialValue = defaultRule && files.includes(defaultRule) ? defaultRule : files[0];
260
288
 
261
289
  console.log(CONTROLS);
262
290
  const selected = await consola.prompt("Select rule file:", {
263
291
  type: "select",
292
+ cancel: "symbol",
264
293
  options,
265
- initial: initialIndex >= 0 ? initialIndex : 0,
294
+ initial: initialValue,
266
295
  });
267
296
 
268
- if (typeof selected === "symbol") throw new Error("Selection cancelled");
297
+ throwIfCancelled(selected);
269
298
  return selected as string;
270
299
  }
271
300
 
@@ -287,21 +316,20 @@ export async function selectOutput(cwd: string, defaultOutput?: string): Promise
287
316
  })),
288
317
  ];
289
318
 
290
- // Find initial index
291
- let initialIndex = 0;
292
- if (defaultDir) {
293
- const idx = defaultDir === "." ? 0 : dirs.findIndex((d) => d === defaultDir) + 1;
294
- if (idx >= 0) initialIndex = idx;
295
- }
319
+ // Determine initial value for default selection
320
+ const initialValue = defaultDir && (defaultDir === "." || dirs.includes(defaultDir))
321
+ ? defaultDir
322
+ : ".";
296
323
 
297
324
  console.log(CONTROLS);
298
325
  const selected = await consola.prompt("Select output directory:", {
299
326
  type: "select",
327
+ cancel: "symbol",
300
328
  options,
301
- initial: initialIndex,
329
+ initial: initialValue,
302
330
  });
303
331
 
304
- if (typeof selected === "symbol") throw new Error("Selection cancelled");
332
+ throwIfCancelled(selected);
305
333
 
306
334
  if (selected === ".") {
307
335
  return cwd;
@@ -330,6 +358,7 @@ export async function checkForPathsFile(cwd: string, autoRun?: boolean): Promise
330
358
  `Found .ralph/paths.json. What would you like to do?`,
331
359
  {
332
360
  type: "select",
361
+ cancel: "symbol",
333
362
  options: [
334
363
  { label: "🚀 Run with this config", value: "run" },
335
364
  { label: "✏️ Edit configuration", value: "edit" },
@@ -337,9 +366,7 @@ export async function checkForPathsFile(cwd: string, autoRun?: boolean): Promise
337
366
  }
338
367
  );
339
368
 
340
- if (typeof action === "symbol") {
341
- throw new Error("Selection cancelled");
342
- }
369
+ throwIfCancelled(action);
343
370
 
344
371
  if (action === "run") {
345
372
  return { action: "run", path: filePath };
@@ -371,27 +398,3 @@ export async function validateConfig(config: RalphConfig): Promise<void> {
371
398
 
372
399
  // Output directory will be created if needed
373
400
  }
374
-
375
- /**
376
- * Interactive configuration builder
377
- */
378
- export async function buildConfig(cwd: string): Promise<RalphConfig> {
379
- // Check for existing paths file first
380
- const pathsFile = await checkForPathsFile(cwd);
381
-
382
- if (pathsFile) {
383
- const loaded = await loadPathsFile(pathsFile);
384
- return {
385
- refs: loaded.refs.map((r) => resolve(cwd, r)),
386
- rule: resolve(cwd, loaded.rule),
387
- output: resolve(cwd, loaded.output),
388
- };
389
- }
390
-
391
- // Interactive selection
392
- const refs = await selectRefs(cwd);
393
- const rule = await selectRule(cwd);
394
- const output = await selectOutput(cwd);
395
-
396
- return { refs, rule, output };
397
- }
@@ -0,0 +1,190 @@
1
+ import { platform } from "os";
2
+ import { which } from "bun";
3
+
4
+ /**
5
+ * Supported platforms
6
+ */
7
+ export type Platform = "darwin" | "linux" | "win32" | "unknown";
8
+
9
+ /**
10
+ * Get the current platform
11
+ */
12
+ export function getPlatform(): Platform {
13
+ const p = platform();
14
+ if (p === "darwin" || p === "linux" || p === "win32") {
15
+ return p;
16
+ }
17
+ return "unknown";
18
+ }
19
+
20
+ /**
21
+ * Platform-specific configuration
22
+ */
23
+ interface PlatformConfig {
24
+ /** Error codes that indicate permission/access denied */
25
+ accessErrorCodes: string[];
26
+ /** Directories to always exclude from scanning */
27
+ systemExcludedDirs: string[];
28
+ }
29
+
30
+ /**
31
+ * Platform configurations
32
+ */
33
+ const PLATFORM_CONFIGS: Record<Platform, PlatformConfig> = {
34
+ darwin: {
35
+ accessErrorCodes: ["EPERM", "EACCES"],
36
+ systemExcludedDirs: [
37
+ // macOS protected directories
38
+ "Library",
39
+ "Photos Library.photoslibrary",
40
+ "Photo Booth Library",
41
+ ],
42
+ },
43
+ linux: {
44
+ accessErrorCodes: ["EPERM", "EACCES"],
45
+ systemExcludedDirs: [
46
+ // Linux protected directories
47
+ "lost+found",
48
+ "proc",
49
+ "sys",
50
+ ],
51
+ },
52
+ win32: {
53
+ accessErrorCodes: ["EPERM", "EACCES"],
54
+ systemExcludedDirs: [
55
+ // Windows protected directories
56
+ "System Volume Information",
57
+ "$Recycle.Bin",
58
+ "Windows",
59
+ ],
60
+ },
61
+ unknown: {
62
+ accessErrorCodes: ["EPERM", "EACCES"],
63
+ systemExcludedDirs: [],
64
+ },
65
+ };
66
+
67
+ /**
68
+ * Get platform-specific configuration
69
+ */
70
+ export function getPlatformConfig(): PlatformConfig {
71
+ return PLATFORM_CONFIGS[getPlatform()];
72
+ }
73
+
74
+ /**
75
+ * Check if an error is a permission/access error for the current platform
76
+ */
77
+ export function isAccessError(error: unknown): boolean {
78
+ if (error && typeof error === "object" && "code" in error) {
79
+ const code = (error as { code: string }).code;
80
+ return getPlatformConfig().accessErrorCodes.includes(code);
81
+ }
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Check if a directory should be excluded on the current platform
87
+ */
88
+ export function isSystemExcludedDir(dirName: string): boolean {
89
+ return getPlatformConfig().systemExcludedDirs.includes(dirName);
90
+ }
91
+
92
+ /**
93
+ * Common directories to exclude across all platforms (project-related)
94
+ */
95
+ export const EXCLUDED_DIRS = [
96
+ "node_modules",
97
+ "dist",
98
+ "build",
99
+ ".git",
100
+ ".next",
101
+ ".nuxt",
102
+ ".output",
103
+ "coverage",
104
+ "__pycache__",
105
+ "vendor",
106
+ ".cache",
107
+ ];
108
+
109
+ /**
110
+ * Check if a directory should be excluded (combines common + platform-specific)
111
+ */
112
+ export function shouldExcludeDir(dirName: string): boolean {
113
+ return EXCLUDED_DIRS.includes(dirName) || isSystemExcludedDir(dirName);
114
+ }
115
+
116
+ // ============================================================================
117
+ // Claude CLI Detection
118
+ // ============================================================================
119
+
120
+ /**
121
+ * Platform-specific install instructions for Claude CLI
122
+ */
123
+ const CLAUDE_INSTALL_INSTRUCTIONS: Record<Platform, string> = {
124
+ darwin: `Install Claude CLI:
125
+ npm install -g @anthropic-ai/claude-code
126
+
127
+ Or via Homebrew:
128
+ brew install claude`,
129
+ linux: `Install Claude CLI:
130
+ npm install -g @anthropic-ai/claude-code`,
131
+ win32: `Install Claude CLI:
132
+ npm install -g @anthropic-ai/claude-code`,
133
+ unknown: `Install Claude CLI:
134
+ npm install -g @anthropic-ai/claude-code`,
135
+ };
136
+
137
+ /**
138
+ * Get platform-specific Claude CLI install instructions
139
+ */
140
+ export function getClaudeInstallInstructions(): string {
141
+ return CLAUDE_INSTALL_INSTRUCTIONS[getPlatform()];
142
+ }
143
+
144
+ /**
145
+ * Check if Claude CLI is installed and available in PATH
146
+ */
147
+ export async function isClaudeInstalled(): Promise<boolean> {
148
+ try {
149
+ const claudePath = which("claude");
150
+ return claudePath !== null;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Result of Claude CLI check
158
+ */
159
+ export interface ClaudeCheckResult {
160
+ installed: boolean;
161
+ path?: string;
162
+ installInstructions: string;
163
+ }
164
+
165
+ /**
166
+ * Check Claude CLI installation and return detailed result
167
+ */
168
+ export async function checkClaudeInstallation(): Promise<ClaudeCheckResult> {
169
+ const installInstructions = getClaudeInstallInstructions();
170
+
171
+ try {
172
+ const claudePath = which("claude");
173
+ if (claudePath) {
174
+ return {
175
+ installed: true,
176
+ path: claudePath,
177
+ installInstructions,
178
+ };
179
+ }
180
+ return {
181
+ installed: false,
182
+ installInstructions,
183
+ };
184
+ } catch {
185
+ return {
186
+ installed: false,
187
+ installInstructions,
188
+ };
189
+ }
190
+ }
package/src/runner.ts CHANGED
@@ -4,6 +4,7 @@ import { mkdir } from "fs/promises";
4
4
  import { homedir } from "os";
5
5
  import type { RalphConfig, RunnerState, IterationResult } from "./types";
6
6
  import { createPrompt } from "./prompt";
7
+ import { setCurrentProcess, throwIfCancelled } from "./state";
7
8
 
8
9
  const COMPLETION_SIGNAL = "<promise>COMPLETE</promise>";
9
10
  const AUTH_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
@@ -102,7 +103,6 @@ export async function checkClaudeAuth(): Promise<boolean> {
102
103
  }
103
104
  }
104
105
 
105
-
106
106
  /**
107
107
  * Check if the TODO file is in a clean/initial state
108
108
  */
@@ -149,10 +149,13 @@ Ralph Session: ${state.startTime.toISOString()}
149
149
  "Found existing TODO with progress. Reset to start fresh?",
150
150
  {
151
151
  type: "confirm",
152
+ cancel: "symbol",
152
153
  initial: true,
153
154
  }
154
155
  );
155
156
 
157
+ throwIfCancelled(response);
158
+
156
159
  if (response === true) {
157
160
  await Bun.write(state.todoFile, INITIAL_TODO_CONTENT);
158
161
  consola.info("TODO reset to clean state");
@@ -175,23 +178,6 @@ async function log(state: RunnerState, message: string): Promise<void> {
175
178
  await Bun.write(state.logFile, existing + logLine);
176
179
  }
177
180
 
178
- // Track current subprocess for cleanup
179
- let currentProc: ReturnType<typeof Bun.spawn> | null = null;
180
-
181
- /**
182
- * Kill any running subprocess on exit
183
- */
184
- export function cleanupSubprocess() {
185
- if (currentProc) {
186
- try {
187
- currentProc.kill();
188
- } catch {
189
- // Process may have already exited
190
- }
191
- currentProc = null;
192
- }
193
- }
194
-
195
181
  /**
196
182
  * Run a single Claude iteration
197
183
  */
@@ -213,14 +199,14 @@ async function runIteration(
213
199
  cwd,
214
200
  });
215
201
 
216
- currentProc = proc;
202
+ setCurrentProcess(proc);
217
203
 
218
204
  // Collect output
219
205
  const stdout = await new Response(proc.stdout).text();
220
206
  const stderr = await new Response(proc.stderr).text();
221
207
  const exitCode = await proc.exited;
222
208
 
223
- currentProc = null;
209
+ setCurrentProcess(null);
224
210
 
225
211
  const output = stdout + stderr;
226
212
 
package/src/state.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Global state shared across modules
3
+ *
4
+ * This module provides centralized state management for:
5
+ * - Graceful shutdown handling (Ctrl+C / SIGINT / SIGTERM)
6
+ * - Prompt cancellation detection
7
+ * - Subprocess tracking for cleanup
8
+ */
9
+
10
+ // ============================================================================
11
+ // Shutdown State
12
+ // ============================================================================
13
+
14
+ let shuttingDown = false;
15
+
16
+ /**
17
+ * Mark the process as shutting down
18
+ */
19
+ export function setShuttingDown(): void {
20
+ shuttingDown = true;
21
+ }
22
+
23
+ /**
24
+ * Check if the process is shutting down (Ctrl+C was pressed)
25
+ */
26
+ export function isShuttingDown(): boolean {
27
+ return shuttingDown;
28
+ }
29
+
30
+ /**
31
+ * Reset shutdown state (for testing purposes only)
32
+ */
33
+ export function resetShutdownState(): void {
34
+ shuttingDown = false;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Prompt Cancellation
39
+ // ============================================================================
40
+
41
+ /**
42
+ * Check if a prompt result indicates cancellation and throw if so.
43
+ * Handles Symbol returns from consola prompts (when cancel: "symbol")
44
+ * and shutdown state.
45
+ *
46
+ * @param result - The result from a consola.prompt() call
47
+ * @throws Error with message "Selection cancelled" if cancelled
48
+ */
49
+ export function throwIfCancelled(result: unknown): void {
50
+ if (typeof result === "symbol" || isShuttingDown()) {
51
+ throw new Error("Selection cancelled");
52
+ }
53
+ }
54
+
55
+ // ============================================================================
56
+ // Subprocess Tracking
57
+ // ============================================================================
58
+
59
+ let currentProc: ReturnType<typeof Bun.spawn> | null = null;
60
+
61
+ /**
62
+ * Set the current running subprocess for tracking
63
+ */
64
+ export function setCurrentProcess(proc: ReturnType<typeof Bun.spawn> | null): void {
65
+ currentProc = proc;
66
+ }
67
+
68
+ /**
69
+ * Kill any running subprocess on exit
70
+ */
71
+ export function cleanupSubprocess(): void {
72
+ if (currentProc) {
73
+ try {
74
+ currentProc.kill();
75
+ } catch {
76
+ // Process may have already exited
77
+ }
78
+ currentProc = null;
79
+ }
80
+ }