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/index.js +1184 -0
- package/package.json +29 -0
- package/providers/azuredevops.js +202 -0
- package/providers/github.js +144 -0
- package/providers/index.js +51 -0
- package/scripts/postinstall.js +125 -0
- package/tools/coder.py +970 -0
- package/tools/mapper.py +465 -0
- package/tools/qa.py +528 -0
- package/tools/requirements.txt +3 -0
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()
|