spec-kitty-cli 0.12.1__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 (242) hide show
  1. spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
  2. spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
  3. spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
  4. spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
  5. spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
  6. specify_cli/__init__.py +171 -0
  7. specify_cli/acceptance.py +627 -0
  8. specify_cli/agent_utils/README.md +157 -0
  9. specify_cli/agent_utils/__init__.py +9 -0
  10. specify_cli/agent_utils/status.py +356 -0
  11. specify_cli/cli/__init__.py +6 -0
  12. specify_cli/cli/commands/__init__.py +46 -0
  13. specify_cli/cli/commands/accept.py +189 -0
  14. specify_cli/cli/commands/agent/__init__.py +22 -0
  15. specify_cli/cli/commands/agent/config.py +382 -0
  16. specify_cli/cli/commands/agent/context.py +191 -0
  17. specify_cli/cli/commands/agent/feature.py +1057 -0
  18. specify_cli/cli/commands/agent/release.py +11 -0
  19. specify_cli/cli/commands/agent/tasks.py +1253 -0
  20. specify_cli/cli/commands/agent/workflow.py +801 -0
  21. specify_cli/cli/commands/context.py +246 -0
  22. specify_cli/cli/commands/dashboard.py +85 -0
  23. specify_cli/cli/commands/implement.py +973 -0
  24. specify_cli/cli/commands/init.py +827 -0
  25. specify_cli/cli/commands/init_help.py +62 -0
  26. specify_cli/cli/commands/merge.py +755 -0
  27. specify_cli/cli/commands/mission.py +240 -0
  28. specify_cli/cli/commands/ops.py +265 -0
  29. specify_cli/cli/commands/orchestrate.py +640 -0
  30. specify_cli/cli/commands/repair.py +175 -0
  31. specify_cli/cli/commands/research.py +165 -0
  32. specify_cli/cli/commands/sync.py +364 -0
  33. specify_cli/cli/commands/upgrade.py +249 -0
  34. specify_cli/cli/commands/validate_encoding.py +186 -0
  35. specify_cli/cli/commands/validate_tasks.py +186 -0
  36. specify_cli/cli/commands/verify.py +310 -0
  37. specify_cli/cli/helpers.py +123 -0
  38. specify_cli/cli/step_tracker.py +91 -0
  39. specify_cli/cli/ui.py +192 -0
  40. specify_cli/core/__init__.py +53 -0
  41. specify_cli/core/agent_context.py +311 -0
  42. specify_cli/core/config.py +96 -0
  43. specify_cli/core/context_validation.py +362 -0
  44. specify_cli/core/dependency_graph.py +351 -0
  45. specify_cli/core/git_ops.py +129 -0
  46. specify_cli/core/multi_parent_merge.py +323 -0
  47. specify_cli/core/paths.py +260 -0
  48. specify_cli/core/project_resolver.py +110 -0
  49. specify_cli/core/stale_detection.py +263 -0
  50. specify_cli/core/tool_checker.py +79 -0
  51. specify_cli/core/utils.py +43 -0
  52. specify_cli/core/vcs/__init__.py +114 -0
  53. specify_cli/core/vcs/detection.py +341 -0
  54. specify_cli/core/vcs/exceptions.py +85 -0
  55. specify_cli/core/vcs/git.py +1304 -0
  56. specify_cli/core/vcs/jujutsu.py +1208 -0
  57. specify_cli/core/vcs/protocol.py +285 -0
  58. specify_cli/core/vcs/types.py +249 -0
  59. specify_cli/core/version_checker.py +261 -0
  60. specify_cli/core/worktree.py +506 -0
  61. specify_cli/dashboard/__init__.py +28 -0
  62. specify_cli/dashboard/diagnostics.py +204 -0
  63. specify_cli/dashboard/handlers/__init__.py +17 -0
  64. specify_cli/dashboard/handlers/api.py +143 -0
  65. specify_cli/dashboard/handlers/base.py +65 -0
  66. specify_cli/dashboard/handlers/features.py +390 -0
  67. specify_cli/dashboard/handlers/router.py +81 -0
  68. specify_cli/dashboard/handlers/static.py +50 -0
  69. specify_cli/dashboard/lifecycle.py +541 -0
  70. specify_cli/dashboard/scanner.py +437 -0
  71. specify_cli/dashboard/server.py +123 -0
  72. specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
  73. specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
  74. specify_cli/dashboard/static/spec-kitty.png +0 -0
  75. specify_cli/dashboard/templates/__init__.py +36 -0
  76. specify_cli/dashboard/templates/index.html +258 -0
  77. specify_cli/doc_generators.py +621 -0
  78. specify_cli/doc_state.py +408 -0
  79. specify_cli/frontmatter.py +384 -0
  80. specify_cli/gap_analysis.py +915 -0
  81. specify_cli/gitignore_manager.py +300 -0
  82. specify_cli/guards.py +145 -0
  83. specify_cli/legacy_detector.py +83 -0
  84. specify_cli/manifest.py +286 -0
  85. specify_cli/merge/__init__.py +63 -0
  86. specify_cli/merge/executor.py +653 -0
  87. specify_cli/merge/forecast.py +215 -0
  88. specify_cli/merge/ordering.py +126 -0
  89. specify_cli/merge/preflight.py +230 -0
  90. specify_cli/merge/state.py +185 -0
  91. specify_cli/merge/status_resolver.py +354 -0
  92. specify_cli/mission.py +654 -0
  93. specify_cli/missions/documentation/command-templates/implement.md +309 -0
  94. specify_cli/missions/documentation/command-templates/plan.md +275 -0
  95. specify_cli/missions/documentation/command-templates/review.md +344 -0
  96. specify_cli/missions/documentation/command-templates/specify.md +206 -0
  97. specify_cli/missions/documentation/command-templates/tasks.md +189 -0
  98. specify_cli/missions/documentation/mission.yaml +113 -0
  99. specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
  100. specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
  101. specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
  102. specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
  103. specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
  104. specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
  105. specify_cli/missions/documentation/templates/plan-template.md +269 -0
  106. specify_cli/missions/documentation/templates/release-template.md +222 -0
  107. specify_cli/missions/documentation/templates/spec-template.md +172 -0
  108. specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
  109. specify_cli/missions/documentation/templates/tasks-template.md +159 -0
  110. specify_cli/missions/research/command-templates/merge.md +388 -0
  111. specify_cli/missions/research/command-templates/plan.md +125 -0
  112. specify_cli/missions/research/command-templates/review.md +144 -0
  113. specify_cli/missions/research/command-templates/tasks.md +225 -0
  114. specify_cli/missions/research/mission.yaml +115 -0
  115. specify_cli/missions/research/templates/data-model-template.md +33 -0
  116. specify_cli/missions/research/templates/plan-template.md +161 -0
  117. specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
  118. specify_cli/missions/research/templates/research/source-register.csv +18 -0
  119. specify_cli/missions/research/templates/research-template.md +35 -0
  120. specify_cli/missions/research/templates/spec-template.md +64 -0
  121. specify_cli/missions/research/templates/task-prompt-template.md +148 -0
  122. specify_cli/missions/research/templates/tasks-template.md +114 -0
  123. specify_cli/missions/software-dev/command-templates/accept.md +75 -0
  124. specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
  125. specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
  126. specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
  127. specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
  128. specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
  129. specify_cli/missions/software-dev/command-templates/implement.md +41 -0
  130. specify_cli/missions/software-dev/command-templates/merge.md +383 -0
  131. specify_cli/missions/software-dev/command-templates/plan.md +171 -0
  132. specify_cli/missions/software-dev/command-templates/review.md +32 -0
  133. specify_cli/missions/software-dev/command-templates/specify.md +321 -0
  134. specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
  135. specify_cli/missions/software-dev/mission.yaml +100 -0
  136. specify_cli/missions/software-dev/templates/plan-template.md +132 -0
  137. specify_cli/missions/software-dev/templates/spec-template.md +116 -0
  138. specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
  139. specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
  140. specify_cli/orchestrator/__init__.py +75 -0
  141. specify_cli/orchestrator/agent_config.py +224 -0
  142. specify_cli/orchestrator/agents/__init__.py +170 -0
  143. specify_cli/orchestrator/agents/augment.py +112 -0
  144. specify_cli/orchestrator/agents/base.py +243 -0
  145. specify_cli/orchestrator/agents/claude.py +112 -0
  146. specify_cli/orchestrator/agents/codex.py +106 -0
  147. specify_cli/orchestrator/agents/copilot.py +137 -0
  148. specify_cli/orchestrator/agents/cursor.py +139 -0
  149. specify_cli/orchestrator/agents/gemini.py +115 -0
  150. specify_cli/orchestrator/agents/kilocode.py +94 -0
  151. specify_cli/orchestrator/agents/opencode.py +132 -0
  152. specify_cli/orchestrator/agents/qwen.py +96 -0
  153. specify_cli/orchestrator/config.py +455 -0
  154. specify_cli/orchestrator/executor.py +642 -0
  155. specify_cli/orchestrator/integration.py +1230 -0
  156. specify_cli/orchestrator/monitor.py +898 -0
  157. specify_cli/orchestrator/scheduler.py +832 -0
  158. specify_cli/orchestrator/state.py +508 -0
  159. specify_cli/orchestrator/testing/__init__.py +122 -0
  160. specify_cli/orchestrator/testing/availability.py +346 -0
  161. specify_cli/orchestrator/testing/fixtures.py +684 -0
  162. specify_cli/orchestrator/testing/paths.py +218 -0
  163. specify_cli/plan_validation.py +107 -0
  164. specify_cli/scripts/debug-dashboard-scan.py +61 -0
  165. specify_cli/scripts/tasks/acceptance_support.py +695 -0
  166. specify_cli/scripts/tasks/task_helpers.py +506 -0
  167. specify_cli/scripts/tasks/tasks_cli.py +848 -0
  168. specify_cli/scripts/validate_encoding.py +180 -0
  169. specify_cli/task_metadata_validation.py +274 -0
  170. specify_cli/tasks_support.py +447 -0
  171. specify_cli/template/__init__.py +47 -0
  172. specify_cli/template/asset_generator.py +206 -0
  173. specify_cli/template/github_client.py +334 -0
  174. specify_cli/template/manager.py +193 -0
  175. specify_cli/template/renderer.py +99 -0
  176. specify_cli/templates/AGENTS.md +190 -0
  177. specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
  178. specify_cli/templates/agent-file-template.md +35 -0
  179. specify_cli/templates/checklist-template.md +42 -0
  180. specify_cli/templates/claudeignore-template +58 -0
  181. specify_cli/templates/command-templates/accept.md +141 -0
  182. specify_cli/templates/command-templates/analyze.md +253 -0
  183. specify_cli/templates/command-templates/checklist.md +352 -0
  184. specify_cli/templates/command-templates/clarify.md +224 -0
  185. specify_cli/templates/command-templates/constitution.md +432 -0
  186. specify_cli/templates/command-templates/dashboard.md +175 -0
  187. specify_cli/templates/command-templates/implement.md +190 -0
  188. specify_cli/templates/command-templates/merge.md +374 -0
  189. specify_cli/templates/command-templates/plan.md +171 -0
  190. specify_cli/templates/command-templates/research.md +88 -0
  191. specify_cli/templates/command-templates/review.md +510 -0
  192. specify_cli/templates/command-templates/specify.md +321 -0
  193. specify_cli/templates/command-templates/status.md +92 -0
  194. specify_cli/templates/command-templates/tasks.md +199 -0
  195. specify_cli/templates/git-hooks/pre-commit +22 -0
  196. specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
  197. specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
  198. specify_cli/templates/plan-template.md +108 -0
  199. specify_cli/templates/spec-template.md +118 -0
  200. specify_cli/templates/task-prompt-template.md +165 -0
  201. specify_cli/templates/tasks-template.md +161 -0
  202. specify_cli/templates/vscode-settings.json +13 -0
  203. specify_cli/text_sanitization.py +225 -0
  204. specify_cli/upgrade/__init__.py +18 -0
  205. specify_cli/upgrade/detector.py +239 -0
  206. specify_cli/upgrade/metadata.py +182 -0
  207. specify_cli/upgrade/migrations/__init__.py +65 -0
  208. specify_cli/upgrade/migrations/base.py +80 -0
  209. specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
  210. specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
  211. specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
  212. specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
  213. specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
  214. specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
  215. specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
  216. specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
  217. specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
  218. specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
  219. specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
  220. specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
  221. specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
  222. specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
  223. specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
  224. specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
  225. specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
  226. specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
  227. specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
  228. specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
  229. specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
  230. specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
  231. specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
  232. specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
  233. specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
  234. specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
  235. specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
  236. specify_cli/upgrade/registry.py +121 -0
  237. specify_cli/upgrade/runner.py +284 -0
  238. specify_cli/validators/__init__.py +14 -0
  239. specify_cli/validators/paths.py +154 -0
  240. specify_cli/validators/research.py +428 -0
  241. specify_cli/verify_enhanced.py +270 -0
  242. specify_cli/workspace_context.py +224 -0
@@ -0,0 +1,346 @@
1
+ """Agent availability detection for orchestrator e2e tests.
2
+
3
+ This module provides functions to detect which AI coding agents are installed
4
+ and authenticated on the system. Results are categorized into tiers:
5
+
6
+ - Core tier (5 agents): Tests FAIL if unavailable
7
+ claude, codex, copilot, gemini, opencode
8
+
9
+ - Extended tier (7 agents): Tests SKIP if unavailable
10
+ cursor, qwen, augment, kilocode, roo, windsurf, amazonq
11
+
12
+ Example usage:
13
+ from specify_cli.orchestrator.testing.availability import (
14
+ detect_all_agents,
15
+ get_available_agents,
16
+ CORE_AGENTS,
17
+ )
18
+
19
+ # Detect all agents (cached for session)
20
+ agents = await detect_all_agents()
21
+
22
+ # Get list of available agent IDs
23
+ available = get_available_agents()
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import os
30
+ import shutil
31
+ import time
32
+ from dataclasses import dataclass
33
+ from typing import Literal
34
+
35
+ # Agent tier constants
36
+ # Core tier: Tests fail if these are unavailable
37
+ CORE_AGENTS = frozenset({"claude", "codex", "copilot", "gemini", "opencode"})
38
+
39
+ # Extended tier: Tests skip if these are unavailable
40
+ EXTENDED_AGENTS = frozenset({
41
+ "cursor", "qwen", "augment", "kilocode", "roo", "windsurf", "amazonq"
42
+ })
43
+
44
+ # All supported agents
45
+ ALL_AGENTS = CORE_AGENTS | EXTENDED_AGENTS
46
+
47
+ # Mapping from canonical agent IDs to orchestrator registry IDs
48
+ # The orchestrator uses "claude-code" but testing uses "claude"
49
+ AGENT_ID_TO_REGISTRY: dict[str, str] = {
50
+ "claude": "claude-code",
51
+ "codex": "codex",
52
+ "copilot": "copilot",
53
+ "gemini": "gemini",
54
+ "opencode": "opencode",
55
+ "cursor": "cursor",
56
+ "qwen": "qwen",
57
+ "augment": "augment",
58
+ "kilocode": "kilocode",
59
+ # These 3 don't have invokers yet (TODO: add when implemented)
60
+ "roo": None,
61
+ "windsurf": None,
62
+ "amazonq": None,
63
+ }
64
+
65
+ # Probe timeout in seconds (configurable via environment)
66
+ PROBE_TIMEOUT = int(os.environ.get("ORCHESTRATOR_PROBE_TIMEOUT", "10"))
67
+
68
+
69
+ @dataclass
70
+ class AgentAvailability:
71
+ """Result of detecting an agent's availability for testing.
72
+
73
+ Attributes:
74
+ agent_id: Canonical agent identifier (e.g., 'claude', 'codex')
75
+ is_installed: True if the agent CLI binary exists and is executable
76
+ is_authenticated: True if the agent responded to a probe API call
77
+ tier: Agent tier ('core' or 'extended')
78
+ failure_reason: Human-readable reason if unavailable
79
+ probe_duration_ms: Time taken for auth probe in milliseconds
80
+ """
81
+
82
+ agent_id: str
83
+ is_installed: bool
84
+ is_authenticated: bool
85
+ tier: Literal["core", "extended"]
86
+ failure_reason: str | None = None
87
+ probe_duration_ms: int | None = None
88
+
89
+ @property
90
+ def is_available(self) -> bool:
91
+ """Agent is available if installed and authenticated."""
92
+ return self.is_installed and self.is_authenticated
93
+
94
+ @classmethod
95
+ def get_tier(cls, agent_id: str) -> Literal["core", "extended"]:
96
+ """Determine tier for an agent ID.
97
+
98
+ Args:
99
+ agent_id: Canonical agent identifier
100
+
101
+ Returns:
102
+ 'core' if agent is in core tier, 'extended' otherwise
103
+ """
104
+ if agent_id in CORE_AGENTS:
105
+ return "core"
106
+ return "extended"
107
+
108
+
109
+ def _get_invoker_class(agent_id: str):
110
+ """Get the invoker class for an agent ID.
111
+
112
+ Args:
113
+ agent_id: Canonical agent identifier
114
+
115
+ Returns:
116
+ Invoker class or None if not available
117
+ """
118
+ # Map canonical ID to registry ID
119
+ registry_id = AGENT_ID_TO_REGISTRY.get(agent_id)
120
+ if registry_id is None:
121
+ return None
122
+
123
+ # Import registry to avoid circular imports
124
+ from specify_cli.orchestrator.agents import AGENT_REGISTRY
125
+
126
+ return AGENT_REGISTRY.get(registry_id)
127
+
128
+
129
+ def check_installed(agent_id: str) -> tuple[bool, str | None]:
130
+ """Check if an agent CLI is installed.
131
+
132
+ Uses shutil.which() to check if the agent's command is in PATH.
133
+
134
+ Args:
135
+ agent_id: Canonical agent identifier (e.g., 'claude', 'codex')
136
+
137
+ Returns:
138
+ Tuple of (is_installed, failure_reason)
139
+ - is_installed: True if CLI is found in PATH
140
+ - failure_reason: Human-readable reason if not installed, None otherwise
141
+ """
142
+ # Check if we have a mapping for this agent
143
+ registry_id = AGENT_ID_TO_REGISTRY.get(agent_id)
144
+ if registry_id is None:
145
+ # Agent doesn't have an invoker yet
146
+ return False, f"No invoker implemented for agent: {agent_id}"
147
+
148
+ # Get invoker class
149
+ invoker_class = _get_invoker_class(agent_id)
150
+ if invoker_class is None:
151
+ return False, f"Unknown agent: {agent_id}"
152
+
153
+ # Get the command from the invoker
154
+ command = getattr(invoker_class, "command", None)
155
+ if command is None:
156
+ return False, f"Agent {agent_id} has no command attribute"
157
+
158
+ # Check if command exists in PATH
159
+ if shutil.which(command) is not None:
160
+ return True, None
161
+
162
+ return False, f"CLI not found: {command}"
163
+
164
+
165
+ async def probe_agent_auth(agent_id: str) -> tuple[bool, str | None, int]:
166
+ """Probe an agent to verify authentication.
167
+
168
+ Makes a minimal API call to verify the agent can communicate.
169
+ For agents without a probe() method, assumes authenticated if installed.
170
+
171
+ Args:
172
+ agent_id: Canonical agent identifier
173
+
174
+ Returns:
175
+ Tuple of (is_authenticated, failure_reason, duration_ms)
176
+ - is_authenticated: True if probe succeeded
177
+ - failure_reason: Human-readable reason if probe failed
178
+ - duration_ms: Time taken for probe in milliseconds
179
+ """
180
+ invoker_class = _get_invoker_class(agent_id)
181
+ if invoker_class is None:
182
+ return False, f"Unknown agent: {agent_id}", 0
183
+
184
+ start_time = time.monotonic()
185
+
186
+ try:
187
+ # Create invoker instance
188
+ invoker = invoker_class()
189
+
190
+ # Check if invoker has a probe() method
191
+ if hasattr(invoker, "probe") and callable(getattr(invoker, "probe")):
192
+ # Call probe method with timeout
193
+ result = await asyncio.wait_for(
194
+ invoker.probe(),
195
+ timeout=PROBE_TIMEOUT
196
+ )
197
+ duration_ms = int((time.monotonic() - start_time) * 1000)
198
+
199
+ if result:
200
+ return True, None, duration_ms
201
+ else:
202
+ return False, "Probe returned failure", duration_ms
203
+ else:
204
+ # No probe method - assume authenticated if installed
205
+ duration_ms = int((time.monotonic() - start_time) * 1000)
206
+ return True, None, duration_ms
207
+
208
+ except asyncio.TimeoutError:
209
+ duration_ms = int((time.monotonic() - start_time) * 1000)
210
+ return False, f"Probe timed out after {PROBE_TIMEOUT}s", duration_ms
211
+ except Exception as e:
212
+ duration_ms = int((time.monotonic() - start_time) * 1000)
213
+ return False, f"Probe error: {str(e)}", duration_ms
214
+
215
+
216
+ # Module-level cache for agent detection results
217
+ _agent_cache: dict[str, AgentAvailability] | None = None
218
+
219
+
220
+ def clear_agent_cache() -> None:
221
+ """Clear the cached agent detection results.
222
+
223
+ Call this to force re-detection on the next detect_all_agents() call.
224
+ Useful for testing or when agent availability may have changed.
225
+ """
226
+ global _agent_cache
227
+ _agent_cache = None
228
+
229
+
230
+ async def detect_agent(agent_id: str) -> AgentAvailability:
231
+ """Detect availability of a single agent.
232
+
233
+ Checks both installation (CLI in PATH) and authentication (probe API call).
234
+
235
+ Args:
236
+ agent_id: Canonical agent identifier (e.g., 'claude', 'codex')
237
+
238
+ Returns:
239
+ AgentAvailability with detection results
240
+ """
241
+ tier = AgentAvailability.get_tier(agent_id)
242
+
243
+ # Check installation
244
+ is_installed, install_reason = check_installed(agent_id)
245
+
246
+ if not is_installed:
247
+ return AgentAvailability(
248
+ agent_id=agent_id,
249
+ is_installed=False,
250
+ is_authenticated=False,
251
+ tier=tier,
252
+ failure_reason=install_reason,
253
+ )
254
+
255
+ # Probe authentication
256
+ is_authenticated, auth_reason, duration_ms = await probe_agent_auth(agent_id)
257
+
258
+ return AgentAvailability(
259
+ agent_id=agent_id,
260
+ is_installed=True,
261
+ is_authenticated=is_authenticated,
262
+ tier=tier,
263
+ failure_reason=auth_reason,
264
+ probe_duration_ms=duration_ms,
265
+ )
266
+
267
+
268
+ async def detect_all_agents() -> dict[str, AgentAvailability]:
269
+ """Detect availability of all supported agents.
270
+
271
+ Results are cached for the session duration. Call clear_agent_cache()
272
+ to force re-detection.
273
+
274
+ Returns:
275
+ Dict mapping agent_id to AgentAvailability for all 12 agents,
276
+ sorted alphabetically by agent_id
277
+ """
278
+ global _agent_cache
279
+
280
+ if _agent_cache is not None:
281
+ return _agent_cache
282
+
283
+ results = {}
284
+ for agent_id in sorted(ALL_AGENTS):
285
+ results[agent_id] = await detect_agent(agent_id)
286
+
287
+ _agent_cache = results
288
+ return results
289
+
290
+
291
+ def get_available_agents() -> list[str]:
292
+ """Get list of available (installed + authenticated) agent IDs.
293
+
294
+ Returns agent IDs sorted alphabetically.
295
+
296
+ Returns:
297
+ List of available agent IDs
298
+
299
+ Raises:
300
+ RuntimeError: If detect_all_agents() has not been called yet
301
+ """
302
+ if _agent_cache is None:
303
+ raise RuntimeError(
304
+ "Call detect_all_agents() first before using get_available_agents()"
305
+ )
306
+
307
+ return sorted([
308
+ agent_id for agent_id, avail in _agent_cache.items()
309
+ if avail.is_available
310
+ ])
311
+
312
+
313
+ def get_core_agents_available() -> list[str]:
314
+ """Get list of available core tier agents.
315
+
316
+ Returns:
317
+ List of available core agent IDs, sorted alphabetically
318
+
319
+ Raises:
320
+ RuntimeError: If detect_all_agents() has not been called yet
321
+ """
322
+ if _agent_cache is None:
323
+ raise RuntimeError("Call detect_all_agents() first")
324
+
325
+ return sorted([
326
+ agent_id for agent_id, avail in _agent_cache.items()
327
+ if avail.is_available and agent_id in CORE_AGENTS
328
+ ])
329
+
330
+
331
+ def get_extended_agents_available() -> list[str]:
332
+ """Get list of available extended tier agents.
333
+
334
+ Returns:
335
+ List of available extended agent IDs, sorted alphabetically
336
+
337
+ Raises:
338
+ RuntimeError: If detect_all_agents() has not been called yet
339
+ """
340
+ if _agent_cache is None:
341
+ raise RuntimeError("Call detect_all_agents() first")
342
+
343
+ return sorted([
344
+ agent_id for agent_id, avail in _agent_cache.items()
345
+ if avail.is_available and agent_id in EXTENDED_AGENTS
346
+ ])