callai-cli 0.9.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.
- callai/__init__.py +37 -0
- callai/__main__.py +8 -0
- callai/adapters/__init__.py +82 -0
- callai/adapters/base.py +190 -0
- callai/adapters/claude_code.py +194 -0
- callai/adapters/codex.py +155 -0
- callai/adapters/grok.py +207 -0
- callai/adapters/hermes.py +112 -0
- callai/adapters/opencode.py +186 -0
- callai/adapters/pi.py +179 -0
- callai/api.py +134 -0
- callai/cli.py +674 -0
- callai/config.py +273 -0
- callai/errors.py +65 -0
- callai/fanout.py +166 -0
- callai/py.typed +0 -0
- callai/request.py +70 -0
- callai/result.py +95 -0
- callai/runner.py +233 -0
- callai_cli-0.9.0.dist-info/METADATA +218 -0
- callai_cli-0.9.0.dist-info/RECORD +25 -0
- callai_cli-0.9.0.dist-info/WHEEL +5 -0
- callai_cli-0.9.0.dist-info/entry_points.txt +2 -0
- callai_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
- callai_cli-0.9.0.dist-info/top_level.txt +1 -0
callai/runner.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Runs a harness command line as a subprocess (DESIGN.md §4, §8).
|
|
2
|
+
|
|
3
|
+
The harness gets its own process group, so a timeout or Ctrl-C reaches the whole tree it
|
|
4
|
+
spawned, and an empty stdin, because the prompt always travels in argv.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
19
|
+
from typing import IO
|
|
20
|
+
|
|
21
|
+
from .adapters.base import strip_ansi
|
|
22
|
+
from .errors import HarnessNotFound, HarnessTimeout, Interrupted, UsageError
|
|
23
|
+
from .result import Completed
|
|
24
|
+
|
|
25
|
+
_POSIX = os.name == "posix"
|
|
26
|
+
_GRACE_SECONDS = 3
|
|
27
|
+
# Harnesses running right now, so a fan-out's Ctrl-C can stop the ones in other threads (stop_all).
|
|
28
|
+
_LIVE: set[subprocess.Popen] = set()
|
|
29
|
+
_LIVE_LOCK = threading.Lock()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_binary(binary: str, harness_name: str | None = None, install_url: str | None = None) -> str:
|
|
33
|
+
"""Find a harness executable on PATH.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
binary: Executable name, e.g. ``"claude"``.
|
|
37
|
+
harness_name: The ``--name`` (harness) that needs it (for the error message).
|
|
38
|
+
install_url: Install docs (for the error message).
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The absolute path of the executable.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
HarnessNotFound: If it is not on PATH.
|
|
45
|
+
"""
|
|
46
|
+
path = shutil.which(binary)
|
|
47
|
+
if path is None:
|
|
48
|
+
raise HarnessNotFound(binary, harness_name=harness_name, install_url=install_url)
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def harness_version(path: str, timeout: float = 10) -> str | None:
|
|
53
|
+
"""Ask a harness binary for its version (``BINARY --version``).
|
|
54
|
+
|
|
55
|
+
The version is the first non-empty line of stdout (else stderr), whatever the exit code:
|
|
56
|
+
hermes 0.19.0 prints its version and exits 1.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
path: The executable.
|
|
60
|
+
timeout: Seconds to wait for it.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The version line, or ``None`` if it printed nothing, failed to start or timed out.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
proc = subprocess.run(
|
|
67
|
+
[path, "--version"],
|
|
68
|
+
stdin=subprocess.DEVNULL,
|
|
69
|
+
capture_output=True,
|
|
70
|
+
timeout=timeout,
|
|
71
|
+
check=False,
|
|
72
|
+
)
|
|
73
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
74
|
+
return None
|
|
75
|
+
for raw in (proc.stdout, proc.stderr):
|
|
76
|
+
for line in raw.decode("utf-8", errors="replace").splitlines():
|
|
77
|
+
if line.strip():
|
|
78
|
+
return strip_ansi(line).strip()
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _pump(stream: IO[str], sink: list[str], tee: IO[str] | None, on_line: Callable[[str], None] | None = None) -> None:
|
|
83
|
+
"""Drain a pipe line by line into ``sink``, optionally copying each line to ``tee`` or ``on_line``.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
stream: The harness's stdout or stderr pipe.
|
|
87
|
+
sink: Collected lines.
|
|
88
|
+
tee: Where to echo lines live, or ``None``.
|
|
89
|
+
on_line: Called with each line as it arrives, or ``None``.
|
|
90
|
+
"""
|
|
91
|
+
for line in iter(stream.readline, ""):
|
|
92
|
+
sink.append(line)
|
|
93
|
+
if tee is not None:
|
|
94
|
+
tee.write(line)
|
|
95
|
+
tee.flush()
|
|
96
|
+
if on_line is not None:
|
|
97
|
+
on_line(line)
|
|
98
|
+
stream.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _signal_group(proc: subprocess.Popen, sig: int) -> None:
|
|
102
|
+
"""Send ``sig`` to the harness's whole process group; a group that already exited is fine.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
proc: The harness process (leader of its own group).
|
|
106
|
+
sig: Signal to send (POSIX); Windows always kills the process.
|
|
107
|
+
"""
|
|
108
|
+
if _POSIX:
|
|
109
|
+
with contextlib.suppress(ProcessLookupError):
|
|
110
|
+
os.killpg(proc.pid, sig)
|
|
111
|
+
else: # pragma: no cover - Windows
|
|
112
|
+
proc.kill()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _kill_group(proc: subprocess.Popen) -> None:
|
|
116
|
+
"""SIGTERM the harness's process group, then SIGKILL whatever is left after a grace period.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
proc: The harness process (leader of its own group).
|
|
120
|
+
"""
|
|
121
|
+
_signal_group(proc, signal.SIGTERM)
|
|
122
|
+
with contextlib.suppress(subprocess.TimeoutExpired): # harness ignored SIGTERM: SIGKILL below
|
|
123
|
+
proc.wait(timeout=_GRACE_SECONDS)
|
|
124
|
+
if _POSIX:
|
|
125
|
+
_signal_group(proc, signal.SIGKILL) # also reaps grandchildren that outlived the leader
|
|
126
|
+
proc.wait()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def stop_all() -> None:
|
|
130
|
+
"""Stop every harness still running (Ctrl-C during a fan-out): SIGINT, then kill after a grace period.
|
|
131
|
+
|
|
132
|
+
Each harness's own :func:`run` call then returns normally with the exit status it got.
|
|
133
|
+
"""
|
|
134
|
+
with _LIVE_LOCK:
|
|
135
|
+
procs = list(_LIVE)
|
|
136
|
+
for proc in procs:
|
|
137
|
+
_signal_group(proc, signal.SIGINT)
|
|
138
|
+
deadline = time.monotonic() + _GRACE_SECONDS
|
|
139
|
+
for proc in procs:
|
|
140
|
+
with contextlib.suppress(subprocess.TimeoutExpired): # ignored SIGINT: killed below
|
|
141
|
+
proc.wait(timeout=max(0.0, deadline - time.monotonic()))
|
|
142
|
+
for proc in procs:
|
|
143
|
+
if proc.poll() is None:
|
|
144
|
+
_kill_group(proc)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run(
|
|
148
|
+
argv: Sequence[str],
|
|
149
|
+
*,
|
|
150
|
+
cwd: str | None = None,
|
|
151
|
+
timeout: float | None = None,
|
|
152
|
+
verbose: bool = False,
|
|
153
|
+
harness_name: str | None = None,
|
|
154
|
+
install_url: str | None = None,
|
|
155
|
+
env: Mapping[str, str] | None = None,
|
|
156
|
+
on_stdout_line: Callable[[str], None] | None = None,
|
|
157
|
+
) -> Completed:
|
|
158
|
+
"""Run a harness and capture its output.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
argv: Command line; ``argv[0]`` is the bare binary name, resolved on PATH.
|
|
162
|
+
cwd: Working directory, or ``None`` for the current one.
|
|
163
|
+
timeout: Seconds before the process group is killed, or ``None`` for no limit.
|
|
164
|
+
verbose: Copy the harness's stderr to ours as it arrives (it is still captured).
|
|
165
|
+
harness_name: The ``--name`` (harness) being run (for error messages).
|
|
166
|
+
install_url: Install docs for the binary (for error messages).
|
|
167
|
+
env: Variables to set for the harness on top of callai's environment (an adapter's ``build_env``).
|
|
168
|
+
on_stdout_line: Called with each stdout line as it arrives (``-o stream-json``), from a reader
|
|
169
|
+
thread; stdout is still captured in full.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The captured outcome; ``argv`` is reported exactly as given.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
UsageError: If ``cwd`` is not a directory.
|
|
176
|
+
HarnessTimeout: If ``timeout`` elapsed (the process group has been killed).
|
|
177
|
+
Interrupted: On Ctrl-C (SIGINT was forwarded to the process group).
|
|
178
|
+
"""
|
|
179
|
+
argv = list(argv)
|
|
180
|
+
if cwd is not None and not os.path.isdir(cwd):
|
|
181
|
+
raise UsageError(f"--cwd {cwd!r} is not a directory")
|
|
182
|
+
executable = resolve_binary(argv[0], harness_name=harness_name, install_url=install_url)
|
|
183
|
+
group_kw = {"start_new_session": True} if _POSIX else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
184
|
+
|
|
185
|
+
start = time.monotonic()
|
|
186
|
+
proc = subprocess.Popen(
|
|
187
|
+
argv,
|
|
188
|
+
executable=executable, # keeps argv[0] as the bare name the harness expects
|
|
189
|
+
cwd=cwd,
|
|
190
|
+
env={**os.environ, **env} if env else None,
|
|
191
|
+
stdin=subprocess.DEVNULL,
|
|
192
|
+
stdout=subprocess.PIPE,
|
|
193
|
+
stderr=subprocess.PIPE,
|
|
194
|
+
**group_kw,
|
|
195
|
+
)
|
|
196
|
+
out: list[str] = []
|
|
197
|
+
err: list[str] = []
|
|
198
|
+
# Decode ourselves with newline="": text=True would translate \r\n to \n, and the output must be
|
|
199
|
+
# the harness's own (hermes marks display lines with \r\n; -o raw must be byte-faithful).
|
|
200
|
+
stdout, stderr = (
|
|
201
|
+
io.TextIOWrapper(s, encoding="utf-8", errors="replace", newline="") for s in (proc.stdout, proc.stderr)
|
|
202
|
+
)
|
|
203
|
+
pumps = [
|
|
204
|
+
threading.Thread(target=_pump, args=(stdout, out, None, on_stdout_line), daemon=True),
|
|
205
|
+
threading.Thread(target=_pump, args=(stderr, err, sys.stderr if verbose else None), daemon=True),
|
|
206
|
+
]
|
|
207
|
+
for t in pumps:
|
|
208
|
+
t.start()
|
|
209
|
+
with _LIVE_LOCK:
|
|
210
|
+
_LIVE.add(proc)
|
|
211
|
+
try:
|
|
212
|
+
proc.wait(timeout=timeout)
|
|
213
|
+
except subprocess.TimeoutExpired:
|
|
214
|
+
_kill_group(proc)
|
|
215
|
+
raise HarnessTimeout(argv[0], timeout) from None
|
|
216
|
+
except KeyboardInterrupt:
|
|
217
|
+
_signal_group(proc, signal.SIGINT)
|
|
218
|
+
with contextlib.suppress(subprocess.TimeoutExpired): # harness ignored SIGINT: killed below
|
|
219
|
+
proc.wait(timeout=_GRACE_SECONDS)
|
|
220
|
+
_kill_group(proc)
|
|
221
|
+
raise Interrupted() from None
|
|
222
|
+
finally:
|
|
223
|
+
with _LIVE_LOCK:
|
|
224
|
+
_LIVE.discard(proc)
|
|
225
|
+
for t in pumps:
|
|
226
|
+
t.join()
|
|
227
|
+
return Completed(
|
|
228
|
+
argv=argv,
|
|
229
|
+
exit_code=proc.returncode,
|
|
230
|
+
stdout="".join(out),
|
|
231
|
+
stderr="".join(err),
|
|
232
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
233
|
+
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: callai-cli
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: One CLI to run a prompt non-interactively through any coding-agent harness (claude-code, codex, opencode, pi, hermes, grok).
|
|
5
|
+
Author-email: Sugato Ray <sugatoray.dev@gmail.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/sugatoray/callai
|
|
8
|
+
Project-URL: Issues, https://github.com/sugatoray/callai/issues
|
|
9
|
+
Project-URL: Documentation, https://github.com/sugatoray/callai/blob/master/docs/callai/README.md
|
|
10
|
+
Project-URL: Source, https://github.com/sugatoray/callai
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Topic :: Software Development :: Testing
|
|
26
|
+
Classifier: Topic :: Utilities
|
|
27
|
+
Classifier: Topic :: Scientific/Engineering
|
|
28
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
29
|
+
Classifier: Typing :: Typed
|
|
30
|
+
Requires-Python: >=3.11
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
35
|
+
Requires-Dist: pytest-cov>=4.0; extra == "test"
|
|
36
|
+
Provides-Extra: dev
|
|
37
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
38
|
+
Requires-Dist: interrogate>=1.7.0; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
# callai
|
|
44
|
+
|
|
45
|
+
`callai` is one command line for every coding-agent harness. It runs a single prompt non-interactively, like `claude -p`, through the harness you pick and prints the result:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
callai -n {claude-code,codex,opencode,pi,hermes,grok} -p "PROMPT" [-m MODEL]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**User documentation:** [`docs/callai/`](docs/callai/README.md) has a tutorial, how-to guides, concepts and a full reference, with `llms.txt` for AI agents.
|
|
52
|
+
|
|
53
|
+
**Status:** Python implementation, version 0.9.0 (Red/Green TDD). All six harnesses work: `claude-code`, `codex`, `opencode`, `pi`, `grok` and `hermes`. The shared test suite is in [`conformance/`](conformance/README.md). PyPI distribution name: `callai-cli` (command `callai`). See [`SCOPE.md`](SCOPE.md) for scope, language/distribution decisions and the roadmap. See [`docs/DESIGN.md`](docs/DESIGN.md) for the CLI surface, adapter architecture, output schema and open decisions, and [`docs/.scrolls/PLAN.md`](docs/.scrolls/PLAN.md) for the build order.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
callai -n claude-code -p "Summarise README.md" # prints the answer
|
|
59
|
+
callai -n codex -p "Fix the failing test" --permission auto
|
|
60
|
+
git diff | callai -n grok -p "Review this diff" -o json # piped stdin is appended to the prompt
|
|
61
|
+
callai -n codex -p "Fix the test" -o stream-json # JSON-lines events (tool calls, text) as they happen
|
|
62
|
+
callai -n pi -p "hello" -m stub/stub-model --dry-run # show the harness command, don't run it
|
|
63
|
+
callai -n opencode -p "hello" -- --agent build # args after -- go to the harness unchanged
|
|
64
|
+
callai -n codex -p "hello" -m fast # a model alias from the config file
|
|
65
|
+
callai -n claude-code -p "and the tests?" --resume SESSION_ID # continue a session (-o json reports session_id)
|
|
66
|
+
callai -n pi -p "and now?" --continue # continue the harness's most recent session
|
|
67
|
+
callai -n codex/claude-code/grok -p "Explain main.py" # several harnesses at the same time ('/' = parallel)
|
|
68
|
+
callai -n codex,pi -p "Explain main.py" # one after another (',' = sequential)
|
|
69
|
+
callai -n all -p "Explain main.py" -o json # every installed harness, at the same time
|
|
70
|
+
callai --list-harnesses # which harnesses are installed, and their versions
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Harnesses: `claude-code`, `codex`, `opencode`, `pi`, `grok`, `hermes`. `-o text|json|stream-json|raw`
|
|
74
|
+
(json is one normalised object: `harness_name`, `text`, `is_error`, `session_id`, `usage`, `cost_usd`, `model`, ...;
|
|
75
|
+
stream-json is one event per line, `start`, then `text`/`tool_use`/`tool_result` as they happen, then `result`,
|
|
76
|
+
the json object; hermes has no event stream, so it gives only `start` and `result`; see DESIGN §5).
|
|
77
|
+
With several harnesses, each answer is printed under a `== name ==` header (`-o json`: `{"results": [...]}`;
|
|
78
|
+
stream-json events carry `harness_name`). One failure doesn't stop the others; the exit code is the first
|
|
79
|
+
failure's. `-n all` skips harnesses that can't honour the options, with a note. Parallel runs share the
|
|
80
|
+
working directory, so prefer `,` for tasks that edit files. See DESIGN §3 "Fan-out".
|
|
81
|
+
Exit codes: the harness's own; 1 if it reported an error but exited 0; 2 usage error; 124 timeout;
|
|
82
|
+
127 harness not installed; 130 interrupted.
|
|
83
|
+
|
|
84
|
+
Stdin: with `-p TEXT`, piped stdin is optional. If it's a pipe that stays open with nothing on it
|
|
85
|
+
(common when an agent or script runs callai), callai waits 3 seconds (`CALLAI_STDIN_WAIT`), warns and
|
|
86
|
+
runs with the prompt alone, like `claude -p`. Use `< /dev/null` to skip the wait. With `-p -` or no
|
|
87
|
+
`-p`, stdin is the prompt, so callai waits for it to close.
|
|
88
|
+
|
|
89
|
+
### Defaults: environment and config file
|
|
90
|
+
|
|
91
|
+
Each option comes from the first of: command-line flag, `CALLAI_*` environment variable, config
|
|
92
|
+
file, built-in default.
|
|
93
|
+
|
|
94
|
+
| setting | flag | environment | config file |
|
|
95
|
+
|---|---|---|---|
|
|
96
|
+
| harness | `-n/--name` | `CALLAI_HARNESS_NAME` | `default_harness` |
|
|
97
|
+
| model | `-m/--model` | `CALLAI_MODEL` | `[harnesses.<name>] model`, then top-level `model` |
|
|
98
|
+
| timeout | `--timeout` | `CALLAI_TIMEOUT` | `timeout` |
|
|
99
|
+
| stdin wait | | `CALLAI_STDIN_WAIT` | |
|
|
100
|
+
|
|
101
|
+
The config file is `~/.config/callai/config.toml` (or `$XDG_CONFIG_HOME/callai/config.toml`, or the
|
|
102
|
+
path in `CALLAI_CONFIG`). It is optional; unknown keys are an error (exit 2), so a typo never
|
|
103
|
+
silently does nothing.
|
|
104
|
+
|
|
105
|
+
```toml
|
|
106
|
+
default_harness = "claude-code"
|
|
107
|
+
model = "fast" # default model for every harness (here an alias)
|
|
108
|
+
timeout = 600
|
|
109
|
+
|
|
110
|
+
[harnesses.codex]
|
|
111
|
+
binary = "~/bin/codex" # run this instead of `codex` from PATH
|
|
112
|
+
model = "gpt-5-codex" # codex's default; beats the top-level model
|
|
113
|
+
extra_args = ["--skip-git-repo-check"] # passed before anything after --
|
|
114
|
+
|
|
115
|
+
[aliases] # -m fast / -m smart, resolved per harness
|
|
116
|
+
fast = { claude-code = "haiku", codex = "gpt-5-codex-mini", opencode = "anthropic/claude-haiku-4-5" }
|
|
117
|
+
smart = { claude-code = "opus", codex = "gpt-5-codex" }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Any model that isn't an alias goes to the harness unchanged. An alias with no entry for the chosen
|
|
121
|
+
harness is an error (`model alias 'smart' has no entry for pi`) rather than passing `smart` on.
|
|
122
|
+
|
|
123
|
+
`callai --list-harnesses` shows each harness's binary (including a configured one), where it is on
|
|
124
|
+
PATH and its `--version`, or where to install it; `-o json` gives the same as JSON.
|
|
125
|
+
|
|
126
|
+
`--permission default|read-only|auto|bypass` is translated per harness; a level a harness can't honour
|
|
127
|
+
is refused (exit 2) rather than run with more access. What each level really allows was checked with
|
|
128
|
+
scripted tool calls; see [DESIGN.md §7](docs/DESIGN.md#7-permissions-and-safety):
|
|
129
|
+
|
|
130
|
+
| level | claude-code | codex | opencode | pi | grok | hermes |
|
|
131
|
+
|---|---|---|---|---|---|---|
|
|
132
|
+
| `read-only` | plan mode: no writes, no shell | read-only sandbox | refused | read tools only | read tools only | refused |
|
|
133
|
+
| `auto` | edits inside the workspace | writes inside the workspace (sandboxed shell) | refused | file writes/edits, no shell | file writes/edits, no shell | file writes/edits, no shell |
|
|
134
|
+
| `bypass` | everything | everything | everything | everything | everything | everything |
|
|
135
|
+
|
|
136
|
+
With `default`, opencode, pi and hermes already write files and run shell commands without asking;
|
|
137
|
+
claude-code, codex and grok don't. `bypass` prints a warning on stderr. As root, claude refuses
|
|
138
|
+
`bypass` unless `IS_SANDBOX=1` is set (callai never sets it).
|
|
139
|
+
|
|
140
|
+
> [!CAUTION]
|
|
141
|
+
> **grok `auto` and `bypass` can write outside the working directory.** callai's `auto` for grok
|
|
142
|
+
> allows only file writes and edits (no shell), but grok will write them **anywhere** the user
|
|
143
|
+
> running it can write, not just in the working directory. Unlike codex's `auto`, which is sandboxed
|
|
144
|
+
> to the workspace. callai does not add a sandbox for you: grok's sandbox needs
|
|
145
|
+
> [bubblewrap](https://github.com/containers/bubblewrap) on Linux, and grok refuses to start without
|
|
146
|
+
> it, so adding it would break `auto` on machines that don't have it.
|
|
147
|
+
>
|
|
148
|
+
> To confine grok's writes to the working directory, opt in with grok's `workspace` sandbox:
|
|
149
|
+
>
|
|
150
|
+
> ```bash
|
|
151
|
+
> sudo apt install -y bubblewrap # Linux only, once
|
|
152
|
+
> callai -n grok -p "Fix the failing test" --permission auto -- --sandbox workspace
|
|
153
|
+
> callai -n grok -p "Refactor utils.py" --permission bypass -C ~/proj -- --sandbox workspace
|
|
154
|
+
> GROK_SANDBOX=workspace callai -n grok -p "Fix the failing test" --permission auto # same, via grok's env var
|
|
155
|
+
> ```
|
|
156
|
+
>
|
|
157
|
+
> Writes inside the working directory (`-C`, or the current directory) still work; writes elsewhere
|
|
158
|
+
> fail with "Permission denied", and so do shell commands that write outside it under `bypass`.
|
|
159
|
+
> Without bubblewrap, grok exits 1 with "bwrap exec failed ... Install bubblewrap" instead of
|
|
160
|
+
> running unconfined. grok's other profiles (`read-only`, `strict`) behave the same way for the
|
|
161
|
+
> working directory: they also allow writes inside it, so `-- --sandbox read-only` does **not** make
|
|
162
|
+
> grok read-only. Use callai's `--permission read-only` for that (no write or shell tools are offered).
|
|
163
|
+
|
|
164
|
+
A few prompts are refused because the harness would not send them as text: pi reads a prompt starting
|
|
165
|
+
with `@` as a file, and grok runs one starting with `/` as a slash command.
|
|
166
|
+
|
|
167
|
+
## From Python
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
import callai
|
|
171
|
+
|
|
172
|
+
result = callai.run("codex", "Fix the failing test", permission="auto", timeout=600)
|
|
173
|
+
print(result.text, result.is_error, result.session_id, result.usage)
|
|
174
|
+
|
|
175
|
+
# Continue that session, and watch tool calls as they happen
|
|
176
|
+
callai.run("codex", "Now add a test for it", resume=result.session_id, on_event=print)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`run()` takes the CLI's options as keyword arguments (`model`, `cwd`, `timeout`, `permission`,
|
|
180
|
+
`system_prompt`, `extra_args`, `resume`, `continue_session`, `binary`, `on_event`) and returns the same
|
|
181
|
+
result as `-o json`. It ignores `CALLAI_*` variables and the config file: pass what you want. A harness
|
|
182
|
+
that fails gives a result with `is_error=True`; `callai.CallaiError` subclasses (`UsageError`,
|
|
183
|
+
`HarnessNotFound`, `HarnessTimeout`, `Interrupted`) mean the run couldn't start or finish.
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
make install.harnesses # all harness CLIs at their verified versions (or make install.<tool>)
|
|
189
|
+
make docs.html # the docs as one HTML page with tabs and a theme toggle -> build/docs.html
|
|
190
|
+
uv sync --extra test --extra dev # Python >= 3.11, no runtime dependencies; develops on the pinned 3.11 (.python-version)
|
|
191
|
+
make test # tests + coverage gate (95%) on the pinned Python (same as: uv run pytest)
|
|
192
|
+
make test.matrix # the same on every supported Python, 3.11-3.14 (or PYTHON_VERSIONS=3.13)
|
|
193
|
+
make pkg.build # sdist + wheel -> dist/ (uv build)
|
|
194
|
+
make pkg.publish # build, then upload to PyPI (uv publish; set UV_PUBLISH_TOKEN)
|
|
195
|
+
make pkg.install.e # editable install of this checkout into .venv
|
|
196
|
+
make pkg.install.local # install callai-cli from the built dist/
|
|
197
|
+
make pkg.install.pypi # install callai-cli from PyPI (PKG_VERSION=0.9.0 to pin)
|
|
198
|
+
uv run ruff check . && uv run ruff format --check .
|
|
199
|
+
uv run interrogate src # docstring coverage (100%)
|
|
200
|
+
uv run callai --version
|
|
201
|
+
CALLAI_LIVE=claude-code uv run pytest -m live # live smoke test: real harness, real model (or CALLAI_LIVE=all)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Development follows Red/Green TDD. Shared behaviour cases live in [`conformance/`](conformance/README.md).
|
|
205
|
+
|
|
206
|
+
## Layout
|
|
207
|
+
|
|
208
|
+
- `docs/` — the user documentation in [`docs/callai/`](docs/callai/README.md), [`DESIGN.md`](docs/DESIGN.md), plus the project's own [`docs/.scrolls/`](docs/.scrolls/STARTER.md) working-memory system.
|
|
209
|
+
- `.trash/` — scratch/disposable files local to this repository (git-ignored).
|
|
210
|
+
- `conformance/` — language-agnostic test cases (argv, parse, CLI) shared by every implementation, plus `tools/`: a local stub model (with scripted tool calls) and the permission probe.
|
|
211
|
+
- `src/` — `src/callai/` (CLI, runner, one adapter per harness in `adapters/`) and `src/tests/`.
|
|
212
|
+
- [`CHANGELOG.md`](CHANGELOG.md) — notable changes to callai, [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) style with [SemVer](https://semver.org/) versions.
|
|
213
|
+
|
|
214
|
+
See [SCROLLS.md](SCROLLS.md) for how the memory system works.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
[Apache-2.0](LICENSE).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
callai/__init__.py,sha256=0LuTDPxYR3BgpX3BYvRs16UnojqGBRIUWgzRh-bG304,1028
|
|
2
|
+
callai/__main__.py,sha256=xfIr6ypBYmw-uuC00AVHQSTxAOOMAbubZJlypbJtzp0,158
|
|
3
|
+
callai/api.py,sha256=z8tMdrVy6Iv5a9mPhmLN6In9hn61zaV6m9pcBQpjDaM,5081
|
|
4
|
+
callai/cli.py,sha256=ZZJJhanGGlMQXIIlp_N2nApL23AuriZ_2uCYcre8O0M,25750
|
|
5
|
+
callai/config.py,sha256=duKGhbVaKArU_5go8BJXz61Ldu0b1ZXWiIwyidRpRzI,9153
|
|
6
|
+
callai/errors.py,sha256=QG-0e3bRQ6K4lJlz63d2jGVzZBPocjM4g72Xf7KQe3c,1932
|
|
7
|
+
callai/fanout.py,sha256=HHjp_tYxZO0AHF4u0kKsIhOznajDWQcijxkJIFSVHM0,5500
|
|
8
|
+
callai/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
callai/request.py,sha256=1vHwcze5i2K7bk3rqu1unDckNiEBz3djzppdnB_oy6Q,3328
|
|
10
|
+
callai/result.py,sha256=YHZoZI16fvMf4Piuiv3Dha5d75jOF48lN45xsV-li2I,3127
|
|
11
|
+
callai/runner.py,sha256=nfru9A7mOuPFOsSdvpxQbzr5QhDfzjo_hIdaoirNKJ8,8356
|
|
12
|
+
callai/adapters/__init__.py,sha256=vkjHzZE2RS9FyaGvAdw9Xj_AZkJDEtehYso2jSeSs88,2322
|
|
13
|
+
callai/adapters/base.py,sha256=BKUgVy3kFFCBVHD7ABtJjF7-veOt7fOWVL0-ykJjQyw,5964
|
|
14
|
+
callai/adapters/claude_code.py,sha256=77xfizhpCeDQADgRi6YHlHSo1dBINgUsKmUL4mEx7YY,6918
|
|
15
|
+
callai/adapters/codex.py,sha256=I_KCbWxpnQWoeTElJeNMx3gN03zRmoHGmLuVvTWVsM4,6658
|
|
16
|
+
callai/adapters/grok.py,sha256=F9jPw07WfxEm70jw8PCZ-PHthvVC0OFTnLorVlhD9eY,8646
|
|
17
|
+
callai/adapters/hermes.py,sha256=ZPHF3sYZwhlvqHYtjvRFFxAi05KwCdG8_Ze7O_8dTdk,4538
|
|
18
|
+
callai/adapters/opencode.py,sha256=crvAlT9yhbKbQNVihqx9e1jsex9kFuiI-Yp490ZULcY,7546
|
|
19
|
+
callai/adapters/pi.py,sha256=W8acocSBL_p8LEU22-9DqE4WL0xSZpquyL-lfUW2oZA,6993
|
|
20
|
+
callai_cli-0.9.0.dist-info/licenses/LICENSE,sha256=ErA_fMqfhQnPFERDycWnCvEzyXkKIamFv10AJbPWI9s,11340
|
|
21
|
+
callai_cli-0.9.0.dist-info/METADATA,sha256=r0uutgBEpx1EIPywO_fgqjPnoY5r8m7nYtzJv-j6Y2k,13280
|
|
22
|
+
callai_cli-0.9.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
23
|
+
callai_cli-0.9.0.dist-info/entry_points.txt,sha256=21U8Nb-AyWuGAViIdTFZMT601gx3dYspErVfSfYzoGk,43
|
|
24
|
+
callai_cli-0.9.0.dist-info/top_level.txt,sha256=JyuQQSsmwPtkHNaLf1FOKiNWo9TvuvzEDx4AWmODigk,7
|
|
25
|
+
callai_cli-0.9.0.dist-info/RECORD,,
|