cralph 1.0.0-alpha.3 → 1.0.0-alpha.5

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
@@ -4,13 +4,13 @@
4
4
  <img src="assets/ralph.png" alt="Ralph cooking" width="500">
5
5
  </p>
6
6
 
7
- Claude in a loop. Point at refs, give it a rule, let it cook.
7
+ Claude in a loop. Give it a rule, let it cook.
8
8
 
9
9
  ```
10
- refs/ ──loop──> ./
11
- (source) │ (output in cwd)
12
-
13
- rule.md
10
+ .ralph/
11
+ ├── rule.md ──loop──> ./
12
+ ├── refs/ (output)
13
+ └── TODO.md
14
14
  ```
15
15
 
16
16
  ## What is Ralph?
@@ -21,7 +21,7 @@ refs/ ──loop──> ./
21
21
  while :; do cat PROMPT.md | claude -p ; done
22
22
  ```
23
23
 
24
- cralph wraps this into a CLI with path selection and logging.
24
+ cralph wraps this into a CLI with config, logging, and TODO tracking.
25
25
 
26
26
  ## Install
27
27
 
@@ -35,113 +35,108 @@ Or with npm:
35
35
  npm install -g cralph
36
36
  ```
37
37
 
38
- ## Usage
38
+ ## Quick Start
39
39
 
40
40
  ```bash
41
- # Run - auto-detects .ralph/paths.json in cwd
41
+ # In an empty directory - creates starter structure
42
+ cralph
43
+
44
+ # Edit rule.md with your instructions, then run again
42
45
  cralph
46
+ ```
43
47
 
44
- # First run (no config) - interactive mode generates .ralph/paths.json
48
+ ## Usage
49
+
50
+ ```bash
51
+ # Auto-detects .ralph/paths.json in cwd
45
52
  cralph
46
53
 
47
54
  # Override with flags
48
55
  cralph --refs ./source --rule ./rule.md --output .
49
- ```
50
56
 
51
- ## Path Selection
57
+ # Auto-confirm prompts (CI/automation)
58
+ cralph --yes
59
+ ```
52
60
 
53
- Simple multiselect for all paths:
61
+ ## How It Works
54
62
 
55
- - **Space** to toggle selection
56
- - **Enter** to confirm
57
- - **Ctrl+C** to exit
58
- - Shows all directories up to 3 levels deep
59
- - Pre-selects current values in edit mode
63
+ 1. Checks Claude CLI auth (cached for 6 hours)
64
+ 2. Loads config from `.ralph/paths.json`
65
+ 3. Runs `claude -p --dangerously-skip-permissions` in a loop
66
+ 4. Claude updates `.ralph/TODO.md` after each iteration
67
+ 5. Stops when Claude outputs `<promise>COMPLETE</promise>`
60
68
 
61
- ## Config File
69
+ ## Config
62
70
 
63
71
  ```json
64
72
  {
65
- "refs": ["./refs", "./more-refs"],
66
- "rule": "./.cursor/rules/my-rules.mdc",
73
+ "refs": ["./.ralph/refs"],
74
+ "rule": "./.ralph/rule.md",
67
75
  "output": "."
68
76
  }
69
77
  ```
70
78
 
71
- Save as `.ralph/paths.json` and cralph auto-detects it. Output is typically `.` (current directory) since you'll run cralph in your repo.
79
+ Save as `.ralph/paths.json`. Refs are optional reference material (read-only).
72
80
 
73
- ## How It Works
81
+ ## Files
74
82
 
75
- 1. Checks if Claude CLI is authenticated (exits with instructions if not)
76
- 2. Reads your source material from `refs/`
77
- 3. Injects your rule into the prompt
78
- 4. Runs `claude -p --dangerously-skip-permissions` in a loop
79
- 5. Stops when Claude outputs `<promise>COMPLETE</promise>`
83
+ | File | Description |
84
+ |------|-------------|
85
+ | `.ralph/paths.json` | Configuration |
86
+ | `.ralph/rule.md` | Your instructions for Claude |
87
+ | `.ralph/refs/` | Optional reference material (read-only) |
88
+ | `.ralph/TODO.md` | Task tracking (updated by Claude) |
89
+ | `.ralph/ralph.log` | Session log |
90
+ | `~/.cralph/auth-cache.json` | Auth cache (6h TTL) |
80
91
 
81
- ## Expected Behavior
92
+ ### TODO Format
82
93
 
83
- **Auth check (runs first):**
84
- ```
85
- ◐ Checking Claude authentication...
86
- ✔ Claude authenticated
87
- ```
94
+ Claude maintains this structure:
88
95
 
89
- If not authenticated:
90
- ```
91
- ◐ Checking Claude authentication...
92
- ✖ Claude CLI is not authenticated
96
+ ```markdown
97
+ # Tasks
93
98
 
94
- ╭─────────────────────╮
95
- claude │
96
- │ │
97
- │ Then type: /login │
98
- ╰─────────────────────╯
99
+ - [ ] Pending task
100
+ - [x] Completed task
99
101
 
100
- After logging in, run cralph again.
101
- ```
102
+ # Notes
102
103
 
103
- **Auto-detect existing config:**
104
- ```
105
- ❯ Found .ralph/paths.json. What would you like to do?
106
- ● 🚀 Run with this config
107
- ○ ✏️ Edit configuration
104
+ Any relevant context
108
105
  ```
109
106
 
110
- **Interactive Mode (no config file):**
107
+ ## First Run (Empty Directory)
108
+
111
109
  ```
112
- Interactive configuration mode
110
+ Created .ralph/refs/ directory
111
+ ℹ Created .ralph/rule.md with starter template
112
+ ℹ Created .ralph/paths.json
113
113
 
114
- ↑↓ Navigate • Space Toggle • Enter • Ctrl+C Exit
115
- Select refs directories:
116
- 📁 src
117
- 📁 src/components
118
- ◼ 📁 docs
114
+ ╭──────────────────────────────────────────────╮
115
+ 1. Add source files to .ralph/refs/ │
116
+ 2. Edit .ralph/rule.md with your instructions│
117
+ 3. Run cralph again │
118
+ ╰──────────────────────────────────────────────╯
119
+ ```
119
120
 
120
- ↑↓ Navigate • Space Toggle • Enter • Ctrl+C Exit
121
- ❯ Select rule file:
122
- ● 📄 .cursor/rules/my-rules.mdc (cursor rule)
123
- ○ 📄 README.md
121
+ ## Prompts
124
122
 
125
- ↑↓ Navigate • Space Toggle • Enter • Ctrl+C Exit
126
- ❯ Select output directory:
127
- 📍 Current directory (.)
128
- 📁 docs
123
+ **Config detected:**
124
+ ```
125
+ Found .ralph/paths.json. What would you like to do?
126
+ 🚀 Run with this config
127
+ ○ ✏️ Edit configuration
129
128
  ```
130
129
 
131
- **Save config after selection:**
130
+ **TODO has progress:**
132
131
  ```
133
- ? Save configuration to .ralph/paths.json? (Y/n)
134
- ✔ Saved .ralph/paths.json
132
+ ? Found existing TODO with progress. Reset to start fresh? (Y/n)
135
133
  ```
136
134
 
137
- **Cancellation:**
138
- - Press `Ctrl+C` at any time to exit
139
- - Running Claude processes are terminated cleanly
135
+ ## Path Selection
140
136
 
141
- **Output Files:**
142
- - `.ralph/paths.json` - Configuration file
143
- - `.ralph/ralph.log` - Session log with timestamps
144
- - `.ralph/TODO.md` - Agent status tracker
137
+ - **Space** - Toggle selection
138
+ - **Enter** - Confirm
139
+ - **Ctrl+C** - Exit
145
140
 
146
141
  ## Testing
147
142
 
@@ -149,7 +144,8 @@ If not authenticated:
149
144
  bun test
150
145
  ```
151
146
 
152
- Tests validate config loading, prompt building, and CLI behavior without calling Claude.
147
+ - **Unit tests** - Config, prompt building, CLI
148
+ - **E2E tests** - Full loop with Claude (requires auth)
153
149
 
154
150
  ## Requirements
155
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cralph",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.5",
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",
package/src/paths.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { consola } from "consola";
2
2
  import { resolve, join } from "path";
3
- import { readdir, stat } from "fs/promises";
3
+ import { readdir, stat, mkdir } from "fs/promises";
4
4
  import type { PathsFileConfig, RalphConfig } from "./types";
5
5
 
6
6
  // Dim text helper
@@ -105,16 +105,47 @@ export async function loadPathsFile(filePath: string): Promise<PathsFileConfig>
105
105
  return content as PathsFileConfig;
106
106
  }
107
107
 
108
+ /**
109
+ * Create starter structure for empty directories
110
+ */
111
+ export async function createStarterStructure(cwd: string): Promise<void> {
112
+ // Create .ralph/
113
+ const ralphDir = join(cwd, ".ralph");
114
+ await mkdir(ralphDir, { recursive: true });
115
+
116
+ // Create .ralph/refs/
117
+ const refsDir = join(ralphDir, "refs");
118
+ await mkdir(refsDir, { recursive: true });
119
+ consola.info("Created .ralph/refs/ directory");
120
+
121
+ // Create .ralph/rule.md
122
+ const rulePath = join(ralphDir, "rule.md");
123
+ await Bun.write(rulePath, STARTER_RULE);
124
+ consola.info("Created .ralph/rule.md with starter template");
125
+
126
+ // Create .ralph/paths.json with default config
127
+ const pathsConfig = {
128
+ refs: ["./.ralph/refs"],
129
+ rule: "./.ralph/rule.md",
130
+ output: ".",
131
+ };
132
+ await Bun.write(join(ralphDir, "paths.json"), JSON.stringify(pathsConfig, null, 2));
133
+ consola.info("Created .ralph/paths.json");
134
+
135
+ consola.box("1. Add source files to .ralph/refs/\n2. Edit .ralph/rule.md with your instructions\n3. Run cralph again");
136
+ }
137
+
108
138
  /**
109
139
  * Prompt user to select refs directories (simple multiselect)
110
140
  */
111
141
  export async function selectRefs(cwd: string, defaults?: string[]): Promise<string[]> {
112
142
  // Get all directories up to 3 levels deep
113
- const allDirs = await listDirectoriesRecursive(cwd, 3);
143
+ let allDirs = await listDirectoriesRecursive(cwd, 3);
114
144
 
115
145
  if (allDirs.length === 0) {
116
- consola.warn("No directories found");
117
- throw new Error("No directories available to select");
146
+ // Create starter structure and exit gracefully
147
+ await createStarterStructure(cwd);
148
+ process.exit(0);
118
149
  }
119
150
 
120
151
  // Convert to relative paths for display
@@ -146,14 +177,21 @@ export async function selectRefs(cwd: string, defaults?: string[]): Promise<stri
146
177
  return selected as string[];
147
178
  }
148
179
 
180
+ const STARTER_RULE = `I want a file named hello.txt
181
+ `;
182
+
149
183
  /**
150
184
  * Prompt user to select a rule file
151
185
  */
152
186
  export async function selectRule(cwd: string, defaultRule?: string): Promise<string> {
153
- const files = await listFilesRecursive(cwd, [".mdc", ".md"]);
187
+ let files = await listFilesRecursive(cwd, [".mdc", ".md"]);
154
188
  if (files.length === 0) {
155
- consola.warn("No .mdc or .md files found");
156
- throw new Error("No rule files available to select");
189
+ // This shouldn't happen if selectRefs ran first, but handle it just in case
190
+ const rulePath = join(cwd, "rule.md");
191
+ await Bun.write(rulePath, STARTER_RULE);
192
+ consola.info("Created rule.md with starter template");
193
+ consola.box("Edit rule.md with your instructions then run cralph again");
194
+ process.exit(0);
157
195
  }
158
196
 
159
197
  // Show relative paths for readability
package/src/prompt.ts CHANGED
@@ -8,13 +8,16 @@ const BASE_PROMPT = `You are an autonomous agent running in a loop.
8
8
 
9
9
  FIRST: Read and internalize the rules provided below.
10
10
 
11
- Your job is to process source material from the refs paths into the output directory.
11
+ Your job is to follow the rules and complete the task. Write output to the output directory.
12
12
 
13
- **CRITICAL: refs paths are READ-ONLY.** Never delete, move, or modify files in refs. Only create files in the output directory.
13
+ If refs paths are provided, they are READ-ONLY reference material. Never delete, move, or modify files in refs.
14
14
 
15
- Follow the rules to determine how to process each file. Track what you've done to avoid duplicate work.
15
+ **IMPORTANT: At the end of EVERY iteration, update the TODO file with your progress.**
16
+ Keep the TODO structure with these sections:
17
+ - **# Tasks** - Checklist with [ ] for pending and [x] for done
18
+ - **# Notes** - Any relevant notes or context
16
19
 
17
- STOPPING CONDITION: When all source files have been processed according to the rules, output exactly:
20
+ STOPPING CONDITION: When done, update the TODO file, then output exactly:
18
21
 
19
22
  <promise>COMPLETE</promise>
20
23
 
@@ -23,8 +26,10 @@ This signals the automation to stop. Only output this tag when truly done.`;
23
26
  /**
24
27
  * Build the complete prompt with config and rules injected
25
28
  */
26
- export function buildPrompt(config: RalphConfig, rulesContent: string): string {
27
- const refsList = config.refs.map((r) => `- ${r}`).join("\n");
29
+ export function buildPrompt(config: RalphConfig, rulesContent: string, todoFile: string): string {
30
+ const refsList = config.refs.length > 0
31
+ ? config.refs.map((r) => `- ${r}`).join("\n")
32
+ : "_None_";
28
33
 
29
34
  return `${BASE_PROMPT}
30
35
 
@@ -32,7 +37,10 @@ export function buildPrompt(config: RalphConfig, rulesContent: string): string {
32
37
 
33
38
  ## Configuration
34
39
 
35
- **Refs paths (read-only source material):**
40
+ **TODO file (update after each iteration):**
41
+ ${todoFile}
42
+
43
+ **Refs paths (optional, read-only reference material):**
36
44
  ${refsList}
37
45
 
38
46
  **Output directory:**
@@ -42,8 +50,6 @@ ${config.output}
42
50
 
43
51
  ## Rules
44
52
 
45
- The following rules define how to classify, refine, and write documentation:
46
-
47
53
  ${rulesContent}
48
54
  `;
49
55
  }
@@ -51,9 +57,9 @@ ${rulesContent}
51
57
  /**
52
58
  * Read rule file and build complete prompt
53
59
  */
54
- export async function createPrompt(config: RalphConfig): Promise<string> {
60
+ export async function createPrompt(config: RalphConfig, todoFile: string): Promise<string> {
55
61
  const ruleFile = Bun.file(config.rule);
56
62
  const ruleContent = await ruleFile.text();
57
63
 
58
- return buildPrompt(config, ruleContent);
64
+ return buildPrompt(config, ruleContent, todoFile);
59
65
  }
package/src/runner.ts CHANGED
@@ -1,15 +1,73 @@
1
1
  import { consola } from "consola";
2
2
  import { join } from "path";
3
3
  import { mkdir } from "fs/promises";
4
+ import { homedir } from "os";
4
5
  import type { RalphConfig, RunnerState, IterationResult } from "./types";
5
6
  import { createPrompt } from "./prompt";
6
7
 
7
8
  const COMPLETION_SIGNAL = "<promise>COMPLETE</promise>";
9
+ const AUTH_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
10
+
11
+ const INITIAL_TODO_CONTENT = `# Tasks
12
+
13
+ - [ ] Task 1
14
+ - [ ] Task 2
15
+
16
+ # Notes
17
+
18
+ _None yet_
19
+ `;
20
+
21
+ /**
22
+ * Get the auth cache file path
23
+ */
24
+ function getAuthCachePath(): string {
25
+ return join(homedir(), ".cralph", "auth-cache.json");
26
+ }
27
+
28
+ /**
29
+ * Check if cached auth is still valid
30
+ */
31
+ async function isAuthCacheValid(): Promise<boolean> {
32
+ try {
33
+ const cachePath = getAuthCachePath();
34
+ const file = Bun.file(cachePath);
35
+ if (!(await file.exists())) {
36
+ return false;
37
+ }
38
+ const cache = await file.json();
39
+ const cachedAt = cache.timestamp;
40
+ const now = Date.now();
41
+ return now - cachedAt < AUTH_CACHE_TTL_MS;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Save successful auth to cache
49
+ */
50
+ async function saveAuthCache(): Promise<void> {
51
+ try {
52
+ const cachePath = getAuthCachePath();
53
+ const cacheDir = join(homedir(), ".cralph");
54
+ await mkdir(cacheDir, { recursive: true });
55
+ await Bun.write(cachePath, JSON.stringify({ timestamp: Date.now() }));
56
+ } catch {
57
+ // Ignore cache write errors
58
+ }
59
+ }
8
60
 
9
61
  /**
10
62
  * Check if Claude CLI is authenticated by sending a minimal test prompt
63
+ * Uses cache to avoid checking too frequently (6 hour TTL)
11
64
  */
12
65
  export async function checkClaudeAuth(): Promise<boolean> {
66
+ // Check cache first
67
+ if (await isAuthCacheValid()) {
68
+ return true;
69
+ }
70
+
13
71
  try {
14
72
  // Send a minimal prompt to test auth
15
73
  const proc = Bun.spawn(["claude", "-p"], {
@@ -32,14 +90,31 @@ export async function checkClaudeAuth(): Promise<boolean> {
32
90
  return false;
33
91
  }
34
92
 
35
- // If exit code is 0, auth is working
36
- return exitCode === 0;
93
+ // If exit code is 0, auth is working - save to cache
94
+ if (exitCode === 0) {
95
+ await saveAuthCache();
96
+ return true;
97
+ }
98
+
99
+ return false;
37
100
  } catch {
38
101
  return false;
39
102
  }
40
103
  }
41
104
 
42
105
 
106
+ /**
107
+ * Check if the TODO file is in a clean/initial state
108
+ */
109
+ async function isTodoClean(todoPath: string): Promise<boolean> {
110
+ const file = Bun.file(todoPath);
111
+ if (!(await file.exists())) {
112
+ return true; // Non-existent is considered clean
113
+ }
114
+ const content = await file.text();
115
+ return content.trim() === INITIAL_TODO_CONTENT.trim();
116
+ }
117
+
43
118
  /**
44
119
  * Initialize the runner state and log file
45
120
  */
@@ -61,26 +136,29 @@ Ralph Session: ${state.startTime.toISOString()}
61
136
 
62
137
  await Bun.write(state.logFile, logHeader);
63
138
 
64
- // Initialize TODO file if not exists
139
+ // Check TODO file state
65
140
  const todoFile = Bun.file(state.todoFile);
66
- if (!(await todoFile.exists())) {
67
- await Bun.write(
68
- state.todoFile,
69
- `# Ralph Agent Status
70
-
71
- ## Current Status
72
-
73
- Idle - waiting for documents in refs/
74
-
75
- ## Processed Files
76
-
77
- _None yet_
78
-
79
- ## Pending
80
-
81
- _Check refs/ for new documents_
82
- `
141
+ const todoExists = await todoFile.exists();
142
+
143
+ if (!todoExists) {
144
+ // Create fresh TODO file
145
+ await Bun.write(state.todoFile, INITIAL_TODO_CONTENT);
146
+ } else if (!(await isTodoClean(state.todoFile))) {
147
+ // TODO exists and has been modified - ask about reset
148
+ const response = await consola.prompt(
149
+ "Found existing TODO with progress. Reset to start fresh?",
150
+ {
151
+ type: "confirm",
152
+ initial: true,
153
+ }
83
154
  );
155
+
156
+ if (response === true) {
157
+ await Bun.write(state.todoFile, INITIAL_TODO_CONTENT);
158
+ consola.info("TODO reset to clean state");
159
+ } else {
160
+ consola.info("Continuing with existing TODO state");
161
+ }
84
162
  }
85
163
 
86
164
  return state;
@@ -97,28 +175,6 @@ async function log(state: RunnerState, message: string): Promise<void> {
97
175
  await Bun.write(state.logFile, existing + logLine);
98
176
  }
99
177
 
100
- /**
101
- * Count files in refs directories (excluding .gitkeep and hidden files)
102
- */
103
- async function countRefs(refs: string[]): Promise<number> {
104
- let count = 0;
105
-
106
- for (const refPath of refs) {
107
- try {
108
- const entries = await Array.fromAsync(
109
- new Bun.Glob("**/*").scan({ cwd: refPath, onlyFiles: true })
110
- );
111
- count += entries.filter(
112
- (e) => !e.startsWith(".") && !e.includes("/.") && e !== ".gitkeep"
113
- ).length;
114
- } catch {
115
- // Directory might not exist or be empty
116
- }
117
- }
118
-
119
- return count;
120
- }
121
-
122
178
  // Track current subprocess for cleanup
123
179
  let currentProc: ReturnType<typeof Bun.spawn> | null = null;
124
180
 
@@ -204,17 +260,8 @@ export async function run(config: RalphConfig): Promise<void> {
204
260
  consola.info(`Log: ${state.logFile}`);
205
261
  consola.info(`TODO: ${state.todoFile}`);
206
262
 
207
- // Count initial refs
208
- const initialCount = await countRefs(config.refs);
209
- consola.info(`Found ${initialCount} files to process`);
210
-
211
- if (initialCount === 0) {
212
- consola.warn("No files found in refs directories");
213
- return;
214
- }
215
-
216
263
  // Build prompt once
217
- const prompt = await createPrompt(config);
264
+ const prompt = await createPrompt(config, state.todoFile);
218
265
 
219
266
  // Ensure output directory exists
220
267
  await mkdir(config.output, { recursive: true });
@@ -225,9 +272,6 @@ export async function run(config: RalphConfig): Promise<void> {
225
272
  while (true) {
226
273
  console.log("━".repeat(40));
227
274
 
228
- const refCount = await countRefs(config.refs);
229
- consola.info(`${refCount} ref files remaining`);
230
-
231
275
  const result = await runIteration(prompt, state, cwd);
232
276
 
233
277
  if (result.isComplete) {