coderouter-cli 2.7.8__py3-none-any.whl → 2.7.10__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.
@@ -1,9 +1,9 @@
1
1
  """External coding-agent CLI adapter (``kind="agent_cli"``).
2
2
 
3
3
  This adapter invokes an external coding-agent CLI (Claude Code / Codex /
4
- Gemini / Grok) as a single one-shot ``exec`` and returns the agent's final
5
- answer as one ``prompt in → text out`` transformation. It is the in-core
6
- implementation of the external-agents-adapter design
4
+ Antigravity / Grok) as a single one-shot ``exec`` and returns the agent's
5
+ final answer as one ``prompt in → text out`` transformation. It is the
6
+ in-core implementation of the external-agents-adapter design
7
7
  (``docs/designs/external-agents-adapter.md``).
8
8
 
9
9
  Design in one paragraph
@@ -18,36 +18,68 @@ CodeRouter merely performs the one conversion. Following the
18
18
  ``openai_compat`` precedent, a *single* adapter class fronts *multiple*
19
19
  target agents, dispatched on the ``agent`` field.
20
20
 
21
- Implemented agents (Phase 1a + 1d)
22
- ==================================
21
+ Implemented agents (Phase 1 complete: 1a + 1b + 1c + 1d)
22
+ ==========================================================
23
23
 
24
- Two targets are implemented here:
24
+ Four targets are implemented here:
25
25
 
26
26
  * ``claude`` (Claude Code CLI, Phase 1a) — the most stable CLI, the most
27
27
  fine-grained safety controls, and the only one that emits
28
28
  ``total_cost_usd`` directly (making it the reference implementation for
29
29
  the parser / cost path).
30
+ * ``codex`` (codex CLI, Phase 1b) — headless one-shot via ``codex exec
31
+ --json``, prompt on stdin (like claude). The CLI is pre-1.0 and emits
32
+ defensively-parsed JSONL (one event per line); usage comes from
33
+ ``turn.completed`` events, normalized per design §5.1.6 with
34
+ ``cached_input_tokens`` kept as a *subset* of ``input_tokens`` (unlike
35
+ claude, which folds its cache buckets into ``prompt_tokens``).
36
+ ``--skip-git-repo-check`` and ``--ephemeral`` are always passed — the
37
+ isolated workdir is not a git repo, and the adapter never wants
38
+ session persistence.
30
39
  * ``grok`` (grok CLI, Phase 1d) — headless one-shot via ``--prompt-file`` +
31
40
  ``--output-format json``. The CLI emits no token/cost figures, so usage
32
41
  is reported as zeros (cost stays 0 unless the operator sets
33
42
  ``ProviderConfig.cost``, design §5.1.6). ``--no-memory`` is always passed
34
43
  so a user-level cross-session memory setting cannot leak state between
35
44
  requests.
36
-
37
- The remaining agents (codex / gemini) are declared in the config schema so
38
- providers.yaml is forward-compatible, but constructing an adapter for them
39
- raises a clear ``AdapterError`` until their phase (1b / 1c) lands.
45
+ * ``antigravity`` (Antigravity CLI, command ``agy``, Phase 1c, in lieu of
46
+ ``gemini``) headless one-shot via ``agy -p <prompt> --mode ...``.
47
+ Google discontinued the legacy Gemini CLI's OAuth for individual
48
+ accounts in June 2026 (field-verified ``IneligibleTierError`` /
49
+ ``UNSUPPORTED_CLIENT``); its successor, the Antigravity CLI, is a
50
+ separate Go implementation (not a gemini-cli fork) fulfilling the design's
51
+ "gemini" slot. It has no stdin or ``--prompt-file`` channel — piped stdin
52
+ hangs the CLI (field-verified on agy 1.1.1) — so the prompt rides argv
53
+ (see the security note below). Output is plain text with no
54
+ ``--output-format`` flag, no token/cost figures, and no session id, so
55
+ usage is reported as zeros and meta is empty, mirroring grok's rationale.
56
+ It is also the only agent with a CLI-side self-termination flag
57
+ (``--print-timeout``), layered *underneath* the adapter's own
58
+ ``asyncio.wait_for`` + PGID SIGKILL rather than replacing it.
59
+
60
+ ``gemini`` itself is declared in the config schema for backward-compatible
61
+ config parsing, but constructing an adapter for it raises a clear
62
+ ``AdapterError`` with a migration pointer to ``agent="antigravity"``.
40
63
 
41
64
  Security (design §6, non-negotiable)
42
65
  ====================================
43
66
 
44
67
  * **allowlist argv only** — the child is launched with
45
68
  :func:`asyncio.create_subprocess_exec` and a list argv. ``shell=True`` is
46
- never used; the prompt is never subject to shell interpretation (claude
47
- reads it from stdin; grok reads it from a private ``0600`` prompt file
48
- inside the resolved workdir its ``-p`` requires the prompt as an argv
49
- value, and argv would both hit Linux's ~128KiB ``MAX_ARG_STRLEN`` on huge
50
- prompts and leak the text into ``ps`` output).
69
+ never used. Prompt delivery is one of three mechanisms depending on the
70
+ agent: claude and codex read it from stdin (codex's argv carries a
71
+ trailing ``-`` sentinel making that explicit); grok reads it from a
72
+ private ``0600`` prompt file inside the resolved workdir (its ``-p``
73
+ requires the prompt as an argv value, and argv would both hit Linux's
74
+ ~128KiB ``MAX_ARG_STRLEN`` on huge prompts and leak the text into ``ps``
75
+ output); antigravity has neither a stdin nor a ``--prompt-file`` channel
76
+ (piped stdin hangs the CLI, field-verified), so its prompt is carried on
77
+ argv — accepting the same ``MAX_ARG_STRLEN`` cap and local ``ps``
78
+ visibility that grok's file delivery was specifically designed to avoid.
79
+ No shell is involved in any case (list argv, never shell text), and the
80
+ documented threat model (an isolated, single-operator workstation) treats
81
+ local ``ps`` visibility as an accepted, documented limitation for
82
+ antigravity rather than a defect.
51
83
  * **default read-only** — ``allow_file_writes=False`` /
52
84
  ``sandbox_mode="read_only"`` are the defaults, mapped to claude's
53
85
  ``--permission-mode plan``. Writes require explicit opt-in and the sandbox
@@ -72,6 +104,7 @@ import asyncio
72
104
  import contextlib
73
105
  import json
74
106
  import os
107
+ import re
75
108
  import shutil
76
109
  import signal
77
110
  import time
@@ -127,6 +160,40 @@ _GROK_SANDBOX_ARGS = {
127
160
  "full_auto": ["--sandbox", "workspace", "--always-approve"],
128
161
  }
129
162
 
163
+ # sandbox_mode → codex ``-s/--sandbox`` value (design §5.4, codex-cli
164
+ # 0.144.1, verified via facts-codex.md). ``codex exec`` has NO approval
165
+ # flag at all (non-interactive, so there is no prompt to approve/skip), so
166
+ # ``full_auto`` collapses onto the same ``workspace-write`` value as
167
+ # ``edit`` — there is nothing further to "auto" beyond granting writes.
168
+ _CODEX_SANDBOX_ARGS = {
169
+ "read_only": ["-s", "read-only"],
170
+ "edit": ["-s", "workspace-write"],
171
+ "full_auto": ["-s", "workspace-write"],
172
+ }
173
+
174
+ # sandbox_mode → antigravity ``--mode`` flags (design §5.4, Antigravity CLI
175
+ # 1.1.1, verified via facts-antigravity.md). ``agy --help`` only enumerates
176
+ # two ``--mode`` values (``plan`` / ``accept-edits``), so ``full_auto`` maps
177
+ # onto ``accept-edits`` plus the separate ``--dangerously-skip-permissions``
178
+ # flag (auto-approves all tool executions) rather than a third ``--mode``
179
+ # value. ``--sandbox`` is deliberately never used here: it has a known bypass
180
+ # bug when combined with ``--dangerously-skip-permissions`` (agy issue #36).
181
+ _ANTIGRAVITY_MODE_ARGS = {
182
+ "read_only": ["--mode", "plan"],
183
+ "edit": ["--mode", "accept-edits"],
184
+ "full_auto": ["--mode", "accept-edits", "--dangerously-skip-permissions"],
185
+ }
186
+
187
+ # Compiled ANSI escape sequence stripper for antigravity's plain-text output
188
+ # (design §5.1.6). Covers CSI sequences (``ESC [ ... final-byte``) plus bare
189
+ # OSC-style ``ESC ]`` sequences terminated by BEL or ``ESC \`` (kept simple —
190
+ # antigravity's TUI chrome is not fully specified, so this is a defensive
191
+ # best-effort strip, not a full ANSI parser).
192
+ _ANSI_RE = re.compile(
193
+ r"\x1b\[[0-9;?]*[ -/]*[@-~]" # CSI ... final byte
194
+ r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC ... BEL or ST
195
+ )
196
+
130
197
 
131
198
  def _chunk_text(text: str, size: int = _STREAM_CHUNK_CHARS) -> Iterator[str]:
132
199
  """Split ``text`` into ``size``-char pieces for the pseudo-stream."""
@@ -135,20 +202,26 @@ def _chunk_text(text: str, size: int = _STREAM_CHUNK_CHARS) -> Iterator[str]:
135
202
 
136
203
 
137
204
  class AgentCliAdapter(BaseAdapter):
138
- """Invoke an external coding-agent CLI one-shot (claude + grok).
205
+ """Invoke an external coding-agent CLI one-shot (claude + codex + grok +
206
+ antigravity).
139
207
 
140
208
  The ``agent`` field selects the argv builder / output parser via the
141
209
  dispatch tables built in :meth:`__init__`, mirroring how
142
210
  ``openai_compat`` fronts many HTTP backends from one class.
143
211
  """
144
212
 
213
+ _IMPLEMENTED_AGENTS = ("claude", "codex", "grok", "antigravity")
214
+
145
215
  def __init__(self, config: ProviderConfig) -> None:
146
216
  """Bind to a ``ProviderConfig`` and reject unsupported agents.
147
217
 
148
- Constructing an adapter for an agent other than ``claude`` / ``grok``
149
- raises a non-retryable :class:`AdapterError` the other targets are
150
- declared in the schema but not implemented until their phase
151
- (design §9).
218
+ ``gemini`` gets its own rejection message (non-retryable): Google
219
+ discontinued the Gemini CLI's OAuth for individual accounts in June
220
+ 2026, so this adapter points the operator at ``antigravity``
221
+ instead of a generic "not implemented" message. Any other
222
+ unimplemented value (future-proofing; currently none, since the
223
+ schema's ``Literal`` only allows the five known agents) falls back
224
+ to a generic message listing what IS implemented.
152
225
  """
153
226
  super().__init__(config)
154
227
  if config.agent_cli is None: # pragma: no cover - schema enforces this
@@ -158,30 +231,50 @@ class AgentCliAdapter(BaseAdapter):
158
231
  retryable=False,
159
232
  )
160
233
  self.acfg: AgentCliConfig = config.agent_cli
161
- if self.acfg.agent not in ("claude", "grok"):
234
+ if self.acfg.agent not in self._IMPLEMENTED_AGENTS:
235
+ if self.acfg.agent == "gemini":
236
+ raise AdapterError(
237
+ "agent 'gemini' is not supported: Google discontinued "
238
+ "the Gemini CLI for individual accounts (June 2026; "
239
+ "IneligibleTierError). Use agent='antigravity' "
240
+ "(Antigravity CLI, command 'agy') instead.",
241
+ provider=config.name,
242
+ retryable=False,
243
+ )
162
244
  raise AdapterError(
163
- f"agent {self.acfg.agent!r} is not implemented yet "
164
- f"(implemented: claude, grok). Wait for Phase 1b/1c.",
245
+ f"agent {self.acfg.agent!r} is not implemented "
246
+ f"(implemented: {', '.join(self._IMPLEMENTED_AGENTS)}).",
165
247
  provider=config.name,
166
248
  retryable=False,
167
249
  )
168
250
  # agent → argv builder / output parser dispatch tables. claude landed
169
- # in Phase 1a, grok in Phase 1d; Phase 1b/1c add codex / gemini.
251
+ # in Phase 1a, codex in Phase 1b, grok in Phase 1d, antigravity in
252
+ # Phase 1c — all four (design §9) are now implemented.
170
253
  self._builders = {
171
254
  "claude": self._build_claude_argv,
255
+ "codex": self._build_codex_argv,
172
256
  "grok": self._build_grok_argv,
257
+ "antigravity": self._build_antigravity_argv,
173
258
  }
174
259
  self._parsers = {
175
260
  "claude": self._parse_claude,
261
+ "codex": self._parse_codex,
176
262
  "grok": self._parse_grok,
263
+ "antigravity": self._parse_antigravity,
177
264
  }
178
- # Prompt delivery is per-agent: claude reads its print-mode prompt
179
- # from stdin (10MB cap), which keeps argv free of the (potentially
180
- # huge) prompt text. grok's ``-p`` REQUIRES the prompt as its argv
181
- # value (piped stdin is only appended as extra context, verified on
182
- # v0.2.93), so grok gets the prompt via ``--prompt-file`` instead
183
- # see ``_write_prompt_file`` for the rationale.
184
- self._uses_stdin = self.acfg.agent == "claude"
265
+ # Prompt delivery is per-agent, one of three mechanisms: claude and
266
+ # codex read their prompt from stdin (10MB cap; codex's argv carries
267
+ # a trailing "-" sentinel making that explicit), which keeps argv
268
+ # free of the (potentially huge) prompt text. grok's ``-p``
269
+ # REQUIRES the prompt as its argv value (piped stdin is only
270
+ # appended as extra context, verified on v0.2.93), so grok gets the
271
+ # prompt via ``--prompt-file`` instead — see ``_write_prompt_file``
272
+ # for the rationale. antigravity has NEITHER a stdin nor a
273
+ # ``--prompt-file`` channel — piped stdin hangs the CLI outright
274
+ # (field-verified on agy 1.1.1) — so its prompt rides argv instead;
275
+ # see ``_build_antigravity_argv`` for the tradeoff this accepts.
276
+ self._uses_stdin = self.acfg.agent in ("claude", "codex")
277
+ self._uses_argv = self.acfg.agent == "antigravity"
185
278
 
186
279
  # ------------------------------------------------------------------
187
280
  # BaseAdapter contract
@@ -230,10 +323,16 @@ class AgentCliAdapter(BaseAdapter):
230
323
 
231
324
  # Agents that cannot take the prompt on stdin (grok) get it through a
232
325
  # private temp file inside the workdir; it is ALWAYS removed in the
233
- # ``finally`` below, including on timeout / exception paths.
234
- prompt_file = None if self._uses_stdin else self._write_prompt_file(prompt, workdir)
326
+ # ``finally`` below, including on timeout / exception paths. Argv
327
+ # agents (antigravity) get neither a file nor stdin bytes — the
328
+ # prompt text is handed straight to the builder instead.
329
+ prompt_file = (
330
+ None
331
+ if self._uses_stdin or self._uses_argv
332
+ else self._write_prompt_file(prompt, workdir)
333
+ )
235
334
  try:
236
- argv = self._builders[self.acfg.agent](workdir, prompt_file)
335
+ argv = self._builders[self.acfg.agent](workdir, prompt_file, prompt)
237
336
  # Resolve the executable to an absolute path so argv[0] is a
238
337
  # concrete binary independent of the child's minimal PATH
239
338
  # (design §6 allowlist).
@@ -272,6 +371,10 @@ class AgentCliAdapter(BaseAdapter):
272
371
  retryable=False,
273
372
  ) from exc
274
373
 
374
+ # Non-stdin agents (grok's file delivery, antigravity's argv
375
+ # delivery) get None here, so ``communicate()`` closes stdin
376
+ # immediately without writing to it — verified required for
377
+ # antigravity, whose CLI hangs if anything is piped to stdin.
275
378
  stdin_bytes = prompt.encode("utf-8") if self._uses_stdin else None
276
379
  try:
277
380
  stdout, stderr = await asyncio.wait_for(
@@ -341,7 +444,9 @@ class AgentCliAdapter(BaseAdapter):
341
444
  # claude argv builder + output parser
342
445
  # ------------------------------------------------------------------
343
446
 
344
- def _build_claude_argv(self, workdir: str, prompt_file: str | None = None) -> list[str]:
447
+ def _build_claude_argv(
448
+ self, workdir: str, prompt_file: str | None = None, prompt: str | None = None
449
+ ) -> list[str]:
345
450
  """Assemble the ``claude -p`` argv (design §5.1.5 / §5.4).
346
451
 
347
452
  Shape::
@@ -350,12 +455,13 @@ class AgentCliAdapter(BaseAdapter):
350
455
  --permission-mode <plan|acceptEdits> --add-dir <workdir>
351
456
 
352
457
  The prompt is fed on stdin (not argv), so it never appears here and
353
- ``prompt_file`` is ignored (it exists only to keep the builder
354
- signature uniform across agents). ``--bare`` is deliberately NOT
355
- added it would skip OAuth/keychain reads and break subscription
356
- auth (design §5.3.4).
458
+ both ``prompt_file`` and ``prompt`` are ignored (they exist only to
459
+ keep the builder signature uniform across agents see
460
+ ``_build_antigravity_argv`` for the one agent that needs ``prompt``).
461
+ ``--bare`` is deliberately NOT added — it would skip OAuth/keychain
462
+ reads and break subscription auth (design §5.3.4).
357
463
  """
358
- del prompt_file # claude takes the prompt on stdin, not from a file.
464
+ del prompt_file, prompt # claude takes the prompt on stdin.
359
465
  model = self.acfg.model or self.config.model
360
466
  argv = [self.acfg.command, "-p", "--output-format", "json", "--model", model]
361
467
  if self.acfg.max_turns is not None:
@@ -465,11 +571,188 @@ class AgentCliAdapter(BaseAdapter):
465
571
  usage["duration_ms"] = data["duration_ms"]
466
572
  return usage
467
573
 
574
+ # ------------------------------------------------------------------
575
+ # codex argv builder + output parser (Phase 1b)
576
+ # ------------------------------------------------------------------
577
+
578
+ def _build_codex_argv(
579
+ self, workdir: str, prompt_file: str | None = None, prompt: str | None = None
580
+ ) -> list[str]:
581
+ """Assemble the ``codex exec`` argv (design §5.1.5 / §5.4, verified
582
+ against codex-cli 0.144.1 — see ``_codex/facts-codex.md``).
583
+
584
+ Shape::
585
+
586
+ codex exec --json --skip-git-repo-check --ephemeral
587
+ -m <model> -C <workdir> -s <read-only|workspace-write> -
588
+
589
+ The prompt is fed on stdin (not argv), so it never appears here and
590
+ both ``prompt_file`` and ``prompt`` are ignored (they exist only to
591
+ keep the builder signature uniform across agents) — the trailing
592
+ ``-`` makes the stdin intent explicit to ``codex exec``, which
593
+ otherwise treats a bare invocation with no PROMPT arg the same way
594
+ but reads more ambiguously in a fixed argv list. ``--skip-git-repo-
595
+ check`` is ALWAYS passed because the isolated workdir is not a git
596
+ repository (without it the CLI exits 1). ``--ephemeral`` is ALWAYS
597
+ passed so no session state persists to disk, matching the adapter's
598
+ stateless one-shot ethos (the same rationale as grok's
599
+ ``--no-memory``). codex has no ``--max-turns`` equivalent, so
600
+ ``AgentCliConfig.max_turns`` is silently ignored here (documented in
601
+ the schema).
602
+ """
603
+ del prompt_file, prompt # codex takes the prompt on stdin.
604
+ model = self.acfg.model or self.config.model
605
+ argv = [
606
+ self.acfg.command,
607
+ "exec",
608
+ "--json",
609
+ "--skip-git-repo-check",
610
+ "--ephemeral",
611
+ "-m",
612
+ model,
613
+ "-C",
614
+ workdir,
615
+ ]
616
+ argv += self._codex_sandbox_args()
617
+ argv += ["-"]
618
+ return argv
619
+
620
+ def _codex_sandbox_args(self) -> list[str]:
621
+ """Map ``sandbox_mode`` → codex ``-s/--sandbox`` flags, clamped.
622
+
623
+ Same clamp as claude/grok (design §5.4): when ``allow_file_writes``
624
+ is False the effective mode is forced to ``read_only`` regardless of
625
+ ``sandbox_mode``, so writes always require the explicit opt-in.
626
+ ``codex exec`` has no approval flag in 0.144.1 (non-interactive, so
627
+ there is nothing to approve), so ``full_auto`` maps onto the same
628
+ ``workspace-write`` value as ``edit`` —
629
+ ``--dangerously-bypass-approvals-and-sandbox`` is never used.
630
+ """
631
+ mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
632
+ return list(_CODEX_SANDBOX_ARGS[mode])
633
+
634
+ def _parse_codex(
635
+ self, stdout: bytes, stderr: bytes
636
+ ) -> tuple[str, dict[str, Any], dict[str, Any]]:
637
+ """Parse codex ``exec --json`` JSONL output (verified codex-cli
638
+ 0.144.1, ``_codex/facts-codex.md``).
639
+
640
+ Real-run shape (one JSON object per line, newline-delimited)::
641
+
642
+ {"type":"thread.started","thread_id":"<uuid>"}
643
+ {"type":"turn.started"}
644
+ {"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"2"}}
645
+ {"type":"turn.completed","usage":{"input_tokens":13810,"cached_input_tokens":9984,"output_tokens":5,"reasoning_output_tokens":0}}
646
+
647
+ The CLI is pre-1.0 and its JSON schema is not frozen, so parsing is
648
+ deliberately defensive at every level: individual lines that fail to
649
+ parse (or parse to something other than a JSON object) are SKIPPED
650
+ rather than aborting the whole parse — stray non-JSON noise on
651
+ stdout should not sink an otherwise-valid answer. The final answer
652
+ is the LAST ``item.completed`` event whose ``item`` is an
653
+ ``agent_message`` with a string ``text``. If a completed answer was
654
+ found, it is returned even when a later ``error`` / ``turn.failed``
655
+ event also appears (a completed answer beats a trailing error); if
656
+ no answer was found, an ``error`` / ``turn.failed`` event (or the
657
+ total absence of any agent_message) raises a retryable
658
+ :class:`AdapterError`.
659
+ """
660
+ text = stdout.decode("utf-8", "replace")
661
+ if not text.strip():
662
+ raise AdapterError(
663
+ "codex produced no stdout to parse",
664
+ provider=self.name,
665
+ retryable=True,
666
+ )
667
+
668
+ final_text: str | None = None
669
+ thread_id: str | None = None
670
+ failure_event: dict[str, Any] | None = None
671
+ prompt_tokens = 0
672
+ completion_tokens = 0
673
+ cached_tokens = 0
674
+ reasoning_tokens = 0
675
+
676
+ for line in text.splitlines():
677
+ line = line.strip()
678
+ if not line:
679
+ continue
680
+ try:
681
+ event = json.loads(line)
682
+ except json.JSONDecodeError:
683
+ # Defensive against stray non-JSON noise (progress text that
684
+ # leaked onto stdout, partial writes, etc.) — skip the line.
685
+ continue
686
+ if not isinstance(event, dict):
687
+ continue
688
+
689
+ etype = event.get("type")
690
+ if etype == "thread.started":
691
+ tid = event.get("thread_id")
692
+ if isinstance(tid, str):
693
+ thread_id = tid
694
+ elif etype == "item.completed":
695
+ item = event.get("item")
696
+ if (
697
+ isinstance(item, dict)
698
+ and item.get("type") == "agent_message"
699
+ and isinstance(item.get("text"), str)
700
+ ):
701
+ final_text = item["text"]
702
+ elif etype == "turn.completed":
703
+ usage = event.get("usage")
704
+ usage = usage if isinstance(usage, dict) else {}
705
+
706
+ def _int(key: str, _usage: dict[str, Any] = usage) -> int:
707
+ value = _usage.get(key)
708
+ return int(value) if isinstance(value, (int, float)) else 0
709
+
710
+ prompt_tokens += _int("input_tokens")
711
+ completion_tokens += _int("output_tokens")
712
+ cached_tokens += _int("cached_input_tokens")
713
+ reasoning_tokens += _int("reasoning_output_tokens")
714
+ elif etype in ("error", "turn.failed"):
715
+ failure_event = event
716
+
717
+ if final_text is None:
718
+ if failure_event is not None:
719
+ raise AdapterError(
720
+ f"codex reported {failure_event.get('type')}: {failure_event!r}"[:500],
721
+ provider=self.name,
722
+ retryable=True,
723
+ )
724
+ raise AdapterError(
725
+ "codex JSONL output contained no agent_message",
726
+ provider=self.name,
727
+ retryable=True,
728
+ )
729
+
730
+ # cached_input_tokens is a SUBSET of input_tokens (not additive) —
731
+ # verified sample: input 13810 ⊇ cached 9984 — so it is preserved
732
+ # under prompt_tokens_details rather than folded into prompt_tokens
733
+ # (this differs from claude's normalization, design §5.1.6).
734
+ usage_out: dict[str, Any] = {
735
+ "prompt_tokens": prompt_tokens,
736
+ "completion_tokens": completion_tokens,
737
+ "total_tokens": prompt_tokens + completion_tokens,
738
+ }
739
+ if cached_tokens > 0:
740
+ usage_out["prompt_tokens_details"] = {"cached_tokens": cached_tokens}
741
+ if reasoning_tokens > 0:
742
+ usage_out["completion_tokens_details"] = {"reasoning_tokens": reasoning_tokens}
743
+
744
+ meta: dict[str, Any] = {}
745
+ if thread_id is not None:
746
+ meta["coderouter_session_id"] = thread_id
747
+ return final_text, usage_out, meta
748
+
468
749
  # ------------------------------------------------------------------
469
750
  # grok argv builder + output parser (Phase 1d)
470
751
  # ------------------------------------------------------------------
471
752
 
472
- def _build_grok_argv(self, workdir: str, prompt_file: str | None = None) -> list[str]:
753
+ def _build_grok_argv(
754
+ self, workdir: str, prompt_file: str | None = None, prompt: str | None = None
755
+ ) -> list[str]:
473
756
  """Assemble the grok headless argv (design §5, grok CLI v0.2.93).
474
757
 
475
758
  Shape::
@@ -483,8 +766,11 @@ class AgentCliAdapter(BaseAdapter):
483
766
  would hit Linux's ~128KiB ``MAX_ARG_STRLEN`` on large prompts and
484
767
  leak the text into ``ps`` output. ``--no-memory`` is deliberate: it
485
768
  enforces the one-request-one-transformation statelessness even if
486
- the user's grok config enables cross-session memory.
769
+ the user's grok config enables cross-session memory. ``prompt`` is
770
+ ignored (it exists only to keep the builder signature uniform across
771
+ agents — grok never takes it on argv).
487
772
  """
773
+ del prompt
488
774
  if prompt_file is None: # pragma: no cover - generate() always supplies it
489
775
  raise AdapterError(
490
776
  "grok argv requires a prompt file",
@@ -577,6 +863,108 @@ class AgentCliAdapter(BaseAdapter):
577
863
  meta["coderouter_session_id"] = session_id
578
864
  return result, usage, meta
579
865
 
866
+ # ------------------------------------------------------------------
867
+ # antigravity argv builder + output parser (Phase 1c, in lieu of gemini)
868
+ # ------------------------------------------------------------------
869
+
870
+ def _build_antigravity_argv(
871
+ self, workdir: str, prompt_file: str | None = None, prompt: str | None = None
872
+ ) -> list[str]:
873
+ """Assemble the ``agy -p`` argv (design §5.1.5 / §5.4, verified
874
+ against Antigravity CLI 1.1.1 — see ``_codex/facts-antigravity.md``).
875
+
876
+ Shape::
877
+
878
+ agy -p <prompt> --model <m> --mode <plan|accept-edits>
879
+ [--dangerously-skip-permissions] --print-timeout <n>s
880
+
881
+ ``workdir`` is unused here — antigravity picks up its working
882
+ directory from the child process's ``cwd`` (set by ``generate()``),
883
+ and this adapter deliberately never passes ``--add-dir`` (design
884
+ keeps the argv minimal; only claude's multi-root model needs it).
885
+ ``prompt_file`` is unused (antigravity has no such flag).
886
+
887
+ Prompt-delivery tradeoff (read this before touching the ``-p``
888
+ line): agy has no stdin channel — piping content to stdin makes the
889
+ real CLI hang waiting for a response that never comes (field-
890
+ verified on 1.1.1) — and no ``--prompt-file`` equivalent either. The
891
+ prompt therefore rides argv as the ``-p`` value, which is the *only*
892
+ delivery mechanism the CLI offers. This caps practical prompt size
893
+ at Linux's ~128KiB ``MAX_ARG_STRLEN`` and exposes the prompt text to
894
+ ``ps`` on the local host — exactly the two costs grok's
895
+ ``--prompt-file`` delivery was built to avoid (see the module
896
+ docstring's security section). No shell is involved (list argv, not
897
+ shell text), and the adapter's threat model — an isolated,
898
+ single-operator workstation — accepts local ``ps`` visibility as a
899
+ documented limitation rather than a defect; there is no safer
900
+ channel to fall back to.
901
+
902
+ ``--print-timeout`` is antigravity's own self-termination clock,
903
+ derived from ``exec_timeout_s`` — it is the CLI's *first* wall
904
+ against a hung call; the adapter's outer ``asyncio.wait_for`` +
905
+ process-group ``SIGKILL`` remains the second, unconditional wall
906
+ (design §6). ``max_turns`` is never emitted: agy has no ``--max-
907
+ turns``-equivalent flag (like codex), so ``AgentCliConfig.max_turns``
908
+ is silently ignored here (documented in the schema). No ``--sandbox``
909
+ (known bypass bug alongside ``--dangerously-skip-permissions``,
910
+ agy issue #36) and no ``--add-dir`` are ever passed.
911
+ """
912
+ del prompt_file # antigravity has no prompt-file flag.
913
+ if prompt is None: # pragma: no cover - generate() always supplies it
914
+ raise AdapterError(
915
+ "antigravity argv requires the prompt text",
916
+ provider=self.name,
917
+ retryable=False,
918
+ )
919
+ model = self.acfg.model or self.config.model
920
+ argv = [self.acfg.command, "-p", prompt, "--model", model]
921
+ argv += self._antigravity_mode_args()
922
+ argv += ["--print-timeout", f"{int(self.acfg.exec_timeout_s)}s"]
923
+ return argv
924
+
925
+ def _antigravity_mode_args(self) -> list[str]:
926
+ """Map ``sandbox_mode`` → antigravity ``--mode`` flags, clamped.
927
+
928
+ Same clamp as claude/codex/grok (design §5.4): when
929
+ ``allow_file_writes`` is False the effective mode is forced to
930
+ ``read_only`` regardless of ``sandbox_mode``, so writes always
931
+ require the explicit opt-in.
932
+ """
933
+ mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
934
+ return list(_ANTIGRAVITY_MODE_ARGS[mode])
935
+
936
+ def _parse_antigravity(
937
+ self, stdout: bytes, stderr: bytes
938
+ ) -> tuple[str, dict[str, Any], dict[str, Any]]:
939
+ """Parse antigravity's plain-text ``-p`` output (verified agy 1.1.1).
940
+
941
+ There is no ``--output-format`` flag at all (agy's ``--help`` does
942
+ not list one) — output is whatever the model printed, decorated
943
+ with whatever terminal styling the CLI applied even in non-TTY runs.
944
+ Parsing is therefore: UTF-8 decode (defensively, replacing invalid
945
+ bytes) → strip ANSI escape sequences (``_ANSI_RE``, best-effort, see
946
+ its definition) → ``.strip()`` surrounding whitespace. Empty output
947
+ raises a retryable :class:`AdapterError` so the chain can fall
948
+ through. There is no token/cost figure and no session id anywhere
949
+ in agy's output, so ``usage`` is all-zeros (cost stays 0 unless the
950
+ operator sets ``ProviderConfig.cost``, same rationale as grok,
951
+ design §5.1.6) and ``meta`` is empty.
952
+ """
953
+ text = _ANSI_RE.sub("", stdout.decode("utf-8", "replace")).strip()
954
+ if not text:
955
+ raise AdapterError(
956
+ "antigravity produced no stdout",
957
+ provider=self.name,
958
+ retryable=True,
959
+ )
960
+ usage: dict[str, Any] = {
961
+ "prompt_tokens": 0,
962
+ "completion_tokens": 0,
963
+ "total_tokens": 0,
964
+ }
965
+ meta: dict[str, Any] = {}
966
+ return text, usage, meta
967
+
580
968
  # ------------------------------------------------------------------
581
969
  # helpers: prompt rendering, response shaping, env, workdir, kill
582
970
  # ------------------------------------------------------------------
@@ -168,13 +168,18 @@ class AgentCliConfig(BaseModel):
168
168
 
169
169
  Introduced by the external-agents-adapter design (Phase 1). One
170
170
  ``agent_cli`` sub-config drives the :class:`AgentCliAdapter`, which
171
- invokes an external coding-agent CLI (codex / gemini / grok / claude)
172
- in a single one-shot ``exec`` and returns the final answer as one
173
- ``prompt in → text out`` transformation. ``claude`` (Claude Code CLI,
174
- Phase 1a) and ``grok`` (grok CLI, Phase 1d) are implemented;
175
- codex / gemini are declared at the schema level so configs are
176
- forward-compatible, but the adapter rejects them until their phase
177
- lands.
171
+ invokes an external coding-agent CLI (codex / gemini / grok / claude /
172
+ antigravity) in a single one-shot ``exec`` and returns the final answer
173
+ as one ``prompt in → text out`` transformation. ``claude`` (Claude Code
174
+ CLI, Phase 1a), ``codex`` (codex CLI, Phase 1b), ``grok`` (grok CLI,
175
+ Phase 1d) and ``antigravity`` (Antigravity CLI, Phase 1c, in lieu of
176
+ ``gemini``) are implemented. ``gemini`` is declared at the schema level
177
+ for backward-compatible config parsing, but the adapter rejects it with
178
+ a migration pointer: Google discontinued the (legacy) Gemini CLI's
179
+ OAuth for individual accounts in June 2026 (``IneligibleTierError`` /
180
+ ``UNSUPPORTED_CLIENT`` on the real client) and its successor is the
181
+ Antigravity CLI (command ``agy``, a separate Go implementation, not a
182
+ gemini-cli fork) — set ``agent: "antigravity"`` instead.
178
183
 
179
184
  Auth note (grok): the grok CLI uses OAuth credentials stored under
180
185
  ``~/.grok`` (``grok login``), which the adapter's HOME inheritance
@@ -182,24 +187,49 @@ class AgentCliConfig(BaseModel):
182
187
  ``GROK_CODE_XAI_API_KEY`` in ``passthrough_env`` (this is grok's key
183
188
  env var — NOT ``XAI_API_KEY``).
184
189
 
190
+ Auth note (codex): the codex CLI uses a ChatGPT-plan OAuth login stored
191
+ under ``~/.codex`` (``codex login``), which the adapter's HOME
192
+ inheritance already covers — the credentials go stale after roughly 8
193
+ days and are auto-refreshed on use, so no extra config is needed for
194
+ interactive/subscription setups. For CI / API-key setups, list
195
+ ``CODEX_API_KEY`` (exec-only) or ``OPENAI_API_KEY`` (general) in
196
+ ``passthrough_env``.
197
+
198
+ Auth note (antigravity): the Antigravity CLI uses a Google-account OAuth
199
+ login (free tier included), with credentials preferentially stored in
200
+ the OS keyring and mirrored under ``~/.gemini/antigravity-cli/``
201
+ (``credentials.enc`` / ``settings.json``) — the adapter's HOME (and, on
202
+ macOS, USER) inheritance already covers this, no extra config needed.
203
+ Any API-key environment variable for CI / non-interactive setups is
204
+ UNCONFIRMED (field reports disagree on the variable name) — this
205
+ docstring deliberately does not name one as authoritative; if you find
206
+ one that works, list it in ``passthrough_env``.
207
+
185
208
  Follows the ``extra="forbid"`` convention used across this module so a
186
209
  typo'd key fails at config-load rather than being silently ignored.
187
210
  """
188
211
 
189
212
  model_config = ConfigDict(extra="forbid")
190
213
 
191
- agent: Literal["codex", "gemini", "grok", "claude"] = Field(
214
+ agent: Literal["codex", "gemini", "grok", "claude", "antigravity"] = Field(
192
215
  ...,
193
216
  description=(
194
- "External coding-agent CLI to invoke. 'claude' (Phase 1a) and "
195
- "'grok' (Phase 1d) are implemented; 'codex' / 'gemini' pending."
217
+ "External coding-agent CLI to invoke. 'claude' (Phase 1a), "
218
+ "'codex' (Phase 1b), 'grok' (Phase 1d) and 'antigravity' "
219
+ "(Phase 1c, Google's Antigravity CLI, command 'agy') are "
220
+ "implemented. 'gemini' is rejected by the adapter — Google "
221
+ "discontinued the Gemini CLI for individual accounts in June "
222
+ "2026; use 'antigravity' instead."
196
223
  ),
197
224
  )
198
225
  command: str | None = Field(
199
226
  default=None,
200
227
  description=(
201
228
  "CLI executable name or absolute path (resolved via PATH). "
202
- "When unset, defaults to the ``agent`` name."
229
+ "When unset, defaults to the ``agent`` name — EXCEPT "
230
+ "'antigravity', whose binary is named ``agy`` (the product is "
231
+ "'Antigravity CLI' but the executable keeps the short, "
232
+ "pre-rename command name)."
203
233
  ),
204
234
  )
205
235
  workdir: str | None = Field(
@@ -282,9 +312,14 @@ class AgentCliConfig(BaseModel):
282
312
  ``sandbox_mode="read_only"`` is contradictory — the operator asked
283
313
  for writes while pinning a read-only sandbox. Fail fast at load,
284
314
  matching the module's other cross-field validators.
315
+
316
+ ``command`` defaults to the ``agent`` name for every agent EXCEPT
317
+ ``antigravity``, whose binary is ``agy`` — the product renamed from
318
+ Gemini CLI to Antigravity CLI, but the executable kept its short
319
+ pre-rename name.
285
320
  """
286
321
  if self.command is None:
287
- self.command = self.agent
322
+ self.command = "agy" if self.agent == "antigravity" else self.agent
288
323
  if self.allow_file_writes and self.sandbox_mode == "read_only":
289
324
  raise ValueError(
290
325
  "agent_cli: allow_file_writes=True conflicts with "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderouter-cli
3
- Version: 2.7.8
3
+ Version: 2.7.10
4
4
  Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
5
5
  Project-URL: Homepage, https://github.com/zephel01/CodeRouter
6
6
  Project-URL: Repository, https://github.com/zephel01/CodeRouter
@@ -16,7 +16,7 @@ coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,
16
16
  coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
17
17
  coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
18
18
  coderouter/adapters/__init__.py,sha256=7dIDSZ-FE_0iSqLSDc_lK1idRdLTKcM2hP9tCJipgPI,463
19
- coderouter/adapters/agent_cli.py,sha256=_-9DsSO-zQJI-bEldBhFgIBbAQd6kqMCqX7xNy8WqtQ,32992
19
+ coderouter/adapters/agent_cli.py,sha256=0g0V92drEQLJACXq-Dn0hE-J6e3vmsuBr5UdnAkXJyk,52430
20
20
  coderouter/adapters/anthropic_native.py,sha256=CT9Hitun-c3z83YHT2zZXgwZpY3_t6eRnOfAmNud2aw,23610
21
21
  coderouter/adapters/base.py,sha256=ykKMaiIVVGr23oFFXilK2iP_YhI8-CtsEiJyFXaC9bE,10396
22
22
  coderouter/adapters/openai_compat.py,sha256=wcrd_UpV7N6ISpUyKvdFPGU-YjCrX5oMqGpjAFEeWfg,20356
@@ -25,7 +25,7 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
25
25
  coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
26
26
  coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
27
27
  coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
28
- coderouter/config/schemas.py,sha256=c5VJkcsuPHIKgTtcRQnXL-q52ollsWOISnDC8RuOX70,86528
28
+ coderouter/config/schemas.py,sha256=sizuuxBgavGWaxBi7MtbLi0al-Ba72H7iVgI7CRYgMY,88835
29
29
  coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
30
30
  coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
31
31
  coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
@@ -69,8 +69,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
69
69
  coderouter/translation/anthropic.py,sha256=aZkcYH4x82b0x7efJgJb9RWn9Hbyc9pEOthXe4vjUdU,11113
70
70
  coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
71
71
  coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
72
- coderouter_cli-2.7.8.dist-info/METADATA,sha256=S3XO80V0pWn7ifsjuoMOEYcU0Cnru5WIADYIq9-PQb4,15546
73
- coderouter_cli-2.7.8.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
74
- coderouter_cli-2.7.8.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
75
- coderouter_cli-2.7.8.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
76
- coderouter_cli-2.7.8.dist-info/RECORD,,
72
+ coderouter_cli-2.7.10.dist-info/METADATA,sha256=4AzX8XCERv5Hd1iywyybjR1gjsyfBh72bFF24YLH85U,15547
73
+ coderouter_cli-2.7.10.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
74
+ coderouter_cli-2.7.10.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
75
+ coderouter_cli-2.7.10.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
76
+ coderouter_cli-2.7.10.dist-info/RECORD,,