etch-loop 0.1.0__tar.gz

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.
@@ -0,0 +1,34 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.11"
16
+ - run: pip install build
17
+ - run: python -m build
18
+ - uses: actions/upload-artifact@v4
19
+ with:
20
+ name: dist
21
+ path: dist/
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ environment: pypi
27
+ permissions:
28
+ id-token: write
29
+ steps:
30
+ - uses: actions/download-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: etch-loop
3
+ Version: 0.1.0
4
+ Summary: Run Claude Code in a fix-break loop until your codebase is clean
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: rich
8
+ Requires-Dist: typer
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == 'dev'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # etch-loop
@@ -0,0 +1 @@
1
+ # etch-loop
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "etch-loop"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.11"
5
+ description = "Run Claude Code in a fix-break loop until your codebase is clean"
6
+ readme = "README.md"
7
+ license = { text = "MIT" }
8
+ dependencies = ["typer", "rich"]
9
+
10
+ [project.optional-dependencies]
11
+ dev = ["pytest"]
12
+
13
+ [project.scripts]
14
+ etch = "etch.cli:app"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/etch"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,91 @@
1
+ """Claude agent subprocess runner."""
2
+
3
+ import subprocess
4
+ import threading
5
+ from collections.abc import Callable
6
+
7
+
8
+ class AgentError(Exception):
9
+ """Raised when the agent subprocess fails or is unavailable."""
10
+
11
+
12
+ def run(
13
+ prompt: str,
14
+ verbose: bool = False,
15
+ tick_callback: Callable[[str], None] | None = None,
16
+ ) -> str:
17
+ """Run the Claude agent with the given prompt piped to stdin.
18
+
19
+ Launches `claude -p --dangerously-skip-permissions` as a subprocess,
20
+ pipes `prompt` to its stdin, and returns the full stdout.
21
+
22
+ Args:
23
+ prompt: The prompt text to send to Claude.
24
+ verbose: If True, streams output to terminal in addition to capturing it.
25
+ tick_callback: Optional callable called with each line of output as it
26
+ arrives. Used by display layer for streaming updates.
27
+
28
+ Returns:
29
+ The full stdout output from the agent as a string.
30
+
31
+ Raises:
32
+ AgentError: If `claude` is not found, or exits with a non-zero code.
33
+ """
34
+ cmd = ["claude", "-p", "--dangerously-skip-permissions"]
35
+
36
+ try:
37
+ process = subprocess.Popen(
38
+ cmd,
39
+ stdin=subprocess.PIPE,
40
+ stdout=subprocess.PIPE,
41
+ stderr=subprocess.PIPE,
42
+ text=True,
43
+ encoding="utf-8",
44
+ errors="replace",
45
+ )
46
+ except FileNotFoundError:
47
+ raise AgentError(
48
+ "claude executable not found. "
49
+ "Is Claude Code installed? Run: npm install -g @anthropic-ai/claude-code"
50
+ )
51
+ except OSError as exc:
52
+ raise AgentError(f"Failed to launch claude: {exc}") from exc
53
+
54
+ # Write prompt to stdin and close it
55
+ try:
56
+ process.stdin.write(prompt)
57
+ process.stdin.close()
58
+ except BrokenPipeError as exc:
59
+ raise AgentError(f"Failed to write prompt to claude stdin: {exc}") from exc
60
+
61
+ output_lines: list[str] = []
62
+ lock = threading.Lock()
63
+
64
+ def read_stdout() -> None:
65
+ assert process.stdout is not None
66
+ for line in process.stdout:
67
+ with lock:
68
+ output_lines.append(line)
69
+ if verbose:
70
+ print(line, end="", flush=True)
71
+ if tick_callback is not None:
72
+ tick_callback(line)
73
+
74
+ reader = threading.Thread(target=read_stdout, daemon=True)
75
+ reader.start()
76
+ reader.join()
77
+
78
+ process.wait()
79
+
80
+ # Capture stderr for error reporting
81
+ stderr_output = ""
82
+ if process.stderr:
83
+ stderr_output = process.stderr.read().strip()
84
+
85
+ if process.returncode != 0:
86
+ detail = stderr_output or "(no stderr)"
87
+ raise AgentError(
88
+ f"claude exited with code {process.returncode}: {detail}"
89
+ )
90
+
91
+ return "".join(output_lines)
@@ -0,0 +1,90 @@
1
+ """CLI entry points for etch-loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+
12
+ from etch import display, loop
13
+
14
+ app = typer.Typer(
15
+ name="etch",
16
+ help="Run Claude Code in a fix-break loop, hunting for edge cases.",
17
+ add_completion=False,
18
+ pretty_exceptions_show_locals=False,
19
+ )
20
+
21
+ _TEMPLATES = ["ETCH.md", "BREAK.md"]
22
+
23
+
24
+ @app.command()
25
+ def init() -> None:
26
+ """Copy ETCH.md and BREAK.md templates into the current directory."""
27
+ for filename in _TEMPLATES:
28
+ dest = Path.cwd() / filename
29
+
30
+ if dest.exists():
31
+ display.print_init_skip(filename)
32
+ continue
33
+
34
+ try:
35
+ # Python 3.9+ traversable API
36
+ pkg_templates = importlib.resources.files("etch") / "templates" / filename
37
+ content = pkg_templates.read_text(encoding="utf-8")
38
+ dest.write_text(content, encoding="utf-8")
39
+ display.print_init_ok(filename)
40
+ except (FileNotFoundError, ModuleNotFoundError, TypeError) as exc:
41
+ display.print_error(f"Could not copy {filename}: {exc}")
42
+
43
+
44
+ @app.command()
45
+ def run(
46
+ prompt: str = typer.Option(
47
+ "./ETCH.md",
48
+ "--prompt",
49
+ help="Path to the fixer prompt file (ETCH.md).",
50
+ show_default=True,
51
+ ),
52
+ max_iterations: int = typer.Option(
53
+ 20,
54
+ "--max-iterations",
55
+ "-n",
56
+ help="Maximum number of fix-break cycles.",
57
+ show_default=True,
58
+ min=1,
59
+ ),
60
+ no_commit: bool = typer.Option(
61
+ False,
62
+ "--no-commit",
63
+ help="Skip git commits after fixer runs.",
64
+ is_flag=True,
65
+ ),
66
+ dry_run: bool = typer.Option(
67
+ False,
68
+ "--dry-run",
69
+ help="Print the fixer prompt and exit without running.",
70
+ is_flag=True,
71
+ ),
72
+ verbose: bool = typer.Option(
73
+ False,
74
+ "--verbose",
75
+ help="Stream agent output to the terminal.",
76
+ is_flag=True,
77
+ ),
78
+ ) -> None:
79
+ """Run the fix-break loop against the current repository."""
80
+ try:
81
+ loop.run(
82
+ prompt_path=prompt,
83
+ max_iterations=max_iterations,
84
+ no_commit=no_commit,
85
+ dry_run=dry_run,
86
+ verbose=verbose,
87
+ )
88
+ except KeyboardInterrupt:
89
+ display.print_interrupted()
90
+ raise typer.Exit(code=130)
@@ -0,0 +1,389 @@
1
+ """Rich-based terminal UI for etch-loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from rich.columns import Columns
12
+ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
13
+ from rich.live import Live
14
+ from rich.panel import Panel
15
+ from rich.segment import Segment
16
+ from rich.style import Style
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from etch import __version__
21
+
22
+ # ── Palette ──────────────────────────────────────────────────────────────────
23
+ BG = "#0a0a0a"
24
+ FG = "#e8e8e8"
25
+ DIM = "#555555"
26
+ AMBER = "#d4a547"
27
+ GREEN = "#7aba78"
28
+ RED = "#c96a6a"
29
+ BORDER = "#1e1e1e"
30
+
31
+ # ── Symbols (ASCII only) ──────────────────────────────────────────────────────
32
+ SYM_RUN = ">"
33
+ SYM_OK = "+"
34
+ SYM_FAIL = "x"
35
+ SYM_NEUTRAL = "-"
36
+
37
+ SCAN_WIDTH = 40
38
+ SCAN_BLOCK = "▓▒ " # 3-char block
39
+ SCAN_FILL = "░"
40
+ TICK_MS = 80
41
+
42
+
43
+ # ── ScanBar renderable ────────────────────────────────────────────────────────
44
+
45
+
46
+ class ScanBar:
47
+ """A Rich renderable that renders one frame of the scan animation.
48
+
49
+ The bar is SCAN_WIDTH chars wide, filled with SCAN_FILL (░).
50
+ A 3-char SCAN_BLOCK (▓▒ ) slides left-to-right, wrapping at the end.
51
+ Rendered in amber.
52
+ """
53
+
54
+ def __init__(self, tick: int) -> None:
55
+ self.tick = tick
56
+
57
+ def __rich_console__(
58
+ self, console: Console, options: ConsoleOptions
59
+ ) -> RenderResult:
60
+ width = SCAN_WIDTH
61
+ block = SCAN_BLOCK
62
+ block_len = len(block)
63
+ pos = self.tick % width
64
+
65
+ bar = list(SCAN_FILL * width)
66
+ for i, ch in enumerate(block):
67
+ idx = (pos + i) % width
68
+ bar[idx] = ch
69
+
70
+ yield Segment("".join(bar), Style(color=AMBER))
71
+
72
+
73
+ # ── Log entry dataclass ───────────────────────────────────────────────────────
74
+
75
+
76
+ @dataclass
77
+ class _LogEntry:
78
+ """One line in the scrolling log."""
79
+
80
+ symbol: str
81
+ phase: str
82
+ status: str
83
+ detail: str | RenderableType
84
+ color: str
85
+ running: bool = False
86
+ tick: int = 0
87
+
88
+
89
+ # ── EtchDisplay ──────────────────────────────────────────────────────────────
90
+
91
+
92
+ class EtchDisplay:
93
+ """Manages the Rich Live layout for the etch-loop run."""
94
+
95
+ def __init__(self, target: str = "") -> None:
96
+ self._console = Console(style=f"on {BG}")
97
+ self._target = target
98
+ self._entries: list[_LogEntry] = []
99
+ self._stats: dict[str, Any] = {
100
+ "iterations": 0,
101
+ "fixes": 0,
102
+ "issues": 0,
103
+ "start": time.monotonic(),
104
+ }
105
+ self._live: Live | None = None
106
+ self._tick = 0
107
+ self._ticker_stop = threading.Event()
108
+ self._ticker_thread: threading.Thread | None = None
109
+ self._lock = threading.Lock()
110
+
111
+ # ── Public lifecycle ──────────────────────────────────────────────────────
112
+
113
+ def __enter__(self) -> "EtchDisplay":
114
+ self._live = Live(
115
+ self._render(),
116
+ console=self._console,
117
+ refresh_per_second=15,
118
+ transient=False,
119
+ )
120
+ self._live.__enter__()
121
+ self._start_ticker()
122
+ return self
123
+
124
+ def __exit__(self, *args: Any) -> None:
125
+ self._stop_ticker()
126
+ if self._live is not None:
127
+ self._live.__exit__(*args)
128
+
129
+ # ── Iteration / phase API ─────────────────────────────────────────────────
130
+
131
+ def start_iteration(self, n: int) -> None:
132
+ """Add an iteration header line to the log."""
133
+ with self._lock:
134
+ self._stats["iterations"] = n
135
+ self._entries.append(
136
+ _LogEntry(
137
+ symbol=SYM_NEUTRAL,
138
+ phase=f"iteration {n}",
139
+ status="",
140
+ detail="",
141
+ color=DIM,
142
+ )
143
+ )
144
+ self._refresh()
145
+
146
+ def start_phase(self, phase: str) -> None:
147
+ """Add a 'running' line with scan animation for the given phase."""
148
+ with self._lock:
149
+ self._entries.append(
150
+ _LogEntry(
151
+ symbol=SYM_RUN,
152
+ phase=phase,
153
+ status="running",
154
+ detail=ScanBar(self._tick),
155
+ color=AMBER,
156
+ running=True,
157
+ tick=self._tick,
158
+ )
159
+ )
160
+ self._refresh()
161
+
162
+ def finish_phase(
163
+ self,
164
+ phase: str,
165
+ status: str,
166
+ detail: str,
167
+ duration: float,
168
+ success: bool = True,
169
+ ) -> None:
170
+ """Replace the last running line for `phase` with a finished result."""
171
+ color = GREEN if success else RED
172
+ symbol = SYM_OK if success else SYM_FAIL
173
+ dur_str = f"{duration:.1f}s"
174
+
175
+ with self._lock:
176
+ # Find the last running entry for this phase and replace it
177
+ for i in range(len(self._entries) - 1, -1, -1):
178
+ entry = self._entries[i]
179
+ if entry.phase == phase and entry.running:
180
+ self._entries[i] = _LogEntry(
181
+ symbol=symbol,
182
+ phase=phase,
183
+ status=status,
184
+ detail=detail,
185
+ color=color,
186
+ running=False,
187
+ )
188
+ break
189
+ self._refresh()
190
+
191
+ def record_fix(self) -> None:
192
+ """Increment the fix counter."""
193
+ with self._lock:
194
+ self._stats["fixes"] += 1
195
+
196
+ def record_issue(self) -> None:
197
+ """Increment the breaker-issues counter."""
198
+ with self._lock:
199
+ self._stats["issues"] += 1
200
+
201
+ def print_summary(self, stats: dict[str, Any]) -> None:
202
+ """Print the final done/stopped/interrupted panel."""
203
+ self._stop_ticker()
204
+ if self._live is not None:
205
+ self._live.stop()
206
+
207
+ reason = stats.get("reason", "done")
208
+ elapsed = stats.get("elapsed", 0.0)
209
+ iterations = stats.get("iterations", 0)
210
+ fixes = stats.get("fixes", 0)
211
+ issues = stats.get("issues", 0)
212
+
213
+ elapsed_str = _format_elapsed(elapsed)
214
+
215
+ if reason == "clear":
216
+ title_color = GREEN
217
+ title = f"[{GREEN}]+ all clear[/{GREEN}]"
218
+ elif reason == "interrupted":
219
+ title_color = AMBER
220
+ title = f"[{AMBER}]- interrupted[/{AMBER}]"
221
+ elif reason == "max_iterations":
222
+ title_color = AMBER
223
+ title = f"[{AMBER}]- stopped (max iterations)[/{AMBER}]"
224
+ elif reason == "no_changes":
225
+ title_color = GREEN
226
+ title = f"[{GREEN}]+ clean — fixer found nothing[/{GREEN}]"
227
+ else:
228
+ title_color = FG
229
+ title = f"[{FG}]done[/{FG}]"
230
+
231
+ body = (
232
+ f"[{DIM}]iterations[/{DIM}] [{FG}]{iterations}[/{FG}] "
233
+ f"[{DIM}]fixes[/{DIM}] [{FG}]{fixes}[/{FG}] "
234
+ f"[{DIM}]breaker issues[/{DIM}] [{FG}]{issues}[/{FG}] "
235
+ f"[{DIM}]{elapsed_str} elapsed[/{DIM}]"
236
+ )
237
+
238
+ panel = Panel(
239
+ body,
240
+ title=title,
241
+ border_style=Style(color=BORDER),
242
+ style=Style(bgcolor=BG),
243
+ )
244
+ self._console.print(panel)
245
+
246
+ # ── Rendering ─────────────────────────────────────────────────────────────
247
+
248
+ def _render(self) -> RenderableType:
249
+ """Build the full Live renderable."""
250
+ with self._lock:
251
+ entries = list(self._entries)
252
+ tick = self._tick
253
+ stats = dict(self._stats)
254
+
255
+ elapsed = time.monotonic() - stats["start"]
256
+
257
+ # Build log table
258
+ log_table = Table.grid(padding=(0, 1))
259
+ log_table.add_column(width=2) # symbol
260
+ log_table.add_column(width=10) # phase
261
+ log_table.add_column(width=12) # status
262
+ log_table.add_column() # detail
263
+
264
+ for entry in entries:
265
+ if entry.running:
266
+ detail_render: RenderableType = ScanBar(tick)
267
+ else:
268
+ detail_render = Text(str(entry.detail), style=Style(color=DIM))
269
+
270
+ sym_text = Text(entry.symbol, style=Style(color=entry.color))
271
+ phase_text = Text(entry.phase, style=Style(color=entry.color))
272
+ status_text = Text(entry.status, style=Style(color=DIM))
273
+
274
+ log_table.add_row(sym_text, phase_text, status_text, detail_render)
275
+
276
+ # Header
277
+ title_str = f"etch loop v{__version__}"
278
+ if self._target:
279
+ title_str += f" {self._target}"
280
+
281
+ # Stats footer
282
+ elapsed_str = _format_elapsed(elapsed)
283
+ footer = (
284
+ f"[{DIM}]iterations[/{DIM}] [{FG}]{stats['iterations']}[/{FG}] "
285
+ f"[{DIM}]fixes[/{DIM}] [{FG}]{stats['fixes']}[/{FG}] "
286
+ f"[{DIM}]breaker issues[/{DIM}] [{FG}]{stats['issues']}[/{FG}] "
287
+ f"[{DIM}]{elapsed_str} elapsed[/{DIM}]"
288
+ )
289
+
290
+ panel = Panel(
291
+ log_table,
292
+ title=f"[{AMBER}]{title_str}[/{AMBER}]",
293
+ subtitle=footer,
294
+ border_style=Style(color=BORDER),
295
+ style=Style(bgcolor=BG),
296
+ )
297
+ return panel
298
+
299
+ def _refresh(self) -> None:
300
+ if self._live is not None:
301
+ self._live.update(self._render())
302
+
303
+ # ── Ticker thread ─────────────────────────────────────────────────────────
304
+
305
+ def _start_ticker(self) -> None:
306
+ self._ticker_stop.clear()
307
+ self._ticker_thread = threading.Thread(
308
+ target=self._ticker_loop, daemon=True
309
+ )
310
+ self._ticker_thread.start()
311
+
312
+ def _stop_ticker(self) -> None:
313
+ self._ticker_stop.set()
314
+ if self._ticker_thread is not None:
315
+ self._ticker_thread.join(timeout=1.0)
316
+ self._ticker_thread = None
317
+
318
+ def _ticker_loop(self) -> None:
319
+ while not self._ticker_stop.is_set():
320
+ with self._lock:
321
+ self._tick += 1
322
+ self._refresh()
323
+ time.sleep(TICK_MS / 1000.0)
324
+
325
+
326
+ # ── Standalone print helpers (used outside Live context) ──────────────────────
327
+
328
+ _console = Console(style=f"on {BG}")
329
+
330
+
331
+ def print_dry_run(prompt_text: str) -> None:
332
+ """Print the prompt in a panel for --dry-run mode."""
333
+ _console.print(
334
+ Panel(
335
+ Text(prompt_text, style=Style(color=FG)),
336
+ title=f"[{AMBER}]dry run — prompt preview[/{AMBER}]",
337
+ border_style=Style(color=BORDER),
338
+ style=Style(bgcolor=BG),
339
+ )
340
+ )
341
+
342
+
343
+ def print_interrupted() -> None:
344
+ """Print interrupted notice."""
345
+ _console.print(
346
+ Panel(
347
+ Text("Run interrupted by user.", style=Style(color=AMBER)),
348
+ title=f"[{AMBER}]- interrupted[/{AMBER}]",
349
+ border_style=Style(color=BORDER),
350
+ style=Style(bgcolor=BG),
351
+ )
352
+ )
353
+
354
+
355
+ def print_error(message: str) -> None:
356
+ """Print an error panel."""
357
+ _console.print(
358
+ Panel(
359
+ Text(message, style=Style(color=RED)),
360
+ title=f"[{RED}]x error[/{RED}]",
361
+ border_style=Style(color=BORDER),
362
+ style=Style(bgcolor=BG),
363
+ )
364
+ )
365
+
366
+
367
+ def print_init_ok(filename: str) -> None:
368
+ """Print a success line for etch init."""
369
+ _console.print(f"[{GREEN}]{SYM_OK}[/{GREEN}] [{FG}]{filename}[/{FG}]")
370
+
371
+
372
+ def print_init_skip(filename: str) -> None:
373
+ """Print a skip line for etch init (file already exists)."""
374
+ _console.print(
375
+ f"[{AMBER}]{SYM_NEUTRAL}[/{AMBER}] [{DIM}]{filename} already exists, skipping[/{DIM}]"
376
+ )
377
+
378
+
379
+ # ── Utilities ─────────────────────────────────────────────────────────────────
380
+
381
+
382
+ def _format_elapsed(seconds: float) -> str:
383
+ """Format elapsed seconds as '2m 14s' or '45s'."""
384
+ seconds = max(0.0, seconds)
385
+ mins = int(seconds // 60)
386
+ secs = int(seconds % 60)
387
+ if mins > 0:
388
+ return f"{mins}m {secs}s"
389
+ return f"{secs}s"