ags-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. harness/workers/inprocess.py +135 -0
@@ -0,0 +1,345 @@
1
+ """Compare-and-merge workflow.
2
+
3
+ ``compare`` produces a side-by-side, metric-annotated view of two or more runs and
4
+ (for ``judge``) a heuristic winner. ``merge`` synthesizes a canonical final answer
5
+ and — when ``persist`` is set — writes it to durable workspace memory, linking the
6
+ ``Comparison`` row to the resulting ``memory_item``. This is how multiple providers'
7
+ outputs become one persisted, vendor-neutral outcome.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import uuid
14
+
15
+ from harness_sdk.base import SessionContext
16
+ from sqlalchemy import select
17
+ from sqlalchemy.ext.asyncio import AsyncSession
18
+
19
+ from harness.adapters.registry import build_adapter
20
+ from harness.memory.ingest import ingest_memory
21
+ from harness.memory.namespaces import workspace_ns
22
+ from harness.models.comparison import Comparison
23
+ from harness.models.enums import ComparisonStrategy, MemoryScope, MemoryType, RunStatus
24
+ from harness.models.provider_config import ProviderConfig
25
+ from harness.models.run import Run
26
+ from harness.models.session import Session
27
+ from harness.models.workspace import Workspace
28
+ from harness.observability.audit import write_audit
29
+
30
+ # How much of each candidate's output the judge gets to see. Generous on
31
+ # purpose: a few hundred characters is not enough to tell two answers apart.
32
+ PREVIEW_CHARS = 4000
33
+
34
+
35
+ def _provider_of(graph_node_id: str | None) -> str:
36
+ """Agent name behind a graph node id ('claude-0' -> 'claude')."""
37
+ return re.sub(r"-\d+$", "", graph_node_id or "") or "unknown"
38
+
39
+
40
+ def _winner_from_verdict(verdict: str, scored: list[dict]) -> str | None:
41
+ """Resolve the judge's prose answer to a run id.
42
+
43
+ Prefers the declared ``WINNER: <name>`` line, then any provider name or run
44
+ id mentioned anywhere in the text. Returns None when nothing matches, which
45
+ leaves the caller on its heuristic fallback.
46
+ """
47
+ if not verdict:
48
+ return None
49
+ by_name = {s["provider"].lower(): s["run_id"] for s in scored}
50
+
51
+ match = re.search(r"^\s*#*\s*\**\s*WINNER\s*\**\s*:\s*\**\s*(.+?)\s*\**\s*$",
52
+ verdict, re.IGNORECASE | re.MULTILINE)
53
+ if match:
54
+ claimed = match.group(1).strip().strip("`").lower()
55
+ if claimed in by_name:
56
+ return by_name[claimed]
57
+ # The judge may decorate the name ("local (llama3.1)") — take the
58
+ # longest candidate name it contains so 'gpt' can't shadow 'gpt-fast'.
59
+ hits = [n for n in by_name if n and n in claimed]
60
+ if hits:
61
+ return by_name[max(hits, key=len)]
62
+
63
+ for s in scored:
64
+ if s["run_id"] in verdict:
65
+ return s["run_id"]
66
+ return None
67
+
68
+
69
+ def _quality_score(run: Run) -> float:
70
+ """Cheap, deterministic heuristic for fallback."""
71
+ if run.status != RunStatus.succeeded:
72
+ return 0.0
73
+ out = run.final_output or ""
74
+ length_score = min(len(out) / 1000.0, 1.0)
75
+ cost = run.cost or {}
76
+ tokens = cost.get("total_tokens", 0) or 0
77
+ efficiency = 1.0 / (1.0 + tokens / 1000.0)
78
+ return 0.7 * length_score + 0.3 * efficiency
79
+
80
+
81
+ async def _load_runs(db: AsyncSession, run_ids: list[uuid.UUID]) -> list[Run]:
82
+ rows = (await db.execute(select(Run).where(Run.id.in_(run_ids)))).scalars().all()
83
+ order = {rid: i for i, rid in enumerate(run_ids)}
84
+ return sorted(rows, key=lambda r: order.get(r.id, 0))
85
+
86
+
87
+ async def _pick_provider(db: AsyncSession, name: str | None = None) -> ProviderConfig | None:
88
+ """The provider to use for an internal (judge/merge) LLM call.
89
+
90
+ *name* selects a specific agent; when it is omitted — or names one that is
91
+ unknown or disabled — this falls back to the first enabled provider, which
92
+ is the historical behaviour.
93
+ """
94
+ if name:
95
+ pc = (await db.execute(
96
+ select(ProviderConfig).where(
97
+ ProviderConfig.name == name, ProviderConfig.enabled.is_(True)
98
+ ).limit(1)
99
+ )).scalar_one_or_none()
100
+ if pc is not None:
101
+ return pc
102
+ return (await db.execute(
103
+ select(ProviderConfig).where(ProviderConfig.enabled.is_(True)).limit(1)
104
+ )).scalar_one_or_none()
105
+
106
+
107
+ async def _invoke_llm(
108
+ db: AsyncSession,
109
+ session_id: uuid.UUID,
110
+ prompt: str,
111
+ *,
112
+ pc: ProviderConfig | None = None,
113
+ model: str | None = None,
114
+ ) -> str:
115
+ """Run a one-off, non-streaming completion for judging/merging.
116
+
117
+ *pc* is the provider to ask (resolved by the caller so it can report which
118
+ agent judged); omitted means the first enabled one. *model* overrides that
119
+ provider's configured model for this call only.
120
+ """
121
+ if pc is None:
122
+ pc = await _pick_provider(db)
123
+
124
+ if not pc:
125
+ return ""
126
+
127
+ if model:
128
+ from harness_sdk.base import AdapterConfig
129
+
130
+ from harness.adapters.registry import build_adapter_from_config
131
+ from harness.security.secrets import resolve_secret_ref
132
+ adapter = build_adapter_from_config(AdapterConfig(
133
+ name=pc.name,
134
+ kind=pc.kind,
135
+ base_url=pc.base_url,
136
+ model=model,
137
+ api_key=resolve_secret_ref(pc.secret_ref),
138
+ params=pc.params or {},
139
+ capabilities=pc.capabilities or {},
140
+ ))
141
+ else:
142
+ adapter = build_adapter(pc)
143
+ ctx = SessionContext(
144
+ run_id=str(uuid.uuid4()),
145
+ session_id=str(session_id),
146
+ workspace_slug="ephemeral",
147
+ objective="internal_llm_call",
148
+ constraints={},
149
+ memory_context=""
150
+ )
151
+ provider_session = await adapter.start_session(ctx)
152
+ result = ""
153
+ async for ev in adapter.stream_events(provider_session, prompt, stream=False):
154
+ if ev.type == "message":
155
+ result = ev.text
156
+ return result
157
+
158
+
159
+ async def compare_runs(
160
+ db: AsyncSession,
161
+ *,
162
+ run_ids: list[uuid.UUID],
163
+ strategy: str = "judge",
164
+ judge_provider: str | None = None,
165
+ judge_model: str | None = None,
166
+ ) -> Comparison:
167
+ """Score candidate runs and, for ``judge``, ask an LLM to pick a winner.
168
+
169
+ *judge_provider* / *judge_model* choose the agent and model doing the
170
+ judging; both fall back to the first enabled provider on its own model.
171
+ The judge answers in prose (stored as ``result["verdict"]``) so the user
172
+ reads a rationale, not a bare id.
173
+ """
174
+ runs = await _load_runs(db, run_ids)
175
+ if not runs:
176
+ raise ValueError("no runs found for comparison")
177
+ session_id = runs[0].session_id
178
+
179
+ scored = [
180
+ {
181
+ "run_id": str(r.id),
182
+ "provider": _provider_of(r.graph_node_id),
183
+ "provider_role": r.role.value,
184
+ "adapter_kind": r.adapter_kind.value,
185
+ "status": r.status.value,
186
+ "score": round(_quality_score(r), 4),
187
+ "output_preview": (r.final_output or "")[:PREVIEW_CHARS],
188
+ "cost": r.cost or {},
189
+ }
190
+ for r in runs
191
+ ]
192
+
193
+ winner_run_id = None
194
+ verdict = ""
195
+ judge_meta: dict | None = None
196
+ if strategy == "judge":
197
+ pc = await _pick_provider(db, judge_provider)
198
+ judge_meta = {
199
+ "provider": pc.name if pc else None,
200
+ "model": judge_model or (pc.model if pc else None),
201
+ "requested_provider": judge_provider,
202
+ }
203
+ names = ", ".join(s["provider"] for s in scored)
204
+ prompt = (
205
+ "You are judging the outputs of several AI agents that were given the "
206
+ "same task. Decide which one did it best.\n\n"
207
+ "Answer in plain text (light markdown is fine). Structure it exactly like "
208
+ "this:\n"
209
+ f"1. A first line of exactly `WINNER: <name>`, where <name> is one of: {names}.\n"
210
+ "2. A short paragraph explaining why that one won.\n"
211
+ "3. One bullet per agent noting its main strength and weakness.\n\n"
212
+ "Do not output JSON. Do not quote run ids.\n\n"
213
+ )
214
+ for s in scored:
215
+ prompt += (
216
+ f"## Agent: {s['provider']} ({s['adapter_kind']}, status={s['status']})\n"
217
+ f"{s['output_preview'] or '(no output)'}\n\n"
218
+ )
219
+
220
+ verdict = (await _invoke_llm(db, session_id, prompt, pc=pc, model=judge_model)).strip()
221
+ winner_run_id = _winner_from_verdict(verdict, scored)
222
+
223
+ if not winner_run_id and scored:
224
+ # Judge unavailable or unparseable — fall back to the heuristic score.
225
+ winner_run_id = max(scored, key=lambda s: s["score"])["run_id"]
226
+
227
+ winner_provider = next(
228
+ (s["provider"] for s in scored if s["run_id"] == winner_run_id), None
229
+ )
230
+ comparison = Comparison(
231
+ session_id=session_id,
232
+ run_ids=run_ids,
233
+ strategy=ComparisonStrategy(strategy),
234
+ result={
235
+ "candidates": scored,
236
+ "winner_run_id": winner_run_id,
237
+ "winner_provider": winner_provider,
238
+ "verdict": verdict,
239
+ "judge": judge_meta,
240
+ },
241
+ )
242
+ db.add(comparison)
243
+ await db.flush()
244
+
245
+ await write_audit(
246
+ db,
247
+ actor="system",
248
+ action="compare_runs",
249
+ target_type="comparison",
250
+ target_id=str(comparison.id),
251
+ after={
252
+ "strategy": strategy,
253
+ "winner_run_id": winner_run_id,
254
+ "winner_provider": winner_provider,
255
+ "judge": judge_meta,
256
+ }
257
+ )
258
+
259
+ return comparison
260
+
261
+
262
+ async def merge_runs(
263
+ db: AsyncSession,
264
+ *,
265
+ run_ids: list[uuid.UUID],
266
+ persist: bool = True,
267
+ merged_content: str | None = None,
268
+ ) -> Comparison:
269
+ """Merge runs into a canonical answer and optionally persist it as memory."""
270
+ runs = await _load_runs(db, run_ids)
271
+ if not runs:
272
+ raise ValueError("no runs found for merge")
273
+ session = (
274
+ await db.execute(select(Session).where(Session.id == runs[0].session_id))
275
+ ).scalar_one()
276
+ workspace = (
277
+ await db.execute(select(Workspace).where(Workspace.id == session.workspace_id))
278
+ ).scalar_one()
279
+
280
+ if merged_content is None:
281
+ prompt = (
282
+ "You are an AI merging the outputs of several language model runs into a "
283
+ "single, canonical, best answer. Synthesize the following outputs into a "
284
+ "cohesive final answer.\n\n"
285
+ )
286
+ for r in runs:
287
+ if r.final_output:
288
+ prompt += (
289
+ f"### Output from {r.role.value} ({r.adapter_kind.value})\n"
290
+ f"{r.final_output}\n\n"
291
+ )
292
+
293
+ ans = await _invoke_llm(db, session.id, prompt)
294
+
295
+ if ans:
296
+ merged_content = ans.strip()
297
+ else:
298
+ # Fallback
299
+ ranked = sorted(runs, key=_quality_score, reverse=True)
300
+ sections = [
301
+ f"## Canonical answer (from {ranked[0].role.value})",
302
+ ranked[0].final_output or "",
303
+ ]
304
+ for r in ranked[1:]:
305
+ if r.final_output:
306
+ sections.append(f"\n### Alternative ({r.role.value}/{r.adapter_kind.value})")
307
+ sections.append(r.final_output)
308
+ merged_content = "\n".join(sections).strip()
309
+
310
+ memory_item_id: uuid.UUID | None = None
311
+ if persist:
312
+ item = await ingest_memory(
313
+ db,
314
+ namespace=workspace_ns(workspace.slug),
315
+ scope=MemoryScope.workspace,
316
+ mem_type=MemoryType.decision,
317
+ content=merged_content,
318
+ tags=["merged", "canonical", session.canonical_objective[:40]],
319
+ source_run_id=runs[0].id,
320
+ )
321
+ memory_item_id = item.id
322
+
323
+ comparison = Comparison(
324
+ session_id=session.id,
325
+ run_ids=run_ids,
326
+ strategy=ComparisonStrategy.merge,
327
+ result={"merged_content": merged_content, "persisted": persist},
328
+ canonical_memory_item_id=memory_item_id,
329
+ )
330
+ db.add(comparison)
331
+ await db.flush()
332
+
333
+ await write_audit(
334
+ db,
335
+ actor="system",
336
+ action="merge_runs",
337
+ target_type="comparison",
338
+ target_id=str(comparison.id),
339
+ after={
340
+ "persisted": persist,
341
+ "canonical_memory_item_id": str(memory_item_id) if memory_item_id else None,
342
+ },
343
+ )
344
+
345
+ return comparison
@@ -0,0 +1,156 @@
1
+ """Doctor service — end-to-end provider preflight.
2
+
3
+ Iterates all *enabled* ProviderConfig rows and for each produces a health
4
+ report dict:
5
+
6
+ {
7
+ "provider": str, # provider name
8
+ "kind": str, # AdapterKind value string
9
+ "binary": str | None, # resolved binary path (CLI kinds only)
10
+ "version": str | None, # version string from probe (CLI kinds only)
11
+ "health_ok": bool,
12
+ "latency_ms": float,
13
+ "model": str | None,
14
+ "mcp_ok": bool | None, # None for non-CLI kinds
15
+ "native_sessions": bool, # whether decide_resume would enable this
16
+ "problems": list[str], # human-readable problem descriptions
17
+ }
18
+
19
+ Probe/health exceptions are caught and appended as problem entries — this
20
+ function never raises.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import os
27
+
28
+ from harness_sdk.cli_base import resolve_binary
29
+ from sqlalchemy import select
30
+ from sqlalchemy.ext.asyncio import AsyncSession
31
+
32
+ from harness.adapters.probe import probe_cli, supports_flag
33
+ from harness.adapters.registry import build_adapter
34
+ from harness.models.enums import AdapterKind
35
+ from harness.models.provider_config import ProviderConfig
36
+
37
+ # Kinds that are CLI-based (have a binary on PATH, support probe_cli, etc.)
38
+ _CLI_KINDS = {AdapterKind.claude_code, AdapterKind.antigravity}
39
+
40
+ # MCP flag checked per CLI kind
41
+ _MCP_FLAG: dict[AdapterKind, str] = {
42
+ AdapterKind.claude_code: "--mcp-config",
43
+ AdapterKind.antigravity: "--mcp-config",
44
+ }
45
+
46
+ # Conversation / resume flag for antigravity native-sessions detection
47
+ _CONVERSATION_FLAG = "--conversation"
48
+
49
+
50
+ def _resolve_existing(binary_name: str) -> str | None:
51
+ """Resolve a CLI name to an existing executable path (PATH + common install dirs),
52
+ or None if nothing is found. Sync (filesystem checks) so ``_check_provider`` — which
53
+ is async — doesn't call ``os.path`` directly."""
54
+ resolved = resolve_binary(binary_name)
55
+ return resolved if os.path.isfile(resolved) else None
56
+
57
+
58
+ async def doctor_report(db: AsyncSession) -> list[dict]:
59
+ """Run a preflight check on all enabled providers and return a report list."""
60
+ result = await db.execute(
61
+ select(ProviderConfig)
62
+ .where(ProviderConfig.enabled.is_(True))
63
+ .order_by(ProviderConfig.name)
64
+ )
65
+ configs = list(result.scalars())
66
+
67
+ results = await asyncio.gather(*[_check_provider(pc) for pc in configs])
68
+ return list(results)
69
+
70
+
71
+ async def _check_provider(pc: ProviderConfig) -> dict:
72
+ """Probe a single provider config and return its report dict."""
73
+ problems: list[str] = []
74
+ is_cli = pc.kind in _CLI_KINDS
75
+
76
+ # --- Binary resolution (CLI kinds only) ---
77
+ binary_path: str | None = None
78
+ version: str | None = None
79
+ mcp_ok: bool | None = None
80
+ probe: dict | None = None
81
+
82
+ if is_cli:
83
+ # Determine the binary name from params or fallback default
84
+ if pc.kind == AdapterKind.claude_code:
85
+ binary_name = (pc.params or {}).get("bin", "claude")
86
+ else: # antigravity
87
+ p = pc.params or {}
88
+ binary_name = p.get("bin") or p.get("cmd") or "agy"
89
+
90
+ # Resolve via PATH + common install dirs (matches the spawn path), so an
91
+ # installed-but-off-PATH CLI isn't wrongly reported as missing.
92
+ binary_path = _resolve_existing(binary_name)
93
+ if binary_path is None:
94
+ problems.append(f"binary not on PATH or common install dirs: {binary_name!r}")
95
+
96
+ # Run CLI probe (fast; handles missing binary gracefully)
97
+ try:
98
+ probe = await probe_cli(binary_name)
99
+ version = probe.get("version")
100
+ except Exception as exc: # noqa: BLE001
101
+ problems.append(f"probe failed: {exc}")
102
+ probe = {"ok": False, "flags": set(), "version": None}
103
+
104
+ # MCP flag support check
105
+ mcp_flag = _MCP_FLAG.get(pc.kind, "--mcp-config")
106
+ if probe is not None and probe.get("ok"):
107
+ mcp_ok = supports_flag(probe, mcp_flag)
108
+ if not mcp_ok:
109
+ problems.append("MCP flag unsupported — servers will be skipped")
110
+ else:
111
+ mcp_ok = False
112
+
113
+ # --- Health check ---
114
+ health_ok = False
115
+ latency_ms = 0.0
116
+ try:
117
+ adapter = build_adapter(pc)
118
+ health = await adapter.health_check()
119
+ health_ok = health.ok
120
+ latency_ms = health.latency_ms
121
+ if not health.ok:
122
+ detail = health.detail or {}
123
+ problems.append(f"health check failed: {detail}")
124
+ except Exception as exc: # noqa: BLE001
125
+ problems.append(f"health check error: {exc}")
126
+
127
+ # --- Native-sessions eligibility ---
128
+ # claude_code: enabled by default unless params explicitly disable it
129
+ # antigravity: enabled only when probe found --conversation flag or params enable it
130
+ # others: False
131
+ explicit = (pc.params or {}).get("native_sessions")
132
+ if explicit is not None:
133
+ native_sessions = bool(explicit)
134
+ elif pc.kind == AdapterKind.claude_code:
135
+ native_sessions = True
136
+ elif pc.kind == AdapterKind.antigravity:
137
+ probed_flags = (probe or {}).get("flags", set()) if probe else None
138
+ native_sessions = (
139
+ probed_flags is not None
140
+ and _CONVERSATION_FLAG in probed_flags
141
+ )
142
+ else:
143
+ native_sessions = False
144
+
145
+ return {
146
+ "provider": pc.name,
147
+ "kind": pc.kind.value,
148
+ "binary": binary_path,
149
+ "version": version,
150
+ "health_ok": health_ok,
151
+ "latency_ms": latency_ms,
152
+ "model": pc.model,
153
+ "mcp_ok": mcp_ok,
154
+ "native_sessions": native_sessions,
155
+ "problems": problems,
156
+ }