cralph 1.0.0-alpha.0
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/LICENSE +21 -0
- package/README.md +88 -0
- package/index.ts +5 -0
- package/package.json +47 -0
- package/src/cli.ts +133 -0
- package/src/paths.ts +263 -0
- package/src/prompt.ts +59 -0
- package/src/runner.ts +188 -0
- package/src/types.ts +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mguleryuz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# cralph
|
|
2
|
+
|
|
3
|
+
Claude in a loop. Point at refs, give it rules, let it cook.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
refs/ ──loop──> output/
|
|
7
|
+
(source) │ (result)
|
|
8
|
+
│
|
|
9
|
+
rules.md
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What is Ralph?
|
|
13
|
+
|
|
14
|
+
[Ralph](https://ghuntley.com/ralph/) is a technique: run Claude in a loop until it signals completion.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
while :; do cat PROMPT.md | claude -p ; done
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
cralph wraps this into a CLI with path selection and logging.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun add -g cralph
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or with npm:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g cralph
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Interactive - prompts for everything
|
|
38
|
+
cralph
|
|
39
|
+
|
|
40
|
+
# With flags
|
|
41
|
+
cralph --refs ./source --rules ./rules.md --output ./out
|
|
42
|
+
|
|
43
|
+
# With config file
|
|
44
|
+
cralph --paths-file ralph.paths.json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Path Selection
|
|
48
|
+
|
|
49
|
+
When prompted, choose how to specify each path:
|
|
50
|
+
|
|
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 |
|
|
56
|
+
|
|
57
|
+
## Config File
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"refs": ["./refs", "./more-refs"],
|
|
62
|
+
"rules": "./.cursor/rules/my-rules.mdc",
|
|
63
|
+
"output": "./output"
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Name it `ralph.paths.json` and cralph auto-detects it.
|
|
68
|
+
|
|
69
|
+
## How It Works
|
|
70
|
+
|
|
71
|
+
1. Reads your source material from `refs/`
|
|
72
|
+
2. Injects your rules into the prompt
|
|
73
|
+
3. Runs `claude -p --dangerously-skip-permissions` in a loop
|
|
74
|
+
4. Stops when Claude outputs `<promise>COMPLETE</promise>`
|
|
75
|
+
|
|
76
|
+
## Requirements
|
|
77
|
+
|
|
78
|
+
- [Bun](https://bun.sh)
|
|
79
|
+
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
|
|
80
|
+
|
|
81
|
+
## Warning
|
|
82
|
+
|
|
83
|
+
Runs with `--dangerously-skip-permissions`. Review output regularly.
|
|
84
|
+
|
|
85
|
+
## Resources
|
|
86
|
+
|
|
87
|
+
- [Ralph / Geoff Huntley](https://ghuntley.com/ralph/)
|
|
88
|
+
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
|
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"author": "mguleryuz",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/mguleryuz/cralph"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"claude",
|
|
13
|
+
"ai",
|
|
14
|
+
"cli",
|
|
15
|
+
"automation",
|
|
16
|
+
"ralph",
|
|
17
|
+
"loop",
|
|
18
|
+
"bun"
|
|
19
|
+
],
|
|
20
|
+
"module": "index.ts",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"cralph": "./src/cli.ts"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"index.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"start": "bun run src/cli.ts"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "latest"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"typescript": "^5"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"citty": "^0.2.0",
|
|
42
|
+
"consola": "^3.4.2"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"bun": ">=1.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { defineCommand, runMain } from "citty";
|
|
4
|
+
import { consola } from "consola";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import {
|
|
7
|
+
buildConfig,
|
|
8
|
+
loadPathsFile,
|
|
9
|
+
validateConfig,
|
|
10
|
+
selectRefs,
|
|
11
|
+
selectRules,
|
|
12
|
+
selectOutput,
|
|
13
|
+
} from "./paths";
|
|
14
|
+
import { run } from "./runner";
|
|
15
|
+
import type { RalphConfig } from "./types";
|
|
16
|
+
|
|
17
|
+
const main = defineCommand({
|
|
18
|
+
meta: {
|
|
19
|
+
name: "cralph",
|
|
20
|
+
version: "1.0.0",
|
|
21
|
+
description: "Claude in a loop. Point at refs, give it rules, let it cook.",
|
|
22
|
+
},
|
|
23
|
+
args: {
|
|
24
|
+
refs: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Comma-separated refs paths (source material)",
|
|
27
|
+
required: false,
|
|
28
|
+
},
|
|
29
|
+
rules: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Path to rules file (.mdc or .md)",
|
|
32
|
+
required: false,
|
|
33
|
+
},
|
|
34
|
+
output: {
|
|
35
|
+
type: "string",
|
|
36
|
+
description: "Output directory",
|
|
37
|
+
required: false,
|
|
38
|
+
},
|
|
39
|
+
"paths-file": {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Path to configuration file (JSON)",
|
|
42
|
+
required: false,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
async run({ args }) {
|
|
46
|
+
const cwd = process.cwd();
|
|
47
|
+
let config: RalphConfig;
|
|
48
|
+
|
|
49
|
+
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);
|
|
55
|
+
config = {
|
|
56
|
+
refs: loaded.refs.map((r) => resolve(cwd, r)),
|
|
57
|
+
rules: resolve(cwd, loaded.rules),
|
|
58
|
+
output: resolve(cwd, loaded.output),
|
|
59
|
+
};
|
|
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()));
|
|
77
|
+
} else {
|
|
78
|
+
refs = await selectRefs(cwd);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let rules: string;
|
|
82
|
+
if (args.rules) {
|
|
83
|
+
rules = resolve(cwd, args.rules);
|
|
84
|
+
} else {
|
|
85
|
+
rules = await selectRules(cwd);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let output: string;
|
|
89
|
+
if (args.output) {
|
|
90
|
+
output = resolve(cwd, args.output);
|
|
91
|
+
} else {
|
|
92
|
+
output = await selectOutput(cwd);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
config = { refs, rules, output };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validate configuration
|
|
99
|
+
consola.info("Validating configuration...");
|
|
100
|
+
await validateConfig(config);
|
|
101
|
+
|
|
102
|
+
// Show config summary
|
|
103
|
+
consola.info("Configuration:");
|
|
104
|
+
consola.info(` Refs: ${config.refs.join(", ")}`);
|
|
105
|
+
consola.info(` Rules: ${config.rules}`);
|
|
106
|
+
consola.info(` Output: ${config.output}`);
|
|
107
|
+
console.log();
|
|
108
|
+
|
|
109
|
+
// Confirm before running
|
|
110
|
+
const proceed = await consola.prompt("Start processing?", {
|
|
111
|
+
type: "confirm",
|
|
112
|
+
initial: true,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (proceed !== true) {
|
|
116
|
+
consola.info("Cancelled.");
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Run the main loop
|
|
121
|
+
await run(config);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error instanceof Error) {
|
|
124
|
+
consola.error(error.message);
|
|
125
|
+
} else {
|
|
126
|
+
consola.error("An unexpected error occurred");
|
|
127
|
+
}
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
runMain(main);
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { consola } from "consola";
|
|
2
|
+
import { resolve, join, basename } from "path";
|
|
3
|
+
import { readdir, stat } from "fs/promises";
|
|
4
|
+
import type { PathSelectionMode, PathsFileConfig, RalphConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* List directories in a given path
|
|
8
|
+
*/
|
|
9
|
+
async function listDirectories(basePath: string): Promise<string[]> {
|
|
10
|
+
const entries = await readdir(basePath, { withFileTypes: true });
|
|
11
|
+
return entries
|
|
12
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
13
|
+
.map((e) => e.name);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* List files matching patterns in a directory (recursive)
|
|
18
|
+
*/
|
|
19
|
+
async function listFilesRecursive(
|
|
20
|
+
basePath: string,
|
|
21
|
+
extensions: string[]
|
|
22
|
+
): Promise<string[]> {
|
|
23
|
+
const results: string[] = [];
|
|
24
|
+
|
|
25
|
+
async function walk(dir: string) {
|
|
26
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const fullPath = join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
30
|
+
await walk(fullPath);
|
|
31
|
+
} else if (entry.isFile()) {
|
|
32
|
+
if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
33
|
+
results.push(fullPath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await walk(basePath);
|
|
40
|
+
return results;
|
|
41
|
+
}
|
|
42
|
+
|
|
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
|
+
/**
|
|
64
|
+
* Load configuration from a paths file
|
|
65
|
+
*/
|
|
66
|
+
export async function loadPathsFile(filePath: string): Promise<PathsFileConfig> {
|
|
67
|
+
const file = Bun.file(filePath);
|
|
68
|
+
if (!(await file.exists())) {
|
|
69
|
+
throw new Error(`Paths file not found: ${filePath}`);
|
|
70
|
+
}
|
|
71
|
+
const content = await file.json();
|
|
72
|
+
return content as PathsFileConfig;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Prompt user to select refs directories
|
|
77
|
+
*/
|
|
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);
|
|
91
|
+
}
|
|
92
|
+
|
|
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
|
+
}
|
|
99
|
+
|
|
100
|
+
const selected = await consola.prompt("Select refs directories:", {
|
|
101
|
+
type: "multiselect",
|
|
102
|
+
options: dirs.map((d) => ({ label: d, value: d })),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (typeof selected === "symbol") throw new Error("Selection cancelled");
|
|
106
|
+
return (selected as string[]).map((d) => resolve(cwd, d));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// file mode - will be handled at config level
|
|
110
|
+
throw new Error("Use loadPathsFile for file-based configuration");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Prompt user to select a rules file
|
|
115
|
+
*/
|
|
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());
|
|
125
|
+
}
|
|
126
|
+
|
|
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
|
+
}));
|
|
139
|
+
|
|
140
|
+
const selected = await consola.prompt("Select rules file:", {
|
|
141
|
+
type: "select",
|
|
142
|
+
options,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (typeof selected === "symbol") throw new Error("Selection cancelled");
|
|
146
|
+
return selected as string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
throw new Error("Use loadPathsFile for file-based configuration");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Prompt user to select output directory
|
|
154
|
+
*/
|
|
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());
|
|
165
|
+
}
|
|
166
|
+
|
|
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
|
+
}
|
|
189
|
+
|
|
190
|
+
return resolve(cwd, selected as string);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
throw new Error("Use loadPathsFile for file-based configuration");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Check if a paths file exists and offer to use it
|
|
198
|
+
*/
|
|
199
|
+
export async function checkForPathsFile(cwd: string): Promise<string | null> {
|
|
200
|
+
const candidates = ["ralph.paths.json", ".ralph.paths.json", "paths.json"];
|
|
201
|
+
|
|
202
|
+
for (const candidate of candidates) {
|
|
203
|
+
const filePath = join(cwd, candidate);
|
|
204
|
+
const file = Bun.file(filePath);
|
|
205
|
+
if (await file.exists()) {
|
|
206
|
+
const useIt = await consola.prompt(
|
|
207
|
+
`Found ${candidate}. Use it for configuration?`,
|
|
208
|
+
{ type: "confirm", initial: true }
|
|
209
|
+
);
|
|
210
|
+
if (useIt === true) {
|
|
211
|
+
return filePath;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Validate that paths exist
|
|
221
|
+
*/
|
|
222
|
+
export async function validateConfig(config: RalphConfig): Promise<void> {
|
|
223
|
+
// Check refs
|
|
224
|
+
for (const ref of config.refs) {
|
|
225
|
+
try {
|
|
226
|
+
await stat(ref);
|
|
227
|
+
} catch {
|
|
228
|
+
throw new Error(`Refs path does not exist: ${ref}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
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}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Output directory will be created if needed
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Interactive configuration builder
|
|
243
|
+
*/
|
|
244
|
+
export async function buildConfig(cwd: string): Promise<RalphConfig> {
|
|
245
|
+
// Check for existing paths file first
|
|
246
|
+
const pathsFile = await checkForPathsFile(cwd);
|
|
247
|
+
|
|
248
|
+
if (pathsFile) {
|
|
249
|
+
const loaded = await loadPathsFile(pathsFile);
|
|
250
|
+
return {
|
|
251
|
+
refs: loaded.refs.map((r) => resolve(cwd, r)),
|
|
252
|
+
rules: resolve(cwd, loaded.rules),
|
|
253
|
+
output: resolve(cwd, loaded.output),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Interactive selection
|
|
258
|
+
const refs = await selectRefs(cwd);
|
|
259
|
+
const rules = await selectRules(cwd);
|
|
260
|
+
const output = await selectOutput(cwd);
|
|
261
|
+
|
|
262
|
+
return { refs, rules, output };
|
|
263
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { RalphConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The main ralph prompt template.
|
|
5
|
+
* Generic prompt for any Ralph use case.
|
|
6
|
+
*/
|
|
7
|
+
const BASE_PROMPT = `You are an autonomous agent running in a loop.
|
|
8
|
+
|
|
9
|
+
FIRST: Read and internalize the rules provided below.
|
|
10
|
+
|
|
11
|
+
Your job is to process source material from the refs paths into the output directory.
|
|
12
|
+
|
|
13
|
+
**CRITICAL: refs paths are READ-ONLY.** Never delete, move, or modify files in refs. Only create files in the output directory.
|
|
14
|
+
|
|
15
|
+
Follow the rules to determine how to process each file. Track what you've done to avoid duplicate work.
|
|
16
|
+
|
|
17
|
+
STOPPING CONDITION: When all source files have been processed according to the rules, output exactly:
|
|
18
|
+
|
|
19
|
+
<promise>COMPLETE</promise>
|
|
20
|
+
|
|
21
|
+
This signals the automation to stop. Only output this tag when truly done.`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the complete prompt with config and rules injected
|
|
25
|
+
*/
|
|
26
|
+
export function buildPrompt(config: RalphConfig, rulesContent: string): string {
|
|
27
|
+
const refsList = config.refs.map((r) => `- ${r}`).join("\n");
|
|
28
|
+
|
|
29
|
+
return `${BASE_PROMPT}
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
**Refs paths (read-only source material):**
|
|
36
|
+
${refsList}
|
|
37
|
+
|
|
38
|
+
**Output directory:**
|
|
39
|
+
${config.output}
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Rules
|
|
44
|
+
|
|
45
|
+
The following rules define how to classify, refine, and write documentation:
|
|
46
|
+
|
|
47
|
+
${rulesContent}
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read rules file and build complete prompt
|
|
53
|
+
*/
|
|
54
|
+
export async function createPrompt(config: RalphConfig): Promise<string> {
|
|
55
|
+
const rulesFile = Bun.file(config.rules);
|
|
56
|
+
const rulesContent = await rulesFile.text();
|
|
57
|
+
|
|
58
|
+
return buildPrompt(config, rulesContent);
|
|
59
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { consola } from "consola";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { mkdir } from "fs/promises";
|
|
4
|
+
import type { RalphConfig, RunnerState, IterationResult } from "./types";
|
|
5
|
+
import { createPrompt } from "./prompt";
|
|
6
|
+
|
|
7
|
+
const COMPLETION_SIGNAL = "<promise>COMPLETE</promise>";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Initialize the runner state and log file
|
|
11
|
+
*/
|
|
12
|
+
async function initRunner(outputDir: string): Promise<RunnerState> {
|
|
13
|
+
const ralphDir = join(outputDir, ".ralph");
|
|
14
|
+
await mkdir(ralphDir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
const state: RunnerState = {
|
|
17
|
+
iteration: 0,
|
|
18
|
+
startTime: new Date(),
|
|
19
|
+
logFile: join(ralphDir, "ralph.log"),
|
|
20
|
+
todoFile: join(ralphDir, "TODO.md"),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Initialize log file
|
|
24
|
+
const logHeader = `═══════════════════════════════════════
|
|
25
|
+
Ralph Session: ${state.startTime.toISOString()}
|
|
26
|
+
═══════════════════════════════════════\n`;
|
|
27
|
+
|
|
28
|
+
await Bun.write(state.logFile, logHeader);
|
|
29
|
+
|
|
30
|
+
// Initialize TODO file if not exists
|
|
31
|
+
const todoFile = Bun.file(state.todoFile);
|
|
32
|
+
if (!(await todoFile.exists())) {
|
|
33
|
+
await Bun.write(
|
|
34
|
+
state.todoFile,
|
|
35
|
+
`# Ralph Agent Status
|
|
36
|
+
|
|
37
|
+
## Current Status
|
|
38
|
+
|
|
39
|
+
Idle - waiting for documents in refs/
|
|
40
|
+
|
|
41
|
+
## Processed Files
|
|
42
|
+
|
|
43
|
+
_None yet_
|
|
44
|
+
|
|
45
|
+
## Pending
|
|
46
|
+
|
|
47
|
+
_Check refs/ for new documents_
|
|
48
|
+
`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Append to log file
|
|
57
|
+
*/
|
|
58
|
+
async function log(state: RunnerState, message: string): Promise<void> {
|
|
59
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
60
|
+
const logLine = `[${timestamp}] ${message}\n`;
|
|
61
|
+
const file = Bun.file(state.logFile);
|
|
62
|
+
const existing = await file.text();
|
|
63
|
+
await Bun.write(state.logFile, existing + logLine);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Count files in refs directories (excluding .gitkeep and hidden files)
|
|
68
|
+
*/
|
|
69
|
+
async function countRefs(refs: string[]): Promise<number> {
|
|
70
|
+
let count = 0;
|
|
71
|
+
|
|
72
|
+
for (const refPath of refs) {
|
|
73
|
+
try {
|
|
74
|
+
const entries = await Array.fromAsync(
|
|
75
|
+
new Bun.Glob("**/*").scan({ cwd: refPath, onlyFiles: true })
|
|
76
|
+
);
|
|
77
|
+
count += entries.filter(
|
|
78
|
+
(e) => !e.startsWith(".") && !e.includes("/.") && e !== ".gitkeep"
|
|
79
|
+
).length;
|
|
80
|
+
} catch {
|
|
81
|
+
// Directory might not exist or be empty
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return count;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run a single Claude iteration
|
|
90
|
+
*/
|
|
91
|
+
async function runIteration(
|
|
92
|
+
prompt: string,
|
|
93
|
+
state: RunnerState,
|
|
94
|
+
cwd: string
|
|
95
|
+
): Promise<IterationResult> {
|
|
96
|
+
state.iteration++;
|
|
97
|
+
|
|
98
|
+
consola.info(`Iteration ${state.iteration} — invoking Claude...`);
|
|
99
|
+
await log(state, `Iteration ${state.iteration} starting`);
|
|
100
|
+
|
|
101
|
+
// Run claude with the prompt piped in
|
|
102
|
+
const proc = Bun.spawn(["claude", "-p", "--dangerously-skip-permissions"], {
|
|
103
|
+
stdin: new Blob([prompt]),
|
|
104
|
+
stdout: "pipe",
|
|
105
|
+
stderr: "pipe",
|
|
106
|
+
cwd,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Collect output
|
|
110
|
+
const stdout = await new Response(proc.stdout).text();
|
|
111
|
+
const stderr = await new Response(proc.stderr).text();
|
|
112
|
+
const exitCode = await proc.exited;
|
|
113
|
+
|
|
114
|
+
const output = stdout + stderr;
|
|
115
|
+
|
|
116
|
+
// Log output
|
|
117
|
+
await log(state, output);
|
|
118
|
+
|
|
119
|
+
// Check for completion signal
|
|
120
|
+
const isComplete = output.includes(COMPLETION_SIGNAL);
|
|
121
|
+
|
|
122
|
+
if (isComplete) {
|
|
123
|
+
consola.success(
|
|
124
|
+
`Complete! All files processed in ${state.iteration} iteration(s).`
|
|
125
|
+
);
|
|
126
|
+
} else if (exitCode === 0) {
|
|
127
|
+
consola.info(`Iteration ${state.iteration} complete`);
|
|
128
|
+
} else {
|
|
129
|
+
consola.warn(`Iteration ${state.iteration} exited with code ${exitCode}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Print Claude's output
|
|
133
|
+
console.log(output);
|
|
134
|
+
|
|
135
|
+
return { exitCode, output, isComplete };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Main runner loop
|
|
140
|
+
*/
|
|
141
|
+
export async function run(config: RalphConfig): Promise<void> {
|
|
142
|
+
const cwd = process.cwd();
|
|
143
|
+
|
|
144
|
+
consola.box("cralph");
|
|
145
|
+
consola.info("Starting ralph...");
|
|
146
|
+
|
|
147
|
+
// Initialize state
|
|
148
|
+
const state = await initRunner(config.output);
|
|
149
|
+
consola.info(`Log: ${state.logFile}`);
|
|
150
|
+
consola.info(`TODO: ${state.todoFile}`);
|
|
151
|
+
|
|
152
|
+
// Count initial refs
|
|
153
|
+
const initialCount = await countRefs(config.refs);
|
|
154
|
+
consola.info(`Found ${initialCount} files to process`);
|
|
155
|
+
|
|
156
|
+
if (initialCount === 0) {
|
|
157
|
+
consola.warn("No files found in refs directories");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Build prompt once
|
|
162
|
+
const prompt = await createPrompt(config);
|
|
163
|
+
|
|
164
|
+
// Ensure output directory exists
|
|
165
|
+
await mkdir(config.output, { recursive: true });
|
|
166
|
+
|
|
167
|
+
consola.info("Press Ctrl+C to stop\n");
|
|
168
|
+
|
|
169
|
+
// Main loop
|
|
170
|
+
while (true) {
|
|
171
|
+
console.log("━".repeat(40));
|
|
172
|
+
|
|
173
|
+
const refCount = await countRefs(config.refs);
|
|
174
|
+
consola.info(`${refCount} ref files remaining`);
|
|
175
|
+
|
|
176
|
+
const result = await runIteration(prompt, state, cwd);
|
|
177
|
+
|
|
178
|
+
if (result.isComplete) {
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Small delay between iterations
|
|
183
|
+
await Bun.sleep(2000);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const duration = (Date.now() - state.startTime.getTime()) / 1000;
|
|
187
|
+
consola.success(`Finished in ${duration.toFixed(1)}s`);
|
|
188
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path selection mode for CLI prompts
|
|
3
|
+
*/
|
|
4
|
+
export type PathSelectionMode = "manual" | "select" | "file";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration loaded from a paths file (e.g., ralph.paths.json)
|
|
8
|
+
*/
|
|
9
|
+
export interface PathsFileConfig {
|
|
10
|
+
refs: string[];
|
|
11
|
+
rules: string;
|
|
12
|
+
output: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolved configuration after path selection
|
|
17
|
+
*/
|
|
18
|
+
export interface RalphConfig {
|
|
19
|
+
/** Paths to reference material directories/files */
|
|
20
|
+
refs: string[];
|
|
21
|
+
/** Path to the rules file (.mdc or .md) */
|
|
22
|
+
rules: string;
|
|
23
|
+
/** Output directory for generated docs */
|
|
24
|
+
output: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Runner state during iteration
|
|
29
|
+
*/
|
|
30
|
+
export interface RunnerState {
|
|
31
|
+
iteration: number;
|
|
32
|
+
startTime: Date;
|
|
33
|
+
logFile: string;
|
|
34
|
+
todoFile: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Result of a single Claude invocation
|
|
39
|
+
*/
|
|
40
|
+
export interface IterationResult {
|
|
41
|
+
exitCode: number;
|
|
42
|
+
output: string;
|
|
43
|
+
isComplete: boolean;
|
|
44
|
+
}
|