cacli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cacli-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Taylor AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
cacli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: cacli
3
+ Version: 0.1.0
4
+ Summary: Provider-agnostic CLI for running coding agents
5
+ Author: Benjamin Anderson
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/taylorai/cacli
8
+ Project-URL: Repository, https://github.com/taylorai/cacli
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # cacli
15
+ coding agent CLI
cacli-0.1.0/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # cacli
2
+ coding agent CLI
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cacli"
7
+ version = "0.1.0"
8
+ description = "Provider-agnostic CLI for running coding agents"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Benjamin Anderson" }]
13
+
14
+ [project.scripts]
15
+ cacli = "cacli.cli:main"
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/taylorai/cacli"
19
+ Repository = "https://github.com/taylorai/cacli"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.ruff.lint]
25
+ select = ["E4", "E7", "E9", "F", "B"]
26
+ ignore = ["B008", "B904"]
cacli-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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"