agentic-ci 0.1.0__py3-none-any.whl

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.
agentic_ci/__init__.py ADDED
File without changes
agentic_ci/cli.py ADDED
@@ -0,0 +1,91 @@
1
+ """CLI entry point for agentic-ci."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser(
9
+ prog="agentic-ci",
10
+ description="Tooling for running AI coding agents in CI/CD environments",
11
+ )
12
+ sub = parser.add_subparsers(dest="command")
13
+
14
+ # setup
15
+ p_setup = sub.add_parser("setup", help="Bootstrap CI container")
16
+ p_setup.add_argument("--workspace", default=None)
17
+ p_setup.add_argument("--user", default="claude-ci")
18
+
19
+ # run
20
+ p_run = sub.add_parser("run", help="Run Claude Code with telemetry")
21
+ p_run.add_argument("prompt")
22
+ p_run.add_argument("workdir", nargs="?", default=".")
23
+ p_run.add_argument("--model", default=None)
24
+
25
+ # stream
26
+ p_stream = sub.add_parser("stream", help="Parse stream-json from stdin")
27
+ p_stream.add_argument("--wrap", type=int, default=0, metavar="COLS")
28
+ p_stream.add_argument("--no-color", action="store_true")
29
+ p_stream.add_argument("--claude-pid", type=int, default=0)
30
+
31
+ # otel-collect
32
+ sub.add_parser("otel-collect", help="Start OTLP metrics collector")
33
+
34
+ # otel-summary
35
+ p_summary = sub.add_parser("otel-summary", help="Print token/cost summary")
36
+ p_summary.add_argument("log_file", nargs="?", default="/tmp/claude-otel.jsonl")
37
+
38
+ # extract
39
+ p_extract = sub.add_parser("extract", help="Extract JSON result from stream output")
40
+ p_extract.add_argument("stream_file")
41
+ p_extract.add_argument("output_dir")
42
+
43
+ args = parser.parse_args()
44
+
45
+ if args.command == "setup":
46
+ from agentic_ci.setup import setup
47
+ setup(workspace=args.workspace, user=args.user)
48
+
49
+ elif args.command == "run":
50
+ from agentic_ci.runner import run
51
+ sys.exit(run(args.prompt, args.workdir, model=args.model))
52
+
53
+ elif args.command == "stream":
54
+ from agentic_ci.stream import StreamProcessor
55
+ processor = StreamProcessor(
56
+ color=not args.no_color,
57
+ wrap=args.wrap,
58
+ claude_pid=args.claude_pid,
59
+ )
60
+ processor.process(sys.stdin)
61
+ print()
62
+
63
+ elif args.command == "otel-collect":
64
+ from agentic_ci.otel_collector import main as collector_main
65
+ collector_main()
66
+
67
+ elif args.command == "otel-summary":
68
+ from agentic_ci.otel_summary import print_summary
69
+ print_summary(args.log_file)
70
+
71
+ elif args.command == "extract":
72
+ from agentic_ci.extract import extract_text_from_stream, parse_result, write_outputs
73
+ import json
74
+ text = extract_text_from_stream(args.stream_file)
75
+ if not text:
76
+ print("Error: no text content found", file=sys.stderr)
77
+ sys.exit(1)
78
+ try:
79
+ result = parse_result(text)
80
+ except json.JSONDecodeError as e:
81
+ print(f"Error: could not parse JSON: {e}", file=sys.stderr)
82
+ sys.exit(1)
83
+ write_outputs(result, args.output_dir)
84
+
85
+ else:
86
+ parser.print_help()
87
+ sys.exit(1)
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
agentic_ci/extract.py ADDED
@@ -0,0 +1,114 @@
1
+ """Extract structured JSON result from Claude's stream-json output.
2
+
3
+ Reads the stream-json capture file, extracts the final text content,
4
+ parses it as JSON, and writes individual fields to an output directory.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+
12
+
13
+ def extract_text_from_stream(stream_file):
14
+ """Reassemble the full text content from stream-json deltas."""
15
+ text = ""
16
+ with open(stream_file) as f:
17
+ for line in f:
18
+ line = line.strip()
19
+ if not line:
20
+ continue
21
+ try:
22
+ msg = json.loads(line)
23
+ except (json.JSONDecodeError, ValueError):
24
+ continue
25
+
26
+ if msg.get("type") != "stream_event":
27
+ continue
28
+
29
+ event = msg.get("event", {})
30
+ if event.get("type") == "content_block_delta":
31
+ delta = event.get("delta", {})
32
+ if delta.get("type") == "text_delta":
33
+ text += delta.get("text", "")
34
+
35
+ return text
36
+
37
+
38
+ def parse_result(text):
39
+ """Parse the JSON result from Claude's text output.
40
+
41
+ Claude may output explanatory text before/after the JSON, and may
42
+ wrap the JSON in markdown code fences.
43
+ """
44
+ fence_match = re.search(r"```(?:json)?\s*\n(.*?)\n\s*```", text, re.DOTALL)
45
+ if fence_match:
46
+ try:
47
+ return json.loads(fence_match.group(1).strip())
48
+ except json.JSONDecodeError:
49
+ pass
50
+
51
+ brace_start = text.find("{")
52
+ if brace_start >= 0:
53
+ depth = 0
54
+ for i in range(brace_start, len(text)):
55
+ if text[i] == "{":
56
+ depth += 1
57
+ elif text[i] == "}":
58
+ depth -= 1
59
+ if depth == 0:
60
+ try:
61
+ return json.loads(text[brace_start:i + 1])
62
+ except json.JSONDecodeError:
63
+ pass
64
+ break
65
+
66
+ return json.loads(text.strip())
67
+
68
+
69
+ def write_outputs(result, output_dir):
70
+ """Write structured fields to individual files in output_dir."""
71
+ os.makedirs(output_dir, exist_ok=True)
72
+
73
+ fields = {
74
+ "recommendation": str(result.get("recommendation", "unknown")),
75
+ "confidence": str(result.get("confidence", 0.0)),
76
+ "severity": str(result.get("severity", "unknown")),
77
+ "manipulation_detected": str(result.get("manipulation_detected", False)).lower(),
78
+ "analysis": str(result.get("analysis", "")),
79
+ }
80
+
81
+ for field, value in fields.items():
82
+ with open(os.path.join(output_dir, field), "w") as f:
83
+ f.write(value)
84
+
85
+ with open(os.path.join(output_dir, "result.json"), "w") as f:
86
+ json.dump(result, f, indent=2)
87
+
88
+
89
+ def main(args=None):
90
+ import argparse
91
+
92
+ parser = argparse.ArgumentParser(description="Extract JSON result from Claude stream output")
93
+ parser.add_argument("stream_file", help="Path to stream-json capture file")
94
+ parser.add_argument("output_dir", help="Directory to write extracted fields")
95
+ parsed = parser.parse_args(args)
96
+
97
+ text = extract_text_from_stream(parsed.stream_file)
98
+ if not text:
99
+ print("Error: no text content found in stream output", file=sys.stderr)
100
+ sys.exit(1)
101
+
102
+ try:
103
+ result = parse_result(text)
104
+ except json.JSONDecodeError as e:
105
+ print(f"Error: could not parse JSON from Claude output: {e}", file=sys.stderr)
106
+ print(f"Raw text:\n{text[:500]}", file=sys.stderr)
107
+ sys.exit(1)
108
+
109
+ write_outputs(result, parsed.output_dir)
110
+ print(f"Result extracted to {parsed.output_dir}/", file=sys.stderr)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -0,0 +1,104 @@
1
+ """Lightweight OTLP HTTP/JSON receiver that writes payloads to a log file.
2
+
3
+ Listens on localhost and accepts OTLP HTTP/JSON exports for metrics
4
+ and logs, writing them to a structured log file for later analysis.
5
+
6
+ Also tracks token usage over time and writes a rate file for live
7
+ tokens/sec display.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import signal
13
+ import sys
14
+ import time
15
+ from datetime import datetime, timezone
16
+ from http.server import HTTPServer, BaseHTTPRequestHandler
17
+
18
+ _token_samples = []
19
+ _WINDOW_SECS = 60
20
+
21
+
22
+ class OTLPHandler(BaseHTTPRequestHandler):
23
+ def do_POST(self):
24
+ length = int(self.headers.get("Content-Length", 0))
25
+ body = self.rfile.read(length) if length else b""
26
+ try:
27
+ payload = json.loads(body) if body else {}
28
+ except json.JSONDecodeError:
29
+ payload = {"raw": body.decode("utf-8", errors="replace")}
30
+
31
+ record = {
32
+ "ts": datetime.now(timezone.utc).isoformat(),
33
+ "path": self.path,
34
+ "payload": payload,
35
+ }
36
+ log_file = os.environ.get("OTEL_LOG_FILE", "/tmp/claude-otel.jsonl")
37
+ with open(log_file, "a") as f:
38
+ f.write(json.dumps(record) + "\n")
39
+
40
+ if "/v1/metrics" in self.path:
41
+ _update_token_rate(payload)
42
+
43
+ self.send_response(200)
44
+ self.send_header("Content-Type", "application/json")
45
+ self.end_headers()
46
+ self.wfile.write(b'{"partialSuccess":{}}')
47
+
48
+ def log_message(self, format, *args):
49
+ pass
50
+
51
+
52
+ def _update_token_rate(payload):
53
+ global _token_samples
54
+ now = time.monotonic()
55
+ total = 0
56
+ for rm in payload.get("resourceMetrics", []):
57
+ for sm in rm.get("scopeMetrics", []):
58
+ for metric in sm.get("metrics", []):
59
+ if metric.get("name") == "claude_code.token.usage":
60
+ data = metric.get("sum", metric.get("gauge", {}))
61
+ for dp in data.get("dataPoints", []):
62
+ total += dp.get("asDouble", dp.get("asInt", 0))
63
+ if total <= 0:
64
+ return
65
+
66
+ _token_samples.append((now, total))
67
+ cutoff = now - _WINDOW_SECS
68
+ _token_samples = [(t, v) for t, v in _token_samples if t >= cutoff]
69
+
70
+ rate = 0.0
71
+ if len(_token_samples) >= 2:
72
+ dt = _token_samples[-1][0] - _token_samples[0][0]
73
+ dv = _token_samples[-1][1] - _token_samples[0][1]
74
+ if dt > 0:
75
+ rate = dv / dt
76
+
77
+ rate_file = os.environ.get("OTEL_RATE_FILE", "/tmp/claude-otel-rate.json")
78
+ tmp = rate_file + ".tmp"
79
+ with open(tmp, "w") as f:
80
+ json.dump({"total": total, "rate": rate, "ts": time.time()}, f)
81
+ os.replace(tmp, rate_file)
82
+
83
+
84
+ def main(args=None):
85
+ port = int(os.environ.get("OTEL_COLLECTOR_PORT", "4318"))
86
+ server = HTTPServer(("127.0.0.1", port), OTLPHandler)
87
+ actual_port = server.server_address[1]
88
+ port_file = os.environ.get("OTEL_PORT_FILE")
89
+ if port_file:
90
+ with open(port_file, "w") as f:
91
+ f.write(str(actual_port))
92
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
93
+ log_file = os.environ.get("OTEL_LOG_FILE", "/tmp/claude-otel.jsonl")
94
+ print(f"OTLP collector listening on 127.0.0.1:{actual_port}, writing to {log_file}",
95
+ file=sys.stderr)
96
+ try:
97
+ server.serve_forever()
98
+ except KeyboardInterrupt:
99
+ pass
100
+ server.server_close()
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
@@ -0,0 +1,139 @@
1
+ """Parse OTLP JSONL log and print a human-readable token/cost summary."""
2
+
3
+ import json
4
+ import sys
5
+ from collections import defaultdict
6
+
7
+
8
+ def parse_metrics(records):
9
+ token_totals = defaultdict(float)
10
+ cost_totals = defaultdict(float)
11
+ api_requests = []
12
+ active_time = defaultdict(float)
13
+
14
+ for rec in records:
15
+ path = rec.get("path", "")
16
+ payload = rec.get("payload", {})
17
+
18
+ if "/v1/metrics" in path:
19
+ for rm in payload.get("resourceMetrics", []):
20
+ for sm in rm.get("scopeMetrics", []):
21
+ for metric in sm.get("metrics", []):
22
+ name = metric.get("name", "")
23
+ data = metric.get("sum", metric.get("gauge", metric.get("histogram", {})))
24
+ for dp in data.get("dataPoints", []):
25
+ attrs = {
26
+ a["key"]: a["value"].get(
27
+ "stringValue",
28
+ a["value"].get("intValue", a["value"].get("doubleValue")),
29
+ )
30
+ for a in dp.get("attributes", [])
31
+ }
32
+ value = dp.get("asDouble", dp.get("asInt", 0))
33
+
34
+ if name == "claude_code.token.usage":
35
+ model = attrs.get("model", "unknown")
36
+ token_type = attrs.get("type", "unknown")
37
+ token_totals[(model, token_type)] += value
38
+ elif name == "claude_code.cost.usage":
39
+ model = attrs.get("model", "unknown")
40
+ cost_totals[model] += value
41
+ elif name == "claude_code.active_time.total":
42
+ time_type = attrs.get("type", "unknown")
43
+ active_time[time_type] += value
44
+
45
+ elif "/v1/logs" in path:
46
+ for rl in payload.get("resourceLogs", []):
47
+ for sl in rl.get("scopeLogs", []):
48
+ for lr in sl.get("logRecords", []):
49
+ event_name = ""
50
+ event_attrs = {}
51
+ for a in lr.get("attributes", []):
52
+ key = a["key"]
53
+ val = a["value"]
54
+ v = val.get("stringValue", val.get("intValue", val.get("doubleValue")))
55
+ event_attrs[key] = v
56
+ if key == "event.name":
57
+ event_name = v
58
+ if event_name == "claude_code.api_request":
59
+ api_requests.append(event_attrs)
60
+
61
+ return token_totals, cost_totals, api_requests, active_time
62
+
63
+
64
+ def print_summary(log_file):
65
+ records = []
66
+ try:
67
+ with open(log_file) as f:
68
+ for line in f:
69
+ line = line.strip()
70
+ if line:
71
+ records.append(json.loads(line))
72
+ except FileNotFoundError:
73
+ print("No OTEL data collected (log file not found).")
74
+ return
75
+ except json.JSONDecodeError as e:
76
+ print(f"Error parsing OTEL log: {e}")
77
+ return
78
+
79
+ if not records:
80
+ print("No OTEL data collected.")
81
+ return
82
+
83
+ token_totals, cost_totals, api_requests, active_time = parse_metrics(records)
84
+
85
+ print("=" * 60)
86
+ print(" CLAUDE TOKEN & COST SUMMARY (OpenTelemetry)")
87
+ print("=" * 60)
88
+
89
+ if token_totals:
90
+ models = sorted(set(m for m, _ in token_totals.keys()))
91
+ for model in models:
92
+ print(f"\n Model: {model}")
93
+ print(f" {'Token Type':<20} {'Count':>12}")
94
+ print(f" {'-'*20} {'-'*12}")
95
+ model_tokens = {t: c for (m, t), c in token_totals.items() if m == model}
96
+ for token_type in ["input", "cacheRead", "cacheCreation", "output"]:
97
+ if token_type in model_tokens:
98
+ print(f" {token_type:<20} {model_tokens[token_type]:>12,.0f}")
99
+ total = sum(model_tokens.values())
100
+ print(f" {'TOTAL':<20} {total:>12,.0f}")
101
+
102
+ if cost_totals:
103
+ print(f"\n {'Model':<30} {'Cost (USD)':>12}")
104
+ print(f" {'-'*30} {'-'*12}")
105
+ grand_total = 0.0
106
+ for model in sorted(cost_totals.keys()):
107
+ cost = cost_totals[model]
108
+ grand_total += cost
109
+ print(f" {model:<30} ${cost:>11.4f}")
110
+ if len(cost_totals) > 1:
111
+ print(f" {'TOTAL':<30} ${grand_total:>11.4f}")
112
+
113
+ if active_time:
114
+ print(f"\n Active Time:")
115
+ for time_type, seconds in sorted(active_time.items()):
116
+ mins, secs = divmod(int(seconds), 60)
117
+ print(f" {time_type}: {mins}m {secs}s")
118
+
119
+ if api_requests:
120
+ print(f"\n API Requests: {len(api_requests)}")
121
+ total_duration = sum(float(r.get("duration_ms", 0)) for r in api_requests)
122
+ if total_duration:
123
+ print(f" Total API time: {total_duration/1000:.1f}s")
124
+
125
+ print("=" * 60)
126
+
127
+
128
+ def main(args=None):
129
+ import argparse
130
+
131
+ parser = argparse.ArgumentParser(description="Print Claude OTEL token/cost summary")
132
+ parser.add_argument("log_file", nargs="?", default="/tmp/claude-otel.jsonl",
133
+ help="Path to OTEL JSONL log file")
134
+ parsed = parser.parse_args(args)
135
+ print_summary(parsed.log_file)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
agentic_ci/runner.py ADDED
@@ -0,0 +1,190 @@
1
+ """Run Claude Code in CI with streaming output and telemetry.
2
+
3
+ Starts an OTEL collector, runs Claude with stream-json output,
4
+ displays human-readable progress, and prints a token/cost summary.
5
+
6
+ When run as root, re-execs itself as a non-root user.
7
+ """
8
+
9
+ import os
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ import time
14
+
15
+ from agentic_ci.otel_collector import main as collector_main
16
+ from agentic_ci.otel_summary import print_summary
17
+ from agentic_ci.stream import StreamProcessor
18
+
19
+
20
+ def run(prompt, workdir=".", model=None, user="claude-ci"):
21
+ """Run Claude Code with telemetry and streaming output.
22
+
23
+ Returns the exit code (0 for success).
24
+ """
25
+ if os.getuid() == 0:
26
+ os.execvp("runuser", [
27
+ "runuser", "-u", user, "--",
28
+ sys.executable, "-m", "agentic_ci.runner",
29
+ prompt, workdir,
30
+ *(["--model", model] if model else []),
31
+ ])
32
+
33
+ if model is None:
34
+ model = os.environ.get("CLAUDE_MODEL", "claude-opus-4-6")
35
+
36
+ os.environ["PATH"] = os.path.expanduser("~/.local/bin") + ":" + os.environ.get("PATH", "")
37
+
38
+ print("--- Preflight checks ---", flush=True)
39
+ subprocess.run(["claude", "--version"], check=True)
40
+
41
+ os.chdir(workdir)
42
+
43
+ workspace = os.environ.get("WORKSPACE_DIR")
44
+ if workspace:
45
+ run_tmp = os.path.join(workspace, "_run")
46
+ else:
47
+ import tempfile
48
+ run_tmp = tempfile.mkdtemp(prefix="agentic-ci-run.")
49
+ os.makedirs(run_tmp, exist_ok=True)
50
+
51
+ otel_log = os.path.join(run_tmp, "claude-otel.jsonl")
52
+ otel_rate = os.path.join(run_tmp, "claude-otel-rate.json")
53
+ otel_port_file = os.path.join(run_tmp, "otel-port")
54
+ stderr_log = os.path.join(run_tmp, "claude-stderr.log")
55
+ stream_capture = os.environ.get(
56
+ "STREAM_CAPTURE_FILE",
57
+ os.path.join(run_tmp, "claude-stream-capture.jsonl"),
58
+ )
59
+
60
+ for f in [otel_log, otel_port_file]:
61
+ try:
62
+ os.unlink(f)
63
+ except FileNotFoundError:
64
+ pass
65
+
66
+ # Start OTEL collector with OS-assigned port
67
+ collector_env = {
68
+ **os.environ,
69
+ "OTEL_LOG_FILE": otel_log,
70
+ "OTEL_RATE_FILE": otel_rate,
71
+ "OTEL_COLLECTOR_PORT": "0",
72
+ "OTEL_PORT_FILE": otel_port_file,
73
+ }
74
+ collector_proc = subprocess.Popen(
75
+ [sys.executable, "-m", "agentic_ci.otel_collector"],
76
+ env=collector_env,
77
+ )
78
+
79
+ # Wait for collector to write its port
80
+ for _ in range(50):
81
+ if os.path.exists(otel_port_file):
82
+ break
83
+ time.sleep(0.1)
84
+ else:
85
+ print("Error: OTEL collector did not write port file", file=sys.stderr)
86
+ collector_proc.kill()
87
+ return 1
88
+
89
+ with open(otel_port_file) as f:
90
+ otel_port = f.read().strip()
91
+ print(f"--- OTEL collector started (pid {collector_proc.pid}, port {otel_port}) ---",
92
+ flush=True)
93
+
94
+ # Configure Claude to export OTEL data
95
+ os.environ["CLAUDE_CODE_ENABLE_TELEMETRY"] = "1"
96
+ os.environ["OTEL_METRICS_EXPORTER"] = "otlp"
97
+ os.environ["OTEL_LOGS_EXPORTER"] = "otlp"
98
+ os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/json"
99
+ os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = f"http://127.0.0.1:{otel_port}"
100
+ os.environ["OTEL_METRIC_EXPORT_INTERVAL"] = "10000"
101
+ os.environ["OTEL_RATE_FILE"] = otel_rate
102
+
103
+ # Run Claude
104
+ with open(stderr_log, "w") as stderr_f, open(stream_capture, "w") as capture_f:
105
+ claude_proc = subprocess.Popen(
106
+ [
107
+ "claude", "-p", prompt,
108
+ "--model", model,
109
+ "--dangerously-skip-permissions",
110
+ "--output-format", "stream-json",
111
+ "--include-partial-messages",
112
+ "--verbose",
113
+ ],
114
+ stdout=subprocess.PIPE,
115
+ stderr=stderr_f,
116
+ )
117
+
118
+ processor = StreamProcessor(claude_pid=claude_proc.pid)
119
+ stream_complete = False
120
+
121
+ for line in claude_proc.stdout:
122
+ text = line.decode("utf-8", errors="replace")
123
+ capture_f.write(text)
124
+ capture_f.flush()
125
+ if processor.process_line(text):
126
+ stream_complete = True
127
+ break
128
+
129
+ # Ensure Claude is terminated
130
+ try:
131
+ claude_proc.kill()
132
+ except OSError:
133
+ pass
134
+ claude_proc.wait()
135
+ rc = claude_proc.returncode
136
+
137
+ # SIGTERM produces 143 (128+15). Treat as success when stream detected completion.
138
+ if stream_complete and rc != 0:
139
+ print(f"--- stream processor detected run complete (claude rc={rc}), treating as success ---",
140
+ flush=True)
141
+ rc = 0
142
+
143
+ # Wait for Claude's final OTEL flush
144
+ time.sleep(7)
145
+
146
+ # Stop OTEL collector
147
+ collector_proc.terminate()
148
+ try:
149
+ collector_proc.wait(timeout=5)
150
+ except subprocess.TimeoutExpired:
151
+ collector_proc.kill()
152
+ collector_proc.wait()
153
+
154
+ print(f"--- Claude exit code: {rc} ---", flush=True)
155
+ print("--- stderr log ---", flush=True)
156
+ with open(stderr_log) as f:
157
+ sys.stderr.write(f.read())
158
+
159
+ print("\n--- OTEL Token/Cost Summary ---", flush=True)
160
+ print_summary(otel_log)
161
+
162
+ # Copy artifacts for CI upload
163
+ artifact_dir = os.environ.get("GITHUB_WORKSPACE") or os.environ.get("CI_PROJECT_DIR")
164
+ if artifact_dir:
165
+ import shutil
166
+ for src in [otel_log, stderr_log]:
167
+ try:
168
+ shutil.copy2(src, artifact_dir)
169
+ except (OSError, FileNotFoundError):
170
+ pass
171
+
172
+ return rc
173
+
174
+
175
+ def main(args=None):
176
+ import argparse
177
+
178
+ parser = argparse.ArgumentParser(description="Run Claude Code in CI with telemetry")
179
+ parser.add_argument("prompt", help="Prompt to send to Claude")
180
+ parser.add_argument("workdir", nargs="?", default=".",
181
+ help="Working directory (default: .)")
182
+ parser.add_argument("--model", default=None,
183
+ help="Claude model (default: $CLAUDE_MODEL or claude-opus-4-6)")
184
+ parsed = parser.parse_args(args)
185
+
186
+ sys.exit(run(parsed.prompt, parsed.workdir, model=parsed.model))
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()