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 +32 -17
- package/index.ts +4 -2
- package/package.json +3 -2
- package/src/cli.ts +34 -23
- package/src/paths.ts +99 -96
- package/src/platform.ts +190 -0
- package/src/runner.ts +6 -20
- package/src/state.ts +80 -0
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ bun add -g cralph
|
|
|
36
36
|
## Quick Start
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
# In
|
|
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.
|
|
63
|
-
3.
|
|
64
|
-
4.
|
|
65
|
-
5.
|
|
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 (
|
|
106
|
+
## First Run (No .ralph/ in cwd)
|
|
106
107
|
|
|
107
108
|
```
|
|
108
|
-
|
|
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
|
-
│
|
|
116
|
-
│
|
|
117
|
-
│
|
|
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
|
-
|
|
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
|
|
152
|
+
## Platform Support
|
|
153
|
+
|
|
154
|
+
cralph works on **macOS**, **Linux**, and **Windows** with platform-specific handling:
|
|
144
155
|
|
|
145
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
+
export { loadPathsFile, validateConfig, resolvePathsConfig, toRelativePath } from "./src/paths";
|
|
4
4
|
export { createPrompt, buildPrompt } from "./src/prompt";
|
|
5
|
-
export { run,
|
|
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.
|
|
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,
|
|
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
|
-
|
|
24
|
+
const exit = (code: number) => process.exit(code);
|
|
22
25
|
|
|
23
26
|
process.on("SIGINT", () => {
|
|
24
|
-
if (
|
|
27
|
+
if (isShuttingDown()) {
|
|
25
28
|
// Force exit on second Ctrl+C
|
|
26
|
-
|
|
29
|
+
exit(1);
|
|
27
30
|
}
|
|
28
|
-
|
|
31
|
+
setShuttingDown();
|
|
29
32
|
cleanupSubprocess();
|
|
30
33
|
console.log("\n");
|
|
31
34
|
consola.info("Cancelled.");
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
163
|
-
rule:
|
|
164
|
-
output: config.output
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
198
|
+
console.log(CONTROLS);
|
|
199
|
+
const action = await consola.prompt(
|
|
200
|
+
`No .ralph/ found in ${cwd}`,
|
|
190
201
|
{
|
|
191
|
-
type: "
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
259
|
-
const
|
|
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:
|
|
294
|
+
initial: initialValue,
|
|
266
295
|
});
|
|
267
296
|
|
|
268
|
-
|
|
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
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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:
|
|
329
|
+
initial: initialValue,
|
|
302
330
|
});
|
|
303
331
|
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/platform.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|