cacli 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.
cacli/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ cacli — Coding Agent CLI.
3
+
4
+ Provider-agnostic interface for running headless coding agents.
5
+ """
6
+
7
+ from cacli.providers import get_provider, list_providers
8
+ from cacli.runner import (
9
+ build_command,
10
+ build_initial_log_entry,
11
+ parse_output,
12
+ run_agent,
13
+ )
14
+ from cacli.sessions import SessionInfo
15
+ from cacli.types import AgentRunResult, ExecResult, ShellExecFn
16
+
17
+ __all__ = [
18
+ "run_agent",
19
+ "build_command",
20
+ "parse_output",
21
+ "build_initial_log_entry",
22
+ "get_provider",
23
+ "list_providers",
24
+ "AgentRunResult",
25
+ "ExecResult",
26
+ "ShellExecFn",
27
+ "SessionInfo",
28
+ ]
cacli/cli.py ADDED
@@ -0,0 +1,195 @@
1
+ """CLI entrypoint for cacli."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+ from cacli.providers import list_providers
10
+ from cacli.runner import build_command, run_agent
11
+ from cacli.types import ExecResult
12
+
13
+
14
+ def subprocess_exec(
15
+ cmd: str, cwd: str, timeout: int, env: dict[str, str] | None
16
+ ) -> ExecResult:
17
+ """Default exec_fn using subprocess."""
18
+ full_env = os.environ.copy()
19
+ if env:
20
+ full_env.update(env)
21
+ try:
22
+ result = subprocess.run(
23
+ cmd,
24
+ shell=True,
25
+ capture_output=True,
26
+ text=True,
27
+ cwd=cwd,
28
+ timeout=timeout,
29
+ env=full_env,
30
+ )
31
+ return ExecResult(
32
+ exit_code=result.returncode,
33
+ stdout=result.stdout,
34
+ stderr=result.stderr,
35
+ )
36
+ except subprocess.TimeoutExpired:
37
+ return ExecResult(exit_code=124, stdout="", stderr="Command timed out")
38
+
39
+
40
+ def _normalize_argv(argv: list[str]) -> list[str]:
41
+ """Prepend 'run' if first arg isn't a known subcommand.
42
+
43
+ Preserves backward compat: ``cacli "my prompt"`` works as ``cacli run "my prompt"``.
44
+ """
45
+ if not argv:
46
+ return argv
47
+ first = argv[0]
48
+ known = {"run", "spawn", "status"}
49
+ if first in known or first in ("-h", "--help", "help") or first.startswith("-"):
50
+ return argv
51
+ return ["run", *argv]
52
+
53
+
54
+ def _add_common_args(parser: argparse.ArgumentParser) -> None:
55
+ """Add arguments shared between run and spawn."""
56
+ parser.add_argument("prompt", help="The task prompt")
57
+ parser.add_argument(
58
+ "--provider",
59
+ "-p",
60
+ default="claude",
61
+ choices=list_providers(),
62
+ help="Agent provider (default: claude)",
63
+ )
64
+ parser.add_argument("--model", "-m", default=None, help="Model name")
65
+ parser.add_argument("--reasoning-effort", "-e", default=None)
66
+ parser.add_argument(
67
+ "--settings",
68
+ "-s",
69
+ default=None,
70
+ help="Path to settings file (forwarded to claude --settings)",
71
+ )
72
+ parser.add_argument(
73
+ "--no-web-search",
74
+ dest="web_search",
75
+ action="store_false",
76
+ default=True,
77
+ )
78
+ parser.add_argument("--cwd", default=".", help="Working directory")
79
+ parser.add_argument(
80
+ "--timeout", "-t", type=int, default=3600, help="Timeout in seconds"
81
+ )
82
+
83
+
84
+ def _run_command(args) -> None:
85
+ """Handle 'cacli run' — synchronous agent execution."""
86
+ if args.command_only:
87
+ cmd = build_command(
88
+ args.provider,
89
+ args.prompt,
90
+ model=args.model,
91
+ reasoning_effort=args.reasoning_effort,
92
+ web_search=args.web_search,
93
+ settings=args.settings,
94
+ )
95
+ print(cmd)
96
+ return
97
+
98
+ result = run_agent(
99
+ provider=args.provider,
100
+ prompt=args.prompt,
101
+ exec_fn=subprocess_exec,
102
+ model=args.model,
103
+ reasoning_effort=args.reasoning_effort,
104
+ web_search=args.web_search,
105
+ settings=args.settings,
106
+ cwd=args.cwd,
107
+ timeout=args.timeout,
108
+ )
109
+
110
+ if args.raw:
111
+ print(result.raw_output)
112
+ elif args.json:
113
+ print(
114
+ json.dumps(
115
+ {
116
+ "exit_code": result.exit_code,
117
+ "result_message": result.result_message,
118
+ "total_cost": result.total_cost,
119
+ "provider": result.provider,
120
+ "model": result.model,
121
+ "command": result.command,
122
+ },
123
+ indent=2,
124
+ )
125
+ )
126
+ else:
127
+ if result.result_message:
128
+ print(result.result_message)
129
+ else:
130
+ print(result.raw_output)
131
+
132
+ sys.exit(result.exit_code)
133
+
134
+
135
+ def _spawn_command(args) -> None:
136
+ """Handle 'cacli spawn' — launch agent in tmux."""
137
+ from cacli.spawn import spawn_agent
138
+
139
+ spawn_agent(args)
140
+
141
+
142
+ def _status_command(_args) -> None:
143
+ """Handle 'cacli status' — TUI dashboard."""
144
+ from cacli.status import status_tui
145
+
146
+ status_tui()
147
+
148
+
149
+ def main() -> None:
150
+ parser = argparse.ArgumentParser(
151
+ prog="cacli",
152
+ description="Run coding agents (provider-agnostic)",
153
+ )
154
+ subparsers = parser.add_subparsers(dest="command")
155
+
156
+ # cacli run <prompt>
157
+ run_parser = subparsers.add_parser("run", help="Run agent synchronously")
158
+ _add_common_args(run_parser)
159
+ run_parser.add_argument(
160
+ "--raw",
161
+ action="store_true",
162
+ help="Print raw output instead of parsed result",
163
+ )
164
+ run_parser.add_argument(
165
+ "--command-only",
166
+ action="store_true",
167
+ help="Just print the command, don't execute",
168
+ )
169
+ run_parser.add_argument("--json", action="store_true", help="Output result as JSON")
170
+
171
+ # cacli spawn <prompt>
172
+ spawn_parser = subparsers.add_parser("spawn", help="Spawn agent in tmux session")
173
+ _add_common_args(spawn_parser)
174
+ spawn_parser.add_argument(
175
+ "--name", "-n", default=None, help="Human-readable session name"
176
+ )
177
+
178
+ # cacli status
179
+ subparsers.add_parser("status", help="Show spawned agent dashboard")
180
+
181
+ args = parser.parse_args(_normalize_argv(sys.argv[1:]))
182
+
183
+ if args.command is None:
184
+ parser.print_help()
185
+ sys.exit(1)
186
+ elif args.command == "run":
187
+ _run_command(args)
188
+ elif args.command == "spawn":
189
+ _spawn_command(args)
190
+ elif args.command == "status":
191
+ _status_command(args)
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()
@@ -0,0 +1,33 @@
1
+ """Provider registry."""
2
+
3
+ from cacli.providers.base import BaseProvider
4
+ from cacli.providers.claude import ClaudeProvider
5
+ from cacli.providers.codex import CodexProvider
6
+ from cacli.providers.cursor import CursorProvider
7
+ from cacli.providers.gemini import GeminiProvider
8
+
9
+ _PROVIDERS: dict[str, BaseProvider] = {}
10
+
11
+
12
+ def _init_registry() -> None:
13
+ _PROVIDERS["claude"] = ClaudeProvider()
14
+ _PROVIDERS["codex"] = CodexProvider()
15
+ _PROVIDERS["openai"] = CodexProvider()
16
+ _PROVIDERS["gemini"] = GeminiProvider()
17
+ _PROVIDERS["cursor"] = CursorProvider()
18
+
19
+
20
+ _init_registry()
21
+
22
+
23
+ def get_provider(name: str) -> BaseProvider:
24
+ """Get a provider instance by name."""
25
+ if name not in _PROVIDERS:
26
+ available = ", ".join(sorted(_PROVIDERS))
27
+ raise ValueError(f"Unknown provider: {name}. Available: {available}")
28
+ return _PROVIDERS[name]
29
+
30
+
31
+ def list_providers() -> list[str]:
32
+ """List all available provider names."""
33
+ return sorted(_PROVIDERS.keys())
@@ -0,0 +1,52 @@
1
+ """Abstract base class for coding agent providers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from cacli.types import AgentRunResult
6
+
7
+
8
+ class BaseProvider(ABC):
9
+ """Abstract base for all coding agent providers."""
10
+
11
+ name: str
12
+
13
+ @abstractmethod
14
+ def build_command(
15
+ self,
16
+ prompt: str,
17
+ model: str | None = None,
18
+ reasoning_effort: str | None = None,
19
+ web_search: bool = True,
20
+ settings: str | None = None,
21
+ ) -> str:
22
+ """Build the shell command string to invoke this provider."""
23
+ ...
24
+
25
+ @abstractmethod
26
+ def parse_output(self, raw_output: str) -> AgentRunResult:
27
+ """Parse raw output and extract result message, cost, etc."""
28
+ ...
29
+
30
+ @abstractmethod
31
+ def build_initial_log_entry(self, prompt: str, model: str | None = None) -> str:
32
+ """Build the initial JSONL log entry for this provider's format."""
33
+ ...
34
+
35
+ @abstractmethod
36
+ def build_pr_description_command(
37
+ self,
38
+ prompt: str,
39
+ model: str | None = None,
40
+ settings: str | None = None,
41
+ ) -> str:
42
+ """Build command for generating PR descriptions."""
43
+ ...
44
+
45
+ @abstractmethod
46
+ def extract_result_from_json(self, raw_output: str) -> str:
47
+ """Extract the result text from JSON output."""
48
+ ...
49
+
50
+ def resolve_model(self, model: str | None) -> str | None:
51
+ """Resolve model aliases. Override in providers with aliases."""
52
+ return model
@@ -0,0 +1,129 @@
1
+ """Claude Code provider."""
2
+
3
+ import json
4
+ import shlex
5
+
6
+ from cacli.providers.base import BaseProvider
7
+ from cacli.types import AgentRunResult
8
+
9
+
10
+ class ClaudeProvider(BaseProvider):
11
+ """Provider for the Claude Code CLI."""
12
+
13
+ name = "claude"
14
+
15
+ def build_command(
16
+ self,
17
+ prompt: str,
18
+ model: str | None = None,
19
+ reasoning_effort: str | None = None,
20
+ web_search: bool = True,
21
+ settings: str | None = None,
22
+ ) -> str:
23
+ safe_prompt = shlex.quote(prompt)
24
+ cmd = f"claude -p {safe_prompt} --verbose --output-format=stream-json"
25
+ if model:
26
+ cmd += f" --model {shlex.quote(model)}"
27
+ if settings:
28
+ cmd += f" --settings {shlex.quote(settings)}"
29
+ if not web_search:
30
+ cmd += " --disallowedTools WebFetch --disallowedTools WebSearch"
31
+ return cmd
32
+
33
+ def parse_output(self, raw_output: str) -> AgentRunResult:
34
+ result_message = ""
35
+ total_cost = None
36
+ permission_denials = []
37
+
38
+ for line in reversed(raw_output.strip().split("\n")):
39
+ if not line.strip():
40
+ continue
41
+ try:
42
+ entry = json.loads(line)
43
+ entries = entry if isinstance(entry, list) else [entry]
44
+ for item in reversed(entries):
45
+ if not isinstance(item, dict):
46
+ continue
47
+ if item.get("type") == "result":
48
+ result_message = item.get("result", "")
49
+ total_cost = item.get("total_cost_usd")
50
+ permission_denials = item.get("permission_denials", [])
51
+ break
52
+ if result_message:
53
+ break
54
+ except json.JSONDecodeError:
55
+ continue
56
+
57
+ return AgentRunResult(
58
+ result_message=result_message,
59
+ total_cost=total_cost,
60
+ permission_denials=permission_denials,
61
+ )
62
+
63
+ def build_initial_log_entry(self, prompt: str, model: str | None = None) -> str:
64
+ return json.dumps(
65
+ {
66
+ "type": "user",
67
+ "message": {
68
+ "role": "user",
69
+ "content": [{"type": "text", "text": prompt}],
70
+ },
71
+ }
72
+ )
73
+
74
+ def build_pr_description_command(
75
+ self,
76
+ prompt: str,
77
+ model: str | None = None,
78
+ settings: str | None = None,
79
+ ) -> str:
80
+ safe_prompt = shlex.quote(prompt)
81
+ cmd = f"claude -p {safe_prompt} --verbose --output-format json"
82
+ if model:
83
+ cmd += f" --model {shlex.quote(model)}"
84
+ if settings:
85
+ cmd += f" --settings {shlex.quote(settings)}"
86
+ return cmd
87
+
88
+ def extract_result_from_json(self, raw_output: str) -> str:
89
+ # First try single-object JSON with "result" key
90
+ try:
91
+ data = json.loads(raw_output)
92
+ if isinstance(data, dict) and "result" in data:
93
+ return data["result"]
94
+ if isinstance(data, list):
95
+ for item in reversed(data):
96
+ if isinstance(item, dict) and "result" in item:
97
+ return item["result"]
98
+ except json.JSONDecodeError:
99
+ pass
100
+
101
+ # Try JSONL: extract result from stream-json format
102
+ # (same logic as parse_output)
103
+ for line in reversed(raw_output.strip().split("\n")):
104
+ line = line.strip()
105
+ if not line:
106
+ continue
107
+ try:
108
+ entry = json.loads(line)
109
+ entries = entry if isinstance(entry, list) else [entry]
110
+ for item in reversed(entries):
111
+ if not isinstance(item, dict):
112
+ continue
113
+ if item.get("type") == "result":
114
+ return item.get("result", "")
115
+ except json.JSONDecodeError:
116
+ continue
117
+
118
+ # Fallback: look for any line with "result" key
119
+ for line in raw_output.split("\n"):
120
+ line = line.strip()
121
+ if line.startswith("{") and "result" in line:
122
+ try:
123
+ data = json.loads(line)
124
+ if isinstance(data, dict) and "result" in data:
125
+ return data["result"]
126
+ except json.JSONDecodeError:
127
+ continue
128
+
129
+ return raw_output
@@ -0,0 +1,111 @@
1
+ """Codex/OpenAI provider."""
2
+
3
+ import json
4
+ import shlex
5
+
6
+ from cacli.providers.base import BaseProvider
7
+ from cacli.types import AgentRunResult
8
+
9
+
10
+ class CodexProvider(BaseProvider):
11
+ """Provider for Codex/OpenAI CLI."""
12
+
13
+ name = "codex"
14
+
15
+ def build_command(
16
+ self,
17
+ prompt: str,
18
+ model: str | None = None,
19
+ reasoning_effort: str | None = None,
20
+ web_search: bool = True,
21
+ settings: str | None = None,
22
+ ) -> str:
23
+ safe_prompt = shlex.quote(prompt)
24
+ cmd = f"codex exec {safe_prompt} --sandbox danger-full-access --json --skip-git-repo-check"
25
+ if model:
26
+ cmd += f" --model {shlex.quote(model)}"
27
+ if reasoning_effort is not None:
28
+ cmd += f" --config model_reasoning_effort={reasoning_effort}"
29
+ if web_search:
30
+ cmd += " --config features.web_search_request=true"
31
+ return cmd
32
+
33
+ def parse_output(self, raw_output: str) -> AgentRunResult:
34
+ result_message = ""
35
+
36
+ for line in reversed(raw_output.strip().split("\n")):
37
+ if not line.strip():
38
+ continue
39
+ try:
40
+ entry = json.loads(line)
41
+ if entry.get("type") == "item.completed":
42
+ item = entry.get("item", {})
43
+ if item.get("type") == "agent_message":
44
+ result_message = item.get("text", "")
45
+ break
46
+ except json.JSONDecodeError:
47
+ continue
48
+
49
+ return AgentRunResult(result_message=result_message)
50
+
51
+ def build_initial_log_entry(self, prompt: str, model: str | None = None) -> str:
52
+ item = {
53
+ "id": "item_user",
54
+ "type": "user_message",
55
+ "text": prompt,
56
+ }
57
+ if model:
58
+ item["model"] = model
59
+ return json.dumps({"type": "item.completed", "item": item})
60
+
61
+ def build_pr_description_command(
62
+ self,
63
+ prompt: str,
64
+ model: str | None = None,
65
+ settings: str | None = None,
66
+ ) -> str:
67
+ safe_prompt = shlex.quote(prompt)
68
+ cmd = f"codex exec {safe_prompt} --sandbox danger-full-access --json --skip-git-repo-check"
69
+ if model:
70
+ cmd += f" --model {shlex.quote(model)}"
71
+ return cmd
72
+
73
+ def extract_result_from_json(self, raw_output: str) -> str:
74
+ # First try single-object JSON with "result" key
75
+ try:
76
+ data = json.loads(raw_output)
77
+ if isinstance(data, dict) and "result" in data:
78
+ return data["result"]
79
+ except json.JSONDecodeError:
80
+ pass
81
+
82
+ # Try JSONL: extract last agent_message from item.completed events
83
+ # (same logic as parse_output)
84
+ for line in reversed(raw_output.strip().split("\n")):
85
+ line = line.strip()
86
+ if not line:
87
+ continue
88
+ try:
89
+ entry = json.loads(line)
90
+ if entry.get("type") == "item.completed":
91
+ item = entry.get("item", {})
92
+ if item.get("type") == "agent_message":
93
+ return item.get("text", "")
94
+ except json.JSONDecodeError:
95
+ continue
96
+
97
+ # Fallback: look for any line with "result" key
98
+ for line in raw_output.split("\n"):
99
+ line = line.strip()
100
+ if line.startswith("{") and "result" in line:
101
+ try:
102
+ data = json.loads(line)
103
+ if "result" in data:
104
+ return data["result"]
105
+ except json.JSONDecodeError:
106
+ continue
107
+
108
+ return raw_output
109
+
110
+ def skills_dir(self) -> str:
111
+ return "/root/.codex/skills"
@@ -0,0 +1,140 @@
1
+ """Cursor provider."""
2
+
3
+ import json
4
+ import shlex
5
+
6
+ from cacli.providers.base import BaseProvider
7
+ from cacli.types import AgentRunResult
8
+
9
+ CURSOR_DEFAULT_MODEL = "composer-1"
10
+
11
+ CURSOR_ALLOWED_MODELS = {
12
+ "composer-1",
13
+ "gpt-5.2-codex",
14
+ "gpt-5.1-codex-max",
15
+ "gpt-5.1-codex-max-high",
16
+ "gpt-5.2",
17
+ "gpt-5.2-high",
18
+ "gpt-5.1-high",
19
+ "opus-4.5-thinking",
20
+ "opus-4.5",
21
+ "sonnet-4.5",
22
+ "sonnet-4.5-thinking",
23
+ "gemini-3-pro",
24
+ "gemini-3-flash",
25
+ "grok",
26
+ }
27
+
28
+
29
+ class CursorProvider(BaseProvider):
30
+ """Provider for Cursor Agent CLI."""
31
+
32
+ name = "cursor"
33
+
34
+ def resolve_model(self, model: str | None) -> str:
35
+ if not model:
36
+ return CURSOR_DEFAULT_MODEL
37
+ if model not in CURSOR_ALLOWED_MODELS:
38
+ allowed = ", ".join(sorted(CURSOR_ALLOWED_MODELS))
39
+ raise ValueError(f"Unsupported cursor model '{model}'. Allowed: {allowed}")
40
+ return model
41
+
42
+ def build_command(
43
+ self,
44
+ prompt: str,
45
+ model: str | None = None,
46
+ reasoning_effort: str | None = None,
47
+ web_search: bool = True,
48
+ settings: str | None = None,
49
+ ) -> str:
50
+ resolved_model = self.resolve_model(model)
51
+ safe_prompt = shlex.quote(prompt)
52
+ cmd = f"agent --print --output-format stream-json --force --model {shlex.quote(resolved_model)} {safe_prompt}"
53
+ return cmd
54
+
55
+ def parse_output(self, raw_output: str) -> AgentRunResult:
56
+ result_message = ""
57
+
58
+ for line in reversed(raw_output.strip().split("\n")):
59
+ if not line.strip():
60
+ continue
61
+ try:
62
+ entry = json.loads(line)
63
+ if entry.get("type") == "assistant":
64
+ message = entry.get("message", {})
65
+ content = message.get("content", [])
66
+ if isinstance(content, list) and content:
67
+ text = content[0].get("text")
68
+ if text:
69
+ result_message = text
70
+ break
71
+ except json.JSONDecodeError:
72
+ continue
73
+
74
+ return AgentRunResult(result_message=result_message)
75
+
76
+ def build_initial_log_entry(self, prompt: str, model: str | None = None) -> str:
77
+ # Cursor uses the same format as Claude for initial log entries
78
+ return json.dumps(
79
+ {
80
+ "type": "user",
81
+ "message": {
82
+ "role": "user",
83
+ "content": [{"type": "text", "text": prompt}],
84
+ },
85
+ }
86
+ )
87
+
88
+ def build_pr_description_command(
89
+ self,
90
+ prompt: str,
91
+ model: str | None = None,
92
+ settings: str | None = None,
93
+ ) -> str:
94
+ resolved_model = self.resolve_model(model)
95
+ safe_prompt = shlex.quote(prompt)
96
+ cmd = f"agent --print --output-format json --force --model {shlex.quote(resolved_model)} {safe_prompt}"
97
+ return cmd
98
+
99
+ def extract_result_from_json(self, raw_output: str) -> str:
100
+ # First try single-object JSON with "result" key
101
+ try:
102
+ data = json.loads(raw_output)
103
+ if isinstance(data, dict) and "result" in data:
104
+ return data["result"]
105
+ except json.JSONDecodeError:
106
+ pass
107
+
108
+ # Try JSONL: extract last assistant message from stream-json format
109
+ # (same logic as parse_output)
110
+ for line in reversed(raw_output.strip().split("\n")):
111
+ line = line.strip()
112
+ if not line:
113
+ continue
114
+ try:
115
+ entry = json.loads(line)
116
+ if entry.get("type") == "assistant":
117
+ message = entry.get("message", {})
118
+ content = message.get("content", [])
119
+ if isinstance(content, list) and content:
120
+ text = content[0].get("text")
121
+ if text:
122
+ return text
123
+ except json.JSONDecodeError:
124
+ continue
125
+
126
+ # Fallback: look for any line with "result" key
127
+ for line in raw_output.split("\n"):
128
+ line = line.strip()
129
+ if line.startswith("{") and "result" in line:
130
+ try:
131
+ data = json.loads(line)
132
+ if "result" in data:
133
+ return data["result"]
134
+ except json.JSONDecodeError:
135
+ continue
136
+
137
+ return raw_output
138
+
139
+ def skills_dir(self) -> str:
140
+ return "/root/.cursor/skills"