taskproof 0.3.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.
Files changed (51) hide show
  1. taskproof/__init__.py +23 -0
  2. taskproof/__main__.py +8 -0
  3. taskproof/adapters/__init__.py +101 -0
  4. taskproof/adapters/base.py +228 -0
  5. taskproof/adapters/claude.py +46 -0
  6. taskproof/adapters/codex.py +140 -0
  7. taskproof/adapters/custom.py +61 -0
  8. taskproof/adapters/gemini.py +45 -0
  9. taskproof/adapters/opencode.py +46 -0
  10. taskproof/api/__init__.py +1 -0
  11. taskproof/api/server.py +822 -0
  12. taskproof/cli.py +1400 -0
  13. taskproof/concurrency.py +329 -0
  14. taskproof/dispatch.py +2289 -0
  15. taskproof/errors.py +55 -0
  16. taskproof/ledger.py +185 -0
  17. taskproof/models.py +155 -0
  18. taskproof/registry.py +1585 -0
  19. taskproof/schemas/result.json +58 -0
  20. taskproof/sql/schema.sql +70 -0
  21. taskproof/storage.py +372 -0
  22. taskproof/verify.py +615 -0
  23. taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
  24. taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
  25. taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
  26. taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
  27. taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
  28. taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
  29. taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
  30. taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
  31. taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
  32. taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
  33. taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
  34. taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
  35. taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
  36. taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
  37. taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
  38. taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
  39. taskproof/web/assets/TasksView-Bn08731V.css +30 -0
  40. taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
  41. taskproof/web/assets/index-B-PLdDbR.js +75421 -0
  42. taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
  43. taskproof/web/assets/index-NKj7kbYl.css +1234 -0
  44. taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
  45. taskproof/web/index.html +23 -0
  46. taskproof-0.3.0.dist-info/METADATA +470 -0
  47. taskproof-0.3.0.dist-info/RECORD +51 -0
  48. taskproof-0.3.0.dist-info/WHEEL +5 -0
  49. taskproof-0.3.0.dist-info/entry_points.txt +2 -0
  50. taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
  51. taskproof-0.3.0.dist-info/top_level.txt +1 -0
taskproof/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """taskproof — a conveyor belt for AI coding agents.
2
+
3
+ It does not decide what to do. It guarantees that what was dispatched gets
4
+ independently verified and recorded.
5
+ """
6
+
7
+ import sys
8
+
9
+ # The single source of the version: `pyproject.toml` reads it from here
10
+ # (`[tool.setuptools.dynamic]`), so cutting a release bumps exactly one string.
11
+ __version__ = "0.3.0"
12
+
13
+ # Fail early and in plain language. Without this, running from a source checkout
14
+ # under an older interpreter surfaces as `ModuleNotFoundError: No module named
15
+ # 'tomllib'` from somewhere deep in the registry loader — which says nothing
16
+ # about the real problem (the interpreter is too old).
17
+ if sys.version_info < (3, 11):
18
+ raise RuntimeError(
19
+ "taskproof requires Python 3.11 or newer: the project registry is parsed "
20
+ f"with the standard library's tomllib. This interpreter is {sys.version.split()[0]}. "
21
+ "Use a newer Python, or install the packaged version with "
22
+ "`pipx install taskproof` (which will refuse incompatible interpreters for you)."
23
+ )
taskproof/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """`python -m taskproof` — same entry point as the `taskproof` script."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,101 @@
1
+ """Adapter registry.
2
+
3
+ `get(name)` resolves a built-in adapter or a `custom:<cmd>` fallback.
4
+
5
+ The resolution order matters: a registry entry saying `custom:my-agent --flag`
6
+ always wins over any built-in of the same prefix, and an unknown bare name is a
7
+ usage error rather than a silent fallback — silently running the wrong agent is
8
+ exactly the class of failure this project exists to prevent.
9
+ """
10
+
11
+ import importlib
12
+ import shutil
13
+ from typing import Dict, List, Type
14
+
15
+ from ..errors import UsageError
16
+ from .base import Adapter
17
+ from .custom import CustomAdapter
18
+
19
+ _BUILTINS: Dict[str, str] = {
20
+ # name -> module attribute path, resolved lazily in get()
21
+ "codex": "codex",
22
+ "claude": "claude",
23
+ "gemini": "gemini",
24
+ "opencode": "opencode",
25
+ }
26
+
27
+ #: Every adapter name this build knows about (used by doctor / --help).
28
+ KNOWN = tuple(sorted(_BUILTINS)) + ("custom:<cmd>",)
29
+
30
+
31
+ def load_class(module_name: str) -> Type[Adapter]:
32
+ """Import `taskproof.adapters.<module_name>` and return its Adapter class.
33
+
34
+ Kept as a function so `get()` can resolve built-ins lazily: importing a
35
+ module never executes the CLI it wraps.
36
+ """
37
+ try:
38
+ module = importlib.import_module(f".{module_name}", __package__)
39
+ except ImportError as exc:
40
+ raise UsageError(
41
+ f"adapter module not available: {module_name} ({exc})",
42
+ hint=f"known adapters: {', '.join(KNOWN)}",
43
+ )
44
+ candidates = [
45
+ obj
46
+ for obj in vars(module).values()
47
+ if isinstance(obj, type)
48
+ and issubclass(obj, Adapter)
49
+ and obj is not Adapter
50
+ and obj.__module__ == module.__name__
51
+ ]
52
+ if not candidates:
53
+ raise UsageError(f"adapter module '{module_name}' defines no Adapter subclass")
54
+ # Deterministic: prefer the conventional `<Name>Adapter` class.
55
+ candidates.sort(key=lambda cls: (not cls.__name__.endswith("Adapter"), cls.__name__))
56
+ return candidates[0]
57
+
58
+
59
+ def available() -> List[str]:
60
+ """Names whose CLI is actually present on PATH (does not execute them)."""
61
+ found: List[str] = []
62
+ for name, module_name in _BUILTINS.items():
63
+ try:
64
+ adapter_cls = load_class(module_name)
65
+ except UsageError:
66
+ continue
67
+ binary = getattr(adapter_cls, "binary", "") or name
68
+ if shutil.which(binary):
69
+ found.append(name)
70
+ return found
71
+
72
+
73
+ def get(name, **kwargs) -> Adapter:
74
+ """Instantiate an adapter by name.
75
+
76
+ `name` may be:
77
+ * a built-in name (`codex`, `claude`, ...)
78
+ * `custom:<shell command>` — the command is templated, not executed here
79
+
80
+ Unknown bare names raise UsageError (exit 64).
81
+ """
82
+ if not isinstance(name, str) or not name.strip():
83
+ raise UsageError("adapter name is required")
84
+ name = name.strip()
85
+
86
+ if name.startswith("custom:"):
87
+ command = name[len("custom:"):].strip()
88
+ if not command:
89
+ raise UsageError(
90
+ "custom adapter needs a command",
91
+ hint="usage: custom:<command template with {prompt} / {workdir}>",
92
+ )
93
+ return CustomAdapter(command, **kwargs)
94
+
95
+ if name in _BUILTINS:
96
+ return load_class(_BUILTINS[name])(**kwargs)
97
+
98
+ raise UsageError(
99
+ f"unknown adapter: {name}",
100
+ hint=f"known adapters: {', '.join(KNOWN)}",
101
+ )
@@ -0,0 +1,228 @@
1
+ """Adapter contract.
2
+
3
+ An adapter is one command template plus one result-parsing convention. It knows
4
+ how to invoke a coding agent CLI and how to salvage a structured outcome from
5
+ its output. It knows nothing about concurrency, verification or storage.
6
+
7
+ The contract exists because agent CLIs lie, crash, and wrap their output in
8
+ markdown. Documented incidents that shaped it:
9
+
10
+ * result JSON wrapped in a ```json fence -> structured parse failed and the
11
+ whole run looked like it had done nothing
12
+ * "I verified this in the browser, screenshot attached" with zero browser
13
+ activity in the event stream -> hence: the adapter's summary is reported,
14
+ never trusted
15
+ * an out-of-scope HTTP login attempt -> hence: `forbidden_paths` are checked
16
+ after the run, and adapters are invoked in the narrowest sandbox they offer
17
+ """
18
+
19
+ import json
20
+ import re
21
+ from abc import ABC, abstractmethod
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from ..models import AdapterResult
25
+
26
+
27
+ class ResultParseError(Exception):
28
+ """The adapter ran but its result could not be understood."""
29
+
30
+
31
+ class Adapter(ABC):
32
+ """Base class for agent CLI adapters."""
33
+
34
+ #: Name used in the registry and on the command line.
35
+ name: str = ""
36
+
37
+ #: Executable looked up on PATH by `adapters.available()` / `preflight()`.
38
+ binary: str = ""
39
+
40
+ #: True when the CLI can be constrained to a machine-readable result.
41
+ supports_schema = False
42
+
43
+ #: True when the CLI offers a sandbox / approval mode worth using.
44
+ supports_sandbox = False
45
+
46
+ def __init__(self, *, model: Optional[str] = None, reasoning: Optional[str] = None,
47
+ timeout: int = 1800, env: Optional[Dict[str, str]] = None):
48
+ self.model = model
49
+ self.reasoning = reasoning
50
+ self.timeout = timeout
51
+ self.env = env or {}
52
+
53
+ # -- required ---------------------------------------------------------
54
+
55
+ @abstractmethod
56
+ def build_command(self, *, brief: str, workdir: str,
57
+ schema_path: Optional[str] = None,
58
+ read_only: bool = False) -> list:
59
+ """Return argv for one invocation.
60
+
61
+ Must not include a shell: pass a list, never `shell=True`.
62
+ """
63
+
64
+ @abstractmethod
65
+ def parse_result(self, *, exit_code: int, stdout: str, stderr: str,
66
+ result_path: Optional[str]) -> AdapterResult:
67
+ """Normalise the CLI's outcome.
68
+
69
+ Implementations MUST tolerate:
70
+ * a markdown code fence around the JSON payload
71
+ * a missing or empty result file
72
+ * trailing noise on stdout after the payload
73
+
74
+ Degrading to a summary parsed from stdout is allowed and must set
75
+ `parse_degraded=True`. Raising ResultParseError is allowed, and must be
76
+ surfaced as an adapter failure (exit 70) — never silently treated as
77
+ success.
78
+ """
79
+
80
+ # -- optional ---------------------------------------------------------
81
+
82
+ def preflight(self) -> Optional[str]:
83
+ """Return a human-readable problem if this CLI is unusable, else None.
84
+
85
+ Used by `taskproof doctor`. Must not execute the agent.
86
+ """
87
+ return None
88
+
89
+ #: Extra context appended to the brief, per CLI.
90
+ prompt_preamble: str = ""
91
+
92
+ def full_prompt(self, brief: str) -> str:
93
+ return f"{self.prompt_preamble}{brief}" if self.prompt_preamble else brief
94
+
95
+ def degraded_result(self, *, exit_code: int, stdout: str, stderr: str,
96
+ result_path: Optional[str]) -> AdapterResult:
97
+ """Stdout-only fallback shared by adapters without structured output.
98
+
99
+ Salvages a JSON object from stdout when possible, otherwise keeps the
100
+ tail of the text as the summary. ALWAYS sets `parse_degraded=True`
101
+ because nothing here is schema-constrained. Raises `ResultParseError`
102
+ when there is nothing at all to salvage (the caller must treat that as
103
+ an adapter failure, exit 70 — never a silent success).
104
+ """
105
+ if not stdout or not stdout.strip():
106
+ raise ResultParseError(
107
+ f"{self.name}: produced no output to parse"
108
+ )
109
+ try:
110
+ decoded = extract_json(stdout)
111
+ except ResultParseError:
112
+ decoded = None
113
+ payload = decoded if isinstance(decoded, dict) else None
114
+ return AdapterResult(
115
+ exit_code=exit_code,
116
+ summary=_summarize(decoded, stdout),
117
+ payload=payload,
118
+ raw_stdout=stdout,
119
+ raw_stderr=stderr,
120
+ parse_degraded=True,
121
+ result_path=result_path,
122
+ )
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # JSON recovery
127
+ # ---------------------------------------------------------------------------
128
+
129
+ _FENCE_RE = re.compile(r"```[ \t]*([A-Za-z0-9_+-]*)[ \t]*\r?\n?(.*?)```", re.DOTALL)
130
+
131
+
132
+ def extract_json(text: str) -> Any:
133
+ """Best-effort JSON recovery used by adapters.
134
+
135
+ Order of attempts:
136
+ 1. the whole string parses
137
+ 2. a fenced block (```json ... ``` or ``` ... ```) parses
138
+ 3. the first balanced `{...}` object parses
139
+
140
+ Returns the decoded object, or raises ResultParseError.
141
+ """
142
+ if text is None:
143
+ raise ResultParseError("no result to parse")
144
+ stripped = text.strip()
145
+ if not stripped:
146
+ raise ResultParseError("empty result")
147
+
148
+ # 1. the whole string is JSON
149
+ try:
150
+ return json.loads(stripped)
151
+ except (json.JSONDecodeError, ValueError):
152
+ pass
153
+
154
+ # 2. a fenced block
155
+ for match in _FENCE_RE.finditer(stripped):
156
+ block = match.group(2).strip()
157
+ if not block:
158
+ continue
159
+ try:
160
+ return json.loads(block)
161
+ except (json.JSONDecodeError, ValueError):
162
+ pass
163
+ balanced = _first_balanced_object(block)
164
+ if balanced is not _MISS:
165
+ return balanced
166
+
167
+ # 3. the first balanced {...} object anywhere in the string
168
+ balanced = _first_balanced_object(stripped)
169
+ if balanced is not _MISS:
170
+ return balanced
171
+
172
+ raise ResultParseError("could not extract JSON from result")
173
+
174
+
175
+ class _Missing:
176
+ """Sentinel so `None` / `0` / `False` are still valid decoded values."""
177
+
178
+
179
+ _MISS = _Missing()
180
+
181
+
182
+ def _first_balanced_object(text: str):
183
+ """Decode the first brace-balanced `{...}` object, or return `_MISS`.
184
+
185
+ String literals and escapes are respected so a `}` inside a JSON string
186
+ does not close the object early.
187
+ """
188
+ start = text.find("{")
189
+ while start != -1:
190
+ depth = 0
191
+ in_string = False
192
+ escaped = False
193
+ for index in range(start, len(text)):
194
+ char = text[index]
195
+ if in_string:
196
+ if escaped:
197
+ escaped = False
198
+ elif char == "\\":
199
+ escaped = True
200
+ elif char == '"':
201
+ in_string = False
202
+ else:
203
+ if char == '"':
204
+ in_string = True
205
+ elif char == "{":
206
+ depth += 1
207
+ elif char == "}":
208
+ depth -= 1
209
+ if depth == 0:
210
+ candidate = text[start:index + 1]
211
+ try:
212
+ return json.loads(candidate)
213
+ except (json.JSONDecodeError, ValueError):
214
+ break # start over from the next '{'
215
+ start = text.find("{", start + 1)
216
+ return _MISS
217
+
218
+
219
+ def _summarize(payload: Any, text: str, tail: int = 40) -> str:
220
+ """Human-readable one-liner: prefer a summary field, else tail the text."""
221
+ if isinstance(payload, dict):
222
+ for key in ("summary", "message", "result", "text"):
223
+ value = payload.get(key)
224
+ if isinstance(value, str) and value.strip():
225
+ return value.strip()
226
+ return json.dumps(payload, ensure_ascii=False)
227
+ lines: List[str] = [line for line in text.splitlines() if line.strip()]
228
+ return "\n".join(lines[-tail:])
@@ -0,0 +1,46 @@
1
+ """Claude Code CLI adapter — NOT tested on this machine (claude not installed).
2
+
3
+ Command shape (print mode, JSON output):
4
+
5
+ claude -p "<prompt>" [--model <m>] [--permission-mode plan|acceptEdits]
6
+
7
+ `read_only=True` maps to `--permission-mode plan`, the non-mutating mode.
8
+ Result parsing degrades to stdout: `parse_degraded=True` always. The Claude
9
+ `--output-format json` envelope is not a JSON Schema, so `supports_schema`
10
+ stays False.
11
+ """
12
+
13
+ import shutil
14
+ from typing import Optional
15
+
16
+ from ..models import AdapterResult
17
+ from .base import Adapter
18
+
19
+
20
+ class ClaudeAdapter(Adapter):
21
+ name = "claude"
22
+ binary = "claude"
23
+ supports_schema = False
24
+ supports_sandbox = True
25
+
26
+ def build_command(self, *, brief: str, workdir: str,
27
+ schema_path: Optional[str] = None,
28
+ read_only: bool = False) -> list:
29
+ argv = ["claude", "-p", self.full_prompt(brief)]
30
+ if self.model:
31
+ argv += ["--model", self.model]
32
+ # read-only is the "plan" permission mode (no edits).
33
+ argv += ["--permission-mode", "plan" if read_only else "acceptEdits"]
34
+ return argv
35
+
36
+ def parse_result(self, *, exit_code: int, stdout: str, stderr: str,
37
+ result_path: Optional[str]) -> AdapterResult:
38
+ return self.degraded_result(
39
+ exit_code=exit_code, stdout=stdout, stderr=stderr,
40
+ result_path=result_path,
41
+ )
42
+
43
+ def preflight(self) -> Optional[str]:
44
+ if shutil.which(self.binary) is None:
45
+ return "claude CLI not found on PATH (adapter not tested on this machine)"
46
+ return None
@@ -0,0 +1,140 @@
1
+ """Codex CLI adapter — the one built-in tested on this machine.
2
+
3
+ Verified against `codex-cli 0.160.0`. The relevant lines from `codex exec
4
+ --help` are:
5
+
6
+ Usage: codex exec [OPTIONS] [PROMPT]
7
+ -m, --model <MODEL>
8
+ -c, --config <key=value>
9
+ -s, --sandbox <SANDBOX_MODE>
10
+ [possible values: read-only, workspace-write, danger-full-access]
11
+ -C, --cd <DIR>
12
+ --output-schema <FILE> Path to a JSON Schema file describing the model's
13
+ final response shape
14
+ -o, --output-last-message <FILE>
15
+ Specifies file where the last message from the
16
+ agent should be written
17
+
18
+ Command shape (from the real dispatch scripts):
19
+
20
+ codex exec -C <workdir> --sandbox read-only|workspace-write \\
21
+ --output-schema <schema.json> -o <result.json> \\
22
+ [--model <m>] [-c model_reasoning_effort=<r>] "<prompt>"
23
+
24
+ The result file named by `-o` is read first; if it is missing (or holds no
25
+ JSON) the adapter degrades to scraping stdout and sets `parse_degraded=True`.
26
+ """
27
+
28
+ import os
29
+ import shutil
30
+ import tempfile
31
+ from typing import List, Optional
32
+
33
+ from ..models import AdapterResult
34
+ from .base import Adapter, ResultParseError, _summarize, extract_json
35
+
36
+
37
+ class CodexAdapter(Adapter):
38
+ name = "codex"
39
+ binary = "codex"
40
+ supports_schema = True
41
+ supports_sandbox = True
42
+
43
+ def __init__(self, *, model: Optional[str] = None, reasoning: Optional[str] = None,
44
+ timeout: int = 1800, env: Optional[dict] = None,
45
+ result_path: Optional[str] = None):
46
+ super().__init__(model=model, reasoning=reasoning, timeout=timeout, env=env)
47
+ #: Where `codex -o` writes the last message. Callers may set it; when
48
+ #: left None `build_command` allocates a per-run temp file.
49
+ self.result_path = result_path
50
+ self._seq = 0
51
+
52
+ def _allocate_result_path(self) -> str:
53
+ self._seq += 1
54
+ return os.path.join(
55
+ tempfile.gettempdir(),
56
+ f"taskproof-codex-{os.getpid()}-{self._seq}.json",
57
+ )
58
+
59
+ def build_command(self, *, brief: str, workdir: str,
60
+ schema_path: Optional[str] = None,
61
+ read_only: bool = False) -> list:
62
+ if self.result_path is None:
63
+ self.result_path = self._allocate_result_path()
64
+
65
+ # argv list, never a shell string: the prompt is passed as one element
66
+ # and can never inject a `;`/`&&`/backtick into a shell.
67
+ argv: List[str] = ["codex", "exec"]
68
+ argv += ["-C", workdir]
69
+ # read-only vs workspace-write is the whole point of the sandbox flag.
70
+ argv += ["--sandbox", "read-only" if read_only else "workspace-write"]
71
+ if schema_path:
72
+ argv += ["--output-schema", schema_path]
73
+ argv += ["-o", self.result_path]
74
+ if self.model:
75
+ argv += ["--model", self.model]
76
+ if self.reasoning:
77
+ argv += ["-c", f"model_reasoning_effort={self.reasoning}"]
78
+ argv += [self.full_prompt(brief)]
79
+ return argv
80
+
81
+ def parse_result(self, *, exit_code: int, stdout: str, stderr: str,
82
+ result_path: Optional[str]) -> AdapterResult:
83
+ path = result_path or self.result_path
84
+
85
+ payload = None
86
+ raw = ""
87
+ if path and os.path.isfile(path):
88
+ try:
89
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
90
+ raw = handle.read()
91
+ except OSError:
92
+ raw = ""
93
+ if raw.strip():
94
+ try:
95
+ payload = extract_json(raw)
96
+ except ResultParseError:
97
+ payload = None
98
+
99
+ if payload is not None:
100
+ # A structured result came back from the `-o` file. A fenced file is
101
+ # still the structured result (extract_json unwraps it), so this is
102
+ # NOT flagged as degraded.
103
+ return AdapterResult(
104
+ exit_code=exit_code,
105
+ summary=_summarize(payload, raw),
106
+ payload=payload if isinstance(payload, dict) else None,
107
+ raw_stdout=stdout,
108
+ raw_stderr=stderr,
109
+ parse_degraded=False,
110
+ result_path=path,
111
+ )
112
+
113
+ # Result file missing/empty/unparseable -> salvage stdout if we can.
114
+ if stdout and stdout.strip():
115
+ decoded = None
116
+ try:
117
+ decoded = extract_json(stdout)
118
+ except ResultParseError:
119
+ decoded = None
120
+ return AdapterResult(
121
+ exit_code=exit_code,
122
+ summary=_summarize(decoded, stdout),
123
+ payload=decoded if isinstance(decoded, dict) else None,
124
+ raw_stdout=stdout,
125
+ raw_stderr=stderr,
126
+ parse_degraded=True,
127
+ result_path=path,
128
+ )
129
+
130
+ raise ResultParseError(
131
+ f"codex: no parseable result (result_path={path!r}, empty stdout)"
132
+ )
133
+
134
+ def preflight(self) -> Optional[str]:
135
+ if shutil.which(self.binary) is None:
136
+ return (
137
+ "codex CLI not found on PATH — install codex-cli, or use "
138
+ "custom:<cmd> pointing at your agent"
139
+ )
140
+ return None
@@ -0,0 +1,61 @@
1
+ """Custom command adapter — `custom:<command template>`.
2
+
3
+ The template is tokenised with `shlex.split` (shell-style quoting, but no
4
+ shell is ever spawned). Two placeholders are substituted, per token, AFTER
5
+ tokenisation so a prompt containing spaces/quotes stays a single argv element
6
+ and cannot be re-interpreted as shell syntax:
7
+
8
+ {prompt} the full brief (adapter `prompt_preamble` included)
9
+ {workdir} the directory the task runs in
10
+
11
+ Example registry value:
12
+
13
+ custom:my-agent --cwd {workdir} --task {prompt}
14
+
15
+ There is no structured-output convention for an arbitrary command, so
16
+ `supports_schema = False` and `parse_result` always degrades: it salvages a
17
+ JSON object from stdout when present, otherwise keeps the tail of the text,
18
+ and sets `parse_degraded=True`.
19
+ """
20
+
21
+ import shlex
22
+ from typing import Optional
23
+
24
+ from ..models import AdapterResult
25
+ from .base import Adapter
26
+
27
+
28
+ class CustomAdapter(Adapter):
29
+ name = "custom"
30
+ supports_schema = False
31
+ supports_sandbox = False
32
+
33
+ def __init__(self, command: str = "", *, model: Optional[str] = None,
34
+ reasoning: Optional[str] = None, timeout: int = 1800,
35
+ env: Optional[dict] = None):
36
+ super().__init__(model=model, reasoning=reasoning, timeout=timeout, env=env)
37
+ #: The raw template (everything after the `custom:` prefix).
38
+ self.command = command
39
+
40
+ def build_command(self, *, brief: str, workdir: str,
41
+ schema_path: Optional[str] = None,
42
+ read_only: bool = False) -> list:
43
+ # `schema_path` / `read_only` are not expressible for an arbitrary
44
+ # command; they are accepted for interface compatibility and ignored.
45
+ prompt = self.full_prompt(brief)
46
+ argv = []
47
+ for token in shlex.split(self.command):
48
+ argv.append(token.replace("{workdir}", workdir).replace("{prompt}", prompt))
49
+ return argv
50
+
51
+ def parse_result(self, *, exit_code: int, stdout: str, stderr: str,
52
+ result_path: Optional[str]) -> AdapterResult:
53
+ return self.degraded_result(
54
+ exit_code=exit_code, stdout=stdout, stderr=stderr,
55
+ result_path=result_path,
56
+ )
57
+
58
+ def preflight(self) -> Optional[str]:
59
+ # An arbitrary command cannot be validated without running it (which
60
+ # we never do here). Report nothing; `doctor` shows the template.
61
+ return None
@@ -0,0 +1,45 @@
1
+ """Gemini CLI adapter — NOT tested on this machine (gemini not installed).
2
+
3
+ Command shape (non-interactive prompt):
4
+
5
+ gemini -p "<prompt>" [-m <model>] [--approval-mode plan|auto_edit]
6
+
7
+ `read_only=True` maps to `--approval-mode plan`. No structured schema output
8
+ is assumed, so `supports_schema = False`; parsing degrades to stdout and sets
9
+ `parse_degraded=True` always.
10
+ """
11
+
12
+ import shutil
13
+ from typing import Optional
14
+
15
+ from ..models import AdapterResult
16
+ from .base import Adapter
17
+
18
+
19
+ class GeminiAdapter(Adapter):
20
+ name = "gemini"
21
+ binary = "gemini"
22
+ supports_schema = False
23
+ supports_sandbox = True
24
+
25
+ def build_command(self, *, brief: str, workdir: str,
26
+ schema_path: Optional[str] = None,
27
+ read_only: bool = False) -> list:
28
+ argv = ["gemini", "-p", self.full_prompt(brief)]
29
+ if self.model:
30
+ argv += ["-m", self.model]
31
+ # `plan` approval mode refuses side effects (read-only).
32
+ argv += ["--approval-mode", "plan" if read_only else "auto_edit"]
33
+ return argv
34
+
35
+ def parse_result(self, *, exit_code: int, stdout: str, stderr: str,
36
+ result_path: Optional[str]) -> AdapterResult:
37
+ return self.degraded_result(
38
+ exit_code=exit_code, stdout=stdout, stderr=stderr,
39
+ result_path=result_path,
40
+ )
41
+
42
+ def preflight(self) -> Optional[str]:
43
+ if shutil.which(self.binary) is None:
44
+ return "gemini CLI not found on PATH (adapter not tested on this machine)"
45
+ return None