chorus-cli 0.4.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/tools/coder.py ADDED
@@ -0,0 +1,970 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Coder — A terminal coding agent powered by Claude.
4
+
5
+ Usage:
6
+ coder.py Interactive REPL
7
+ coder.py --prompt "do something" Headless mode — outputs JSON to stdout
8
+
9
+ Environment variables:
10
+ ANTHROPIC_API_KEY — Required. Your Anthropic API key (or Chorus pk- key).
11
+ CODER_PROXY_URL — Optional. Chorus base URL (e.g. http://localhost:8081)
12
+ CODER_MODEL — Model to use (default: claude-sonnet-4-5-20250929)
13
+ CODER_MAX_TOKENS — Max response tokens (default: 16384)
14
+ CODER_SAFE_MODE — Set to 1 to require approval for writes/edits/bash
15
+ """
16
+
17
+ import anthropic
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ import glob as glob_module
23
+ import subprocess
24
+ import re
25
+ import readline
26
+ from pathlib import Path
27
+
28
+ # ── Colors ──────────────────────────────────────────────────────────────────
29
+
30
+ class C:
31
+ RESET = "\033[0m"
32
+ BOLD = "\033[1m"
33
+ DIM = "\033[2m"
34
+ RED = "\033[31m"
35
+ GREEN = "\033[32m"
36
+ YELLOW = "\033[33m"
37
+ BLUE = "\033[34m"
38
+ MAGENTA = "\033[35m"
39
+ CYAN = "\033[36m"
40
+ GRAY = "\033[90m"
41
+
42
+ # ── Config ──────────────────────────────────────────────────────────────────
43
+
44
+ MODEL = os.environ.get("CODER_MODEL", "claude-sonnet-4-5-20250929")
45
+ MAX_TOKENS = int(os.environ.get("CODER_MAX_TOKENS", "16384"))
46
+ SAFE_MODE = os.environ.get("CODER_SAFE_MODE", "").lower() in ("1", "true", "yes")
47
+
48
+ def is_token_limit_error(err):
49
+ msg = str(err)
50
+ return "token limit exceeded" in msg or "rate_limit_error" in msg
51
+
52
+ SYSTEM_PROMPT = """\
53
+ You are a coding agent running in the terminal.
54
+ Working directory: {cwd}
55
+
56
+ You help with software engineering tasks: writing code, debugging, refactoring, \
57
+ explaining code, running commands, and managing files.
58
+
59
+ Formatting:
60
+ - Your output is displayed raw in a terminal. Never use markdown.
61
+ - No ## headers, **bold**, *italic*, [links](url), or bullet symbols like -.
62
+ - Use blank lines, indentation, and CAPS for emphasis or section labels.
63
+ - Use plain numbered lists (1. 2. 3.) when listing things.
64
+ - For inline code references, just use the name directly (e.g. myFunction, not `myFunction`).
65
+ - Keep responses short and scannable.
66
+
67
+ {approach}Guidelines:
68
+ - Be direct and concise. No filler.
69
+ - Always use your tools. If a question can be answered by running a command (git, ls, etc.), use the bash tool — never guess.
70
+ - Always read a file before editing it.
71
+ - If edit_file fails with "old_string not found", re-read the file to get the actual current content before retrying. Never guess at file contents.
72
+ - Use edit_file for targeted changes. Use write_file for new files or complete rewrites.
73
+ - Prefer editing existing files over creating new ones.
74
+ - Do not write new unit tests unless the project already has substantive test coverage.
75
+ - Do not attempt to build or compile the project.
76
+ - Don't add unnecessary comments, docstrings, or type annotations.
77
+ - For bash commands, prefer non-interactive commands.
78
+ - When asked about the codebase, use list_files and search_files to explore it.
79
+ """
80
+
81
+ APPROACH_BLOCK = """\
82
+ Approach:
83
+ - Before making changes, read the relevant files and briefly state your approach.
84
+ - For multi-file changes, outline which files you'll modify and in what order.
85
+ - Do not start editing until you understand the existing code.
86
+
87
+ """
88
+
89
+ # ── Tool Definitions ────────────────────────────────────────────────────────
90
+
91
+ TOOLS = [
92
+ {
93
+ "name": "read_file",
94
+ "description": "Read a file's contents. Returns lines with line numbers.",
95
+ "input_schema": {
96
+ "type": "object",
97
+ "properties": {
98
+ "path": {"type": "string", "description": "File path (relative to cwd or absolute)"},
99
+ "offset": {"type": "integer", "description": "Start line (1-indexed)"},
100
+ "limit": {"type": "integer", "description": "Max lines to read"},
101
+ },
102
+ "required": ["path"],
103
+ },
104
+ },
105
+ {
106
+ "name": "write_file",
107
+ "description": "Create or overwrite a file with the given content.",
108
+ "input_schema": {
109
+ "type": "object",
110
+ "properties": {
111
+ "path": {"type": "string", "description": "File path to write"},
112
+ "content": {"type": "string", "description": "Full file content"},
113
+ },
114
+ "required": ["path", "content"],
115
+ },
116
+ },
117
+ {
118
+ "name": "edit_file",
119
+ "description": (
120
+ "Replace an exact string in a file with new content. "
121
+ "old_string must match exactly including whitespace/indentation. "
122
+ "Fails if old_string is not found or is ambiguous (found multiple times without replace_all)."
123
+ ),
124
+ "input_schema": {
125
+ "type": "object",
126
+ "properties": {
127
+ "path": {"type": "string", "description": "File path to edit"},
128
+ "old_string": {"type": "string", "description": "Exact string to find"},
129
+ "new_string": {"type": "string", "description": "Replacement string"},
130
+ "replace_all": {"type": "boolean", "description": "Replace all occurrences (default: false)"},
131
+ },
132
+ "required": ["path", "old_string", "new_string"],
133
+ },
134
+ },
135
+ {
136
+ "name": "list_files",
137
+ "description": "List files matching a glob pattern. Use '**/*.ext' for recursive search.",
138
+ "input_schema": {
139
+ "type": "object",
140
+ "properties": {
141
+ "pattern": {"type": "string", "description": "Glob pattern (e.g. '**/*.py', 'src/**/*.ts')"},
142
+ "path": {"type": "string", "description": "Base directory (default: cwd)"},
143
+ },
144
+ "required": ["pattern"],
145
+ },
146
+ },
147
+ {
148
+ "name": "search_files",
149
+ "description": "Search file contents with regex. Returns matching lines with file:line: prefix.",
150
+ "input_schema": {
151
+ "type": "object",
152
+ "properties": {
153
+ "pattern": {"type": "string", "description": "Regex pattern to search for"},
154
+ "path": {"type": "string", "description": "Directory or file to search (default: cwd)"},
155
+ "include": {"type": "string", "description": "Glob to filter files (e.g. '*.py')"},
156
+ },
157
+ "required": ["pattern"],
158
+ },
159
+ },
160
+ {
161
+ "name": "bash",
162
+ "description": "Execute a shell command. Returns stdout, stderr, and exit code.",
163
+ "input_schema": {
164
+ "type": "object",
165
+ "properties": {
166
+ "command": {"type": "string", "description": "Shell command to run"},
167
+ "timeout": {"type": "integer", "description": "Timeout in seconds (default: 120)"},
168
+ },
169
+ "required": ["command"],
170
+ },
171
+ },
172
+ ]
173
+
174
+ # ── Tool Implementations ───────────────────────────────────────────────────
175
+
176
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".tox", ".mypy_cache", "dist", "build"}
177
+
178
+
179
+ def resolve_path(path):
180
+ p = Path(path).expanduser()
181
+ if not p.is_absolute():
182
+ p = Path.cwd() / p
183
+ return p
184
+
185
+
186
+ def tool_read_file(path, offset=None, limit=None):
187
+ p = resolve_path(path)
188
+ if not p.exists():
189
+ return f"Error: File not found: {path}"
190
+ if not p.is_file():
191
+ return f"Error: Not a file: {path}"
192
+ try:
193
+ lines = p.read_text(encoding="utf-8", errors="replace").splitlines(True)
194
+ start = (offset - 1) if offset and offset > 0 else 0
195
+ end = (start + limit) if limit else len(lines)
196
+ numbered = [f"{i:>6}\t{line.rstrip()}" for i, line in enumerate(lines[start:end], start=start + 1)]
197
+ return "\n".join(numbered) if numbered else "(empty file)"
198
+ except Exception as e:
199
+ return f"Error reading file: {e}"
200
+
201
+
202
+ def tool_write_file(path, content):
203
+ p = resolve_path(path)
204
+ try:
205
+ p.parent.mkdir(parents=True, exist_ok=True)
206
+ p.write_text(content, encoding="utf-8")
207
+ line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
208
+ return f"Wrote {line_count} lines to {path}"
209
+ except Exception as e:
210
+ return f"Error writing file: {e}"
211
+
212
+
213
+ def tool_edit_file(path, old_string, new_string, replace_all=False):
214
+ p = resolve_path(path)
215
+ if not p.exists():
216
+ return f"Error: File not found: {path}"
217
+ try:
218
+ content = p.read_text(encoding="utf-8")
219
+ count = content.count(old_string)
220
+ if count == 0:
221
+ return f"Error: old_string not found in {path}. Make sure it matches exactly (including whitespace)."
222
+ if count > 1 and not replace_all:
223
+ return f"Error: old_string found {count} times in {path}. Use replace_all=true or provide more surrounding context to make it unique."
224
+ if replace_all:
225
+ new_content = content.replace(old_string, new_string)
226
+ else:
227
+ new_content = content.replace(old_string, new_string, 1)
228
+ p.write_text(new_content, encoding="utf-8")
229
+ return f"Edited {path} ({count} replacement{'s' if count != 1 else ''})"
230
+ except Exception as e:
231
+ return f"Error editing file: {e}"
232
+
233
+
234
+ def tool_list_files(pattern, path=None):
235
+ base = resolve_path(path) if path else Path.cwd()
236
+ try:
237
+ matches = sorted(glob_module.glob(str(base / pattern), recursive=True))
238
+ results = []
239
+ for m in matches:
240
+ mp = Path(m)
241
+ # Skip entries inside ignored directories
242
+ try:
243
+ rel = mp.relative_to(Path.cwd())
244
+ except ValueError:
245
+ rel = mp
246
+ if any(part in SKIP_DIRS for part in rel.parts):
247
+ continue
248
+ results.append(str(rel))
249
+ if len(results) >= 200:
250
+ break
251
+ if not results:
252
+ return "No files matched."
253
+ out = "\n".join(results)
254
+ remaining = len(matches) - len(results)
255
+ if remaining > 0:
256
+ out += f"\n... and {remaining} more files"
257
+ return out
258
+ except Exception as e:
259
+ return f"Error: {e}"
260
+
261
+
262
+ def tool_search_files(pattern, path=None, include=None):
263
+ base = resolve_path(path) if path else Path.cwd()
264
+ try:
265
+ regex = re.compile(pattern)
266
+ except re.error as e:
267
+ return f"Invalid regex: {e}"
268
+
269
+ results = []
270
+ search_path = base if base.is_dir() else base.parent
271
+ file_pattern = include or "*"
272
+
273
+ try:
274
+ files = [base] if base.is_file() else sorted(search_path.rglob(file_pattern))
275
+ for fp in files:
276
+ if not fp.is_file():
277
+ continue
278
+ try:
279
+ rel_parts = fp.relative_to(search_path).parts
280
+ except ValueError:
281
+ rel_parts = fp.parts
282
+ if any(part in SKIP_DIRS for part in rel_parts):
283
+ continue
284
+ try:
285
+ text = fp.read_text(encoding="utf-8", errors="strict")
286
+ except (UnicodeDecodeError, PermissionError):
287
+ continue
288
+ for i, line in enumerate(text.splitlines(), 1):
289
+ if regex.search(line):
290
+ try:
291
+ rel = str(fp.relative_to(Path.cwd()))
292
+ except ValueError:
293
+ rel = str(fp)
294
+ results.append(f"{rel}:{i}: {line.rstrip()}")
295
+ if len(results) >= 100:
296
+ break
297
+ if len(results) >= 100:
298
+ break
299
+ if not results:
300
+ return "No matches found."
301
+ return "\n".join(results)
302
+ except Exception as e:
303
+ return f"Error searching: {e}"
304
+
305
+
306
+ def tool_bash(command, timeout=120):
307
+ try:
308
+ result = subprocess.run(
309
+ command, shell=True, capture_output=True, text=True,
310
+ timeout=timeout, cwd=str(Path.cwd()),
311
+ )
312
+ parts = []
313
+ if result.stdout:
314
+ parts.append(result.stdout)
315
+ if result.stderr:
316
+ parts.append(result.stderr)
317
+ output = "\n".join(parts).strip()
318
+ if result.returncode != 0:
319
+ output += f"\n(exit code: {result.returncode})"
320
+ return output if output else "(no output)"
321
+ except subprocess.TimeoutExpired:
322
+ return f"Command timed out after {timeout}s"
323
+ except Exception as e:
324
+ return f"Error: {e}"
325
+
326
+
327
+ TOOL_DISPATCH = {
328
+ "read_file": tool_read_file,
329
+ "write_file": tool_write_file,
330
+ "edit_file": tool_edit_file,
331
+ "list_files": tool_list_files,
332
+ "search_files": tool_search_files,
333
+ "bash": tool_bash,
334
+ }
335
+
336
+
337
+ def execute_tool(name, inputs):
338
+ fn = TOOL_DISPATCH.get(name)
339
+ if not fn:
340
+ return f"Unknown tool: {name}"
341
+ return fn(**inputs)
342
+
343
+
344
+ # ── Reflection Nudge ──────────────────────────────────────────────────────
345
+
346
+ REFLECT_NUDGE = (
347
+ "\n\n[Before continuing, verify this change is correct and consider "
348
+ "if there's a simpler approach.]"
349
+ )
350
+
351
+
352
+ def _should_nudge(name, inputs, result):
353
+ """Return True for substantive, successful file changes worth reflecting on."""
354
+ if result.startswith("Error"):
355
+ return False
356
+ if name == "write_file":
357
+ return inputs.get("content", "").count("\n") >= 10
358
+ if name == "edit_file":
359
+ return inputs.get("new_string", "").count("\n") >= 3
360
+ return False
361
+
362
+ # ── Permissions ─────────────────────────────────────────────────────────────
363
+
364
+ # Tools that are safe to auto-approve (read-only)
365
+ AUTO_APPROVE = {"read_file", "list_files", "search_files"}
366
+
367
+ # Tools that require user permission
368
+ NEEDS_PERMISSION = {"write_file", "edit_file", "bash"}
369
+
370
+ # Session-level "always allow" set — populated when user presses 'a'
371
+ always_allowed = set()
372
+
373
+
374
+ def show_tool_preview(name, inputs):
375
+ """Show a detailed preview of what the tool will do."""
376
+ print(f" {C.DIM}{'─' * 60}{C.RESET}")
377
+
378
+ if name == "write_file":
379
+ path = inputs.get("path", "?")
380
+ content = inputs.get("content", "")
381
+ line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
382
+ print(f" {C.YELLOW}write{C.RESET} {C.BOLD}{path}{C.RESET} {C.DIM}({line_count} lines){C.RESET}")
383
+ print(f" {C.DIM}{'─' * 60}{C.RESET}")
384
+ # Show first/last few lines as preview
385
+ lines = content.splitlines()
386
+ preview_lines = 8
387
+ if len(lines) <= preview_lines * 2:
388
+ for line in lines:
389
+ print(f" {C.GREEN}+ {line}{C.RESET}")
390
+ else:
391
+ for line in lines[:preview_lines]:
392
+ print(f" {C.GREEN}+ {line}{C.RESET}")
393
+ print(f" {C.DIM} ... ({len(lines) - preview_lines * 2} more lines){C.RESET}")
394
+ for line in lines[-preview_lines:]:
395
+ print(f" {C.GREEN}+ {line}{C.RESET}")
396
+
397
+ elif name == "edit_file":
398
+ path = inputs.get("path", "?")
399
+ old = inputs.get("old_string", "")
400
+ new = inputs.get("new_string", "")
401
+ replace_all = inputs.get("replace_all", False)
402
+ label = "edit (all)" if replace_all else "edit"
403
+ print(f" {C.YELLOW}{label}{C.RESET} {C.BOLD}{path}{C.RESET}")
404
+ print(f" {C.DIM}{'─' * 60}{C.RESET}")
405
+ for line in old.splitlines():
406
+ print(f" {C.RED}- {line}{C.RESET}")
407
+ for line in new.splitlines():
408
+ print(f" {C.GREEN}+ {line}{C.RESET}")
409
+
410
+ elif name == "bash":
411
+ cmd = inputs.get("command", "?")
412
+ print(f" {C.YELLOW}${C.RESET} {C.BOLD}{cmd}{C.RESET}")
413
+
414
+ print(f" {C.DIM}{'─' * 60}{C.RESET}")
415
+
416
+
417
+ def request_permission(name, inputs):
418
+ """
419
+ Ask the user for permission to run a tool.
420
+ Returns True if allowed, False if denied.
421
+ Updates always_allowed if user picks 'a'.
422
+ """
423
+ if name in AUTO_APPROVE:
424
+ return True
425
+ if name in always_allowed:
426
+ return True
427
+
428
+ show_tool_preview(name, inputs)
429
+
430
+ while True:
431
+ try:
432
+ choice = input(f" {C.YELLOW}Allow?{C.RESET} {C.DIM}[y]es / [n]o / [a]lways{C.RESET} ").strip().lower()
433
+ except EOFError:
434
+ return False
435
+
436
+ if choice in ("y", "yes", ""):
437
+ return True
438
+ elif choice in ("a", "always"):
439
+ always_allowed.add(name)
440
+ return True
441
+ elif choice in ("n", "no"):
442
+ return False
443
+ # anything else, re-prompt
444
+
445
+
446
+ # ── Display Helpers ─────────────────────────────────────────────────────────
447
+
448
+ TOOL_LABELS = {
449
+ "read_file": ("read", C.BLUE),
450
+ "write_file": ("write", C.GREEN),
451
+ "edit_file": ("edit", C.YELLOW),
452
+ "list_files": ("ls", C.CYAN),
453
+ "search_files": ("grep", C.MAGENTA),
454
+ "bash": ("$", C.RED),
455
+ }
456
+
457
+
458
+ def format_tool_header(name, inputs):
459
+ label, _ = TOOL_LABELS.get(name, (name, C.DIM))
460
+ if name in ("read_file", "write_file", "edit_file"):
461
+ detail = inputs.get("path", "")
462
+ elif name == "list_files":
463
+ detail = inputs.get("pattern", "")
464
+ elif name == "search_files":
465
+ detail = f"/{inputs.get('pattern', '')}/"
466
+ elif name == "bash":
467
+ cmd = inputs.get("command", "")
468
+ detail = cmd if len(cmd) <= 80 else cmd[:77] + "..."
469
+ else:
470
+ detail = ""
471
+ return f"{label} {detail}"
472
+
473
+
474
+ def print_tool_call(name, inputs):
475
+ """Print a colored tool header."""
476
+ _, color = TOOL_LABELS.get(name, (name, C.DIM))
477
+ header = format_tool_header(name, inputs)
478
+ print(f" {color}{header}{C.RESET}")
479
+
480
+
481
+ def print_tool_result_summary(name, result):
482
+ lines = result.split("\n")
483
+ is_error = result.startswith("Error")
484
+ if is_error:
485
+ print(f" {C.RED}{lines[0]}{C.RESET}")
486
+ elif len(lines) <= 3:
487
+ for line in lines:
488
+ print(f" {C.DIM}{line}{C.RESET}")
489
+ else:
490
+ print(f" {C.DIM}{lines[0]}{C.RESET}")
491
+ print(f" {C.DIM}... ({len(lines)} lines){C.RESET}")
492
+
493
+ # ── Context Pruning ────────────────────────────────────────────────────────
494
+
495
+ # Keep full content for the last N messages; summarize older tool results.
496
+ # This prevents input tokens from growing quadratically over long sessions.
497
+ # Claude can always re-read files if it needs the content again.
498
+ KEEP_RECENT = 6 # number of recent messages to keep verbatim
499
+
500
+
501
+ def _estimate_tokens(messages):
502
+ """Rough token estimate: 1 token ≈ 4 chars."""
503
+ total = 0
504
+ for msg in messages:
505
+ content = msg.get("content", "")
506
+ if isinstance(content, str):
507
+ total += len(content)
508
+ elif isinstance(content, list):
509
+ for item in content:
510
+ if isinstance(item, dict):
511
+ total += len(str(item.get("content", "")))
512
+ else:
513
+ # ContentBlock objects from the API
514
+ if hasattr(item, "text"):
515
+ total += len(item.text)
516
+ elif hasattr(item, "input"):
517
+ total += len(str(item.input))
518
+ else:
519
+ total += len(str(content))
520
+ return total // 4
521
+
522
+
523
+ def _summarize_tool_result(tool_result):
524
+ """Replace a tool result's content with a short placeholder."""
525
+ content = tool_result.get("content", "")
526
+ if len(content) <= 200:
527
+ return # already small, not worth summarizing
528
+
529
+ # Extract first meaningful line for context
530
+ first_line = content.split("\n", 1)[0].strip()
531
+ line_count = content.count("\n") + 1
532
+
533
+ if first_line.startswith("Error"):
534
+ # Keep error messages intact — they're small and Claude may need them
535
+ return
536
+
537
+ tool_result["content"] = f"[{line_count} lines, truncated to save context — re-read the file or re-run the command if needed]\n{first_line}"
538
+
539
+
540
+ def _summarize_tool_use_input(block):
541
+ """Shrink large tool_use inputs in assistant messages (e.g. write_file content)."""
542
+ if not hasattr(block, "input") or not isinstance(block.input, dict):
543
+ return
544
+ content = block.input.get("content", "")
545
+ if isinstance(content, str) and len(content) > 500:
546
+ line_count = content.count("\n") + 1
547
+ block.input["content"] = f"[file content: {line_count} lines, truncated]"
548
+
549
+
550
+ def prune_context(messages, token_budget=None):
551
+ """
552
+ Trim old tool results when conversation exceeds the token budget.
553
+ Keeps the first message (original prompt) and the last KEEP_RECENT
554
+ messages verbatim. Everything in between gets tool results summarized
555
+ and large tool_use inputs shrunk.
556
+ """
557
+ if token_budget is None:
558
+ token_budget = int(os.environ.get("CODER_TOKEN_BUDGET", "80000"))
559
+
560
+ est = _estimate_tokens(messages)
561
+ if est <= token_budget:
562
+ return
563
+
564
+ # Protected: first message + last KEEP_RECENT
565
+ prune_end = max(1, len(messages) - KEEP_RECENT)
566
+ before = est
567
+
568
+ for i in range(1, prune_end):
569
+ msg = messages[i]
570
+ content = msg.get("content")
571
+
572
+ if isinstance(content, list):
573
+ for item in content:
574
+ if isinstance(item, dict) and item.get("type") == "tool_result":
575
+ _summarize_tool_result(item)
576
+ # Shrink assistant tool_use inputs (e.g. write_file with full file content)
577
+ elif hasattr(item, "type") and item.type == "tool_use":
578
+ _summarize_tool_use_input(item)
579
+
580
+ after = _estimate_tokens(messages)
581
+ saved = before - after
582
+ if saved > 0:
583
+ print(f" {C.DIM}[pruned ~{saved:,} tokens from old context]{C.RESET}", file=sys.stderr, flush=True)
584
+
585
+
586
+ # ── Streaming Response Handler ──────────────────────────────────────────────
587
+
588
+ def stream_response(client, messages, system):
589
+ """Stream Claude's response, handling tool-use loops until done."""
590
+ while True:
591
+ printed_text = False
592
+
593
+ with client.messages.stream(
594
+ model=MODEL,
595
+ max_tokens=MAX_TOKENS,
596
+ system=system,
597
+ tools=TOOLS,
598
+ messages=messages,
599
+ ) as stream:
600
+ for event in stream:
601
+ if event.type == "content_block_delta":
602
+ if hasattr(event.delta, "text"):
603
+ sys.stdout.write(event.delta.text)
604
+ sys.stdout.flush()
605
+ printed_text = True
606
+
607
+ response = stream.get_final_message()
608
+
609
+ if printed_text:
610
+ print() # newline after streamed text
611
+
612
+ # Add the full assistant message to conversation
613
+ messages.append({"role": "assistant", "content": response.content})
614
+
615
+ # If stop reason is tool_use, execute tools and loop
616
+ if response.stop_reason == "tool_use":
617
+ tool_results = []
618
+ for block in response.content:
619
+ if block.type == "tool_use":
620
+ if SAFE_MODE and block.name in NEEDS_PERMISSION:
621
+ # Safe mode: show preview and ask
622
+ if request_permission(block.name, block.input):
623
+ result = execute_tool(block.name, block.input)
624
+ else:
625
+ result = f"Permission denied: user rejected {block.name} call."
626
+ else:
627
+ # Default: just run it
628
+ print_tool_call(block.name, block.input)
629
+ result = execute_tool(block.name, block.input)
630
+ # Truncate huge results
631
+ if len(result) > 15000:
632
+ result = result[:15000] + "\n... (output truncated)"
633
+ if _should_nudge(block.name, block.input, result):
634
+ result += REFLECT_NUDGE
635
+ print_tool_result_summary(block.name, result)
636
+ tool_results.append({
637
+ "type": "tool_result",
638
+ "tool_use_id": block.id,
639
+ "content": result,
640
+ })
641
+ messages.append({"role": "user", "content": tool_results})
642
+ prune_context(messages)
643
+ print() # breathing room before next response
644
+ else:
645
+ # Print token usage
646
+ if hasattr(response, "usage") and response.usage:
647
+ inp = response.usage.input_tokens
648
+ out = response.usage.output_tokens
649
+ print(f"{C.DIM}[{inp} in / {out} out tokens]{C.RESET}")
650
+ break
651
+
652
+ # ── Headless Prompt Mode ────────────────────────────────────────────────────
653
+
654
+ def run_prompt(client, prompt, system):
655
+ """Run a single prompt non-interactively. Returns a JSON-serializable dict."""
656
+ messages = [{"role": "user", "content": prompt}]
657
+ files_modified = set()
658
+ files_created = set()
659
+ commands_run = []
660
+ errors = [] # fatal errors
661
+ warnings = [] # recoverable issues (edit retries, etc.)
662
+ total_input_tokens = 0
663
+ total_output_tokens = 0
664
+
665
+ # Agent loop — no streaming, log progress to stderr
666
+ max_turns = int(os.environ.get("CODER_MAX_TURNS", "65"))
667
+ turn = 0
668
+
669
+ while turn < max_turns:
670
+ turn += 1
671
+
672
+ try:
673
+ response = client.messages.create(
674
+ model=MODEL,
675
+ max_tokens=MAX_TOKENS,
676
+ system=system,
677
+ tools=TOOLS,
678
+ messages=messages,
679
+ )
680
+ except anthropic.APIError as e:
681
+ if is_token_limit_error(e):
682
+ print(f"\n{C.YELLOW}Token limit reached — stopping.{C.RESET}", file=sys.stderr, flush=True)
683
+ errors.append(str(e))
684
+ break
685
+ raise
686
+
687
+ # Per-turn token tracking
688
+ turn_in = turn_out = 0
689
+ if hasattr(response, "usage") and response.usage:
690
+ turn_in = response.usage.input_tokens
691
+ turn_out = response.usage.output_tokens
692
+ total_input_tokens += turn_in
693
+ total_output_tokens += turn_out
694
+
695
+ messages.append({"role": "assistant", "content": response.content})
696
+
697
+ if response.stop_reason == "tool_use":
698
+ tool_results = []
699
+ for block in response.content:
700
+ if block.type == "tool_use":
701
+ # Track what's happening
702
+ if block.name == "write_file":
703
+ path = block.input.get("path", "")
704
+ if resolve_path(path).exists():
705
+ files_modified.add(path)
706
+ else:
707
+ files_created.add(path)
708
+ elif block.name == "edit_file":
709
+ pass # tracked after execution below
710
+ elif block.name == "bash":
711
+ commands_run.append(block.input.get("command", ""))
712
+
713
+ # Colored tool log to stderr
714
+ _, color = TOOL_LABELS.get(block.name, (block.name, C.DIM))
715
+ header = format_tool_header(block.name, block.input)
716
+ print(f" {color}{header}{C.RESET}", file=sys.stderr, flush=True)
717
+
718
+ result = execute_tool(block.name, block.input)
719
+
720
+ # Track successful edits
721
+ if block.name == "edit_file" and not result.startswith("Error"):
722
+ files_modified.add(block.input.get("path", ""))
723
+
724
+ if result.startswith("Error"):
725
+ err_msg = f"{block.name}: {result}"
726
+ # Recoverable: file not found on read (exploring), edit match failures (retries)
727
+ if (block.name == "read_file" and "not found" in result) or \
728
+ (block.name == "edit_file" and "not found" in result):
729
+ warnings.append(err_msg)
730
+ print(f" {C.YELLOW}{result.splitlines()[0]}{C.RESET}", file=sys.stderr, flush=True)
731
+ else:
732
+ errors.append(err_msg)
733
+ print(f" {C.RED}{result.splitlines()[0]}{C.RESET}", file=sys.stderr, flush=True)
734
+
735
+ if len(result) > 15000:
736
+ result = result[:15000] + "\n... (output truncated)"
737
+ if _should_nudge(block.name, block.input, result):
738
+ result += REFLECT_NUDGE
739
+
740
+ tool_results.append({
741
+ "type": "tool_result",
742
+ "tool_use_id": block.id,
743
+ "content": result,
744
+ })
745
+
746
+ # Token usage for this turn
747
+ print(f" {C.DIM}[{turn_in} in / {turn_out} out]{C.RESET}", file=sys.stderr, flush=True)
748
+
749
+ messages.append({"role": "user", "content": tool_results})
750
+
751
+ # Prune old tool results to prevent quadratic token growth
752
+ prune_context(messages)
753
+ else:
754
+ break
755
+
756
+ if turn >= max_turns:
757
+ errors.append(f"Hit max turns limit ({max_turns})")
758
+ print(f"{C.RED}Max turns reached ({max_turns}), stopping{C.RESET}", file=sys.stderr, flush=True)
759
+
760
+ # Final totals
761
+ print(f"{C.DIM}Coder finished: {turn} turns, {total_input_tokens} in / {total_output_tokens} out tokens{C.RESET}", file=sys.stderr, flush=True)
762
+
763
+ # Extract Claude's final text response
764
+ final_text = "".join(
765
+ block.text for block in response.content if block.type == "text"
766
+ ) if response else ""
767
+
768
+ # Ask Claude for a CodeRabbit-oriented summary (skip if we hit token limit)
769
+ # Uses a standalone minimal prompt — no conversation history, system prompt, or tools.
770
+ summary = final_text.strip()
771
+ if not any(is_token_limit_error(e) for e in errors):
772
+ summary_messages = [{
773
+ "role": "user",
774
+ "content": (
775
+ f"Summarize these code changes in 2-3 sentences for a code review tool.\n\n"
776
+ f"Files modified: {', '.join(sorted(files_modified)) or 'none'}\n"
777
+ f"Files created: {', '.join(sorted(files_created)) or 'none'}\n\n"
778
+ f"Agent's final notes:\n{final_text[:2000]}\n\n"
779
+ f"Focus on what changed, what was added/fixed, and why. Be specific. No preamble."
780
+ ),
781
+ }]
782
+
783
+ try:
784
+ summary_response = client.messages.create(
785
+ model=MODEL,
786
+ max_tokens=1024,
787
+ messages=summary_messages,
788
+ )
789
+
790
+ if hasattr(summary_response, "usage") and summary_response.usage:
791
+ total_input_tokens += summary_response.usage.input_tokens
792
+ total_output_tokens += summary_response.usage.output_tokens
793
+
794
+ summary = "".join(
795
+ block.text for block in summary_response.content if block.type == "text"
796
+ ).strip()
797
+ except anthropic.APIError as e:
798
+ if is_token_limit_error(e):
799
+ errors.append(str(e))
800
+ else:
801
+ raise
802
+
803
+ result = {
804
+ "completed": len(errors) == 0,
805
+ "summary": summary,
806
+ "files_modified": sorted(files_modified),
807
+ "files_created": sorted(files_created),
808
+ "commands_run": commands_run,
809
+ "usage": {
810
+ "input_tokens": total_input_tokens,
811
+ "output_tokens": total_output_tokens,
812
+ },
813
+ }
814
+ if errors:
815
+ result["errors"] = errors
816
+ if warnings:
817
+ result["warnings"] = warnings
818
+
819
+ return result
820
+
821
+ # ── Main ────────────────────────────────────────────────────────────────────
822
+
823
+ def main():
824
+ parser = argparse.ArgumentParser(description="Coder — AI coding agent powered by Claude")
825
+ parser.add_argument("-p", "--prompt", help="Run a single prompt headlessly and output JSON")
826
+ args = parser.parse_args()
827
+
828
+ # if not os.environ.get("ANTHROPIC_API_KEY"):
829
+ # print(f"{C.RED}Error: ANTHROPIC_API_KEY not set.{C.RESET}", file=sys.stderr)
830
+ # print(" export ANTHROPIC_API_KEY=sk-ant-... (or pk-... for proxy)", file=sys.stderr)
831
+ # sys.exit(1)
832
+
833
+ proxy_url = os.environ.get("CODER_PROXY_URL")
834
+ if proxy_url:
835
+ client = anthropic.Anthropic(base_url=proxy_url.rstrip('/'))
836
+ else:
837
+ client = anthropic.Anthropic()
838
+ system = SYSTEM_PROMPT.format(cwd=os.getcwd(), approach=APPROACH_BLOCK)
839
+
840
+ # Load codebase map if available
841
+ map_file = Path.cwd() / ".coder" / "map.md"
842
+ if map_file.exists():
843
+ try:
844
+ map_content = map_file.read_text(encoding="utf-8").strip()
845
+ if len(map_content) > 20000:
846
+ map_content = map_content[:20000] + "\n\n... (map truncated — use list_files to explore further)"
847
+ system += f"\n\n{map_content}"
848
+ print(f"{C.DIM}Loaded codebase map ({map_content.count(chr(10))} lines){C.RESET}", file=sys.stderr)
849
+ except OSError:
850
+ pass
851
+
852
+ # ── Headless prompt mode ────────────────────────────────────────────
853
+ if args.prompt:
854
+ try:
855
+ result = run_prompt(client, args.prompt, system)
856
+ print(json.dumps(result, indent=2))
857
+ sys.exit(0 if result["completed"] else 1)
858
+ except anthropic.APIError as e:
859
+ print(json.dumps({
860
+ "completed": False,
861
+ "summary": f"API error: {e}",
862
+ "files_modified": [],
863
+ "files_created": [],
864
+ "commands_run": [],
865
+ "errors": [str(e)],
866
+ }, indent=2))
867
+ sys.exit(1)
868
+ except KeyboardInterrupt:
869
+ print(json.dumps({
870
+ "completed": False,
871
+ "summary": "Task interrupted.",
872
+ "files_modified": [],
873
+ "files_created": [],
874
+ "commands_run": [],
875
+ "errors": ["Interrupted by user"],
876
+ }, indent=2))
877
+ sys.exit(130)
878
+
879
+ # ── Interactive REPL mode ───────────────────────────────────────────
880
+ messages = []
881
+
882
+ mode_label = f" {C.YELLOW}(safe mode){C.RESET}" if SAFE_MODE else ""
883
+ print(f"\n{C.BOLD}{C.BLUE}Coder{C.RESET} {C.DIM}— AI coding agent{C.RESET}{mode_label}")
884
+ print(f"{C.DIM}Model: {MODEL}{C.RESET}")
885
+ print(f"{C.DIM}Dir: {os.getcwd()}{C.RESET}")
886
+ print(f"{C.DIM}Commands: /clear /quit /help{C.RESET}")
887
+ print()
888
+
889
+ histfile = os.path.expanduser("~/.coder_history")
890
+ try:
891
+ readline.read_history_file(histfile)
892
+ except (FileNotFoundError, OSError, PermissionError):
893
+ pass
894
+ readline.set_history_length(1000)
895
+
896
+ try:
897
+ while True:
898
+ try:
899
+ user_input = input(f"{C.GREEN}>{C.RESET} ").strip()
900
+ except EOFError:
901
+ print("\nBye!")
902
+ break
903
+
904
+ if not user_input:
905
+ continue
906
+
907
+ if user_input in ("/quit", "/exit", "/q"):
908
+ print("Bye!")
909
+ break
910
+ if user_input == "/clear":
911
+ messages.clear()
912
+ print(f"{C.DIM}Conversation cleared.{C.RESET}\n")
913
+ continue
914
+ if user_input == "/help":
915
+ print(f" {C.DIM}/clear Clear conversation history{C.RESET}")
916
+ print(f" {C.DIM}/quit Exit coder{C.RESET}")
917
+ print(f" {C.DIM}/model Show current model{C.RESET}")
918
+ print(f" {C.DIM}/tokens Token estimate for conversation{C.RESET}")
919
+ if SAFE_MODE:
920
+ print(f" {C.DIM}/permissions Show always-allowed tools{C.RESET}")
921
+ print(f" {C.DIM}/permissions reset Reset all permissions{C.RESET}")
922
+ print(f" {C.DIM}Ctrl+C Cancel current response{C.RESET}")
923
+ print()
924
+ continue
925
+ if user_input == "/model":
926
+ print(f" {C.DIM}{MODEL}{C.RESET}\n")
927
+ continue
928
+ if user_input == "/permissions":
929
+ if always_allowed:
930
+ print(f" {C.DIM}Always allowed: {', '.join(sorted(always_allowed))}{C.RESET}")
931
+ else:
932
+ print(f" {C.DIM}No tools always-allowed yet. (approve with 'a' when prompted){C.RESET}")
933
+ print()
934
+ continue
935
+ if user_input == "/permissions reset":
936
+ always_allowed.clear()
937
+ print(f" {C.DIM}Permissions reset. All write tools will prompt again.{C.RESET}\n")
938
+ continue
939
+ if user_input == "/tokens":
940
+ chars = sum(
941
+ len(json.dumps(m.get("content", ""))) for m in messages
942
+ )
943
+ print(f" {C.DIM}~{chars // 4} tokens in conversation{C.RESET}\n")
944
+ continue
945
+
946
+ snapshot = len(messages)
947
+ messages.append({"role": "user", "content": user_input})
948
+
949
+ try:
950
+ print()
951
+ stream_response(client, messages, system)
952
+ print()
953
+ except KeyboardInterrupt:
954
+ del messages[snapshot:]
955
+ print(f"\n{C.DIM}(interrupted){C.RESET}\n")
956
+ except anthropic.APIError as e:
957
+ del messages[snapshot:]
958
+ print(f"\n{C.RED}API error: {e}{C.RESET}\n")
959
+
960
+ except KeyboardInterrupt:
961
+ print("\nBye!")
962
+ finally:
963
+ try:
964
+ readline.write_history_file(histfile)
965
+ except OSError:
966
+ pass
967
+
968
+
969
+ if __name__ == "__main__":
970
+ main()