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 +28 -0
- cacli/cli.py +195 -0
- cacli/providers/__init__.py +33 -0
- cacli/providers/base.py +52 -0
- cacli/providers/claude.py +129 -0
- cacli/providers/codex.py +111 -0
- cacli/providers/cursor.py +140 -0
- cacli/providers/gemini.py +111 -0
- cacli/runner.py +82 -0
- cacli/sessions.py +111 -0
- cacli/spawn.py +98 -0
- cacli/status.py +208 -0
- cacli/types.py +32 -0
- cacli-0.1.0.dist-info/METADATA +15 -0
- cacli-0.1.0.dist-info/RECORD +19 -0
- cacli-0.1.0.dist-info/WHEEL +5 -0
- cacli-0.1.0.dist-info/entry_points.txt +2 -0
- cacli-0.1.0.dist-info/licenses/LICENSE +21 -0
- cacli-0.1.0.dist-info/top_level.txt +1 -0
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())
|
cacli/providers/base.py
ADDED
|
@@ -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
|
cacli/providers/codex.py
ADDED
|
@@ -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"
|