cli_adapter.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811
  1. """
  2. CLI Adapter for Multi-Platform Support.
  3. Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Windsurf, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces.
  4. Supported platforms:
  5. - claude: Claude Code (default)
  6. - opencode: OpenCode
  7. - cursor: Cursor IDE
  8. - iflow: iFlow CLI
  9. - codex: Codex CLI (skills-based)
  10. - kilo: Kilo CLI
  11. - kiro: Kiro Code (skills-based)
  12. - gemini: Gemini CLI
  13. - antigravity: Antigravity (workflow-based)
  14. - windsurf: Windsurf (workflow-based)
  15. - qoder: Qoder
  16. - codebuddy: CodeBuddy
  17. - copilot: GitHub Copilot (VS Code)
  18. - droid: Factory Droid (commands-based)
  19. - pi: Pi Agent (extension-backed)
  20. Usage:
  21. from common.cli_adapter import CLIAdapter
  22. adapter = CLIAdapter("opencode")
  23. cmd = adapter.build_run_command(
  24. agent="dispatch",
  25. session_id="abc123",
  26. prompt="Start the pipeline"
  27. )
  28. """
  29. from __future__ import annotations
  30. from dataclasses import dataclass
  31. from pathlib import Path
  32. from typing import ClassVar, Literal
  33. Platform = Literal[
  34. "claude",
  35. "opencode",
  36. "cursor",
  37. "iflow",
  38. "codex",
  39. "kilo",
  40. "kiro",
  41. "gemini",
  42. "antigravity",
  43. "windsurf",
  44. "qoder",
  45. "codebuddy",
  46. "copilot",
  47. "droid",
  48. "pi",
  49. ]
  50. @dataclass
  51. class CLIAdapter:
  52. """Adapter for different AI coding CLI tools."""
  53. platform: Platform
  54. # =========================================================================
  55. # Agent Name Mapping
  56. # =========================================================================
  57. # OpenCode has built-in agents that cannot be overridden
  58. # See: https://github.com/sst/opencode/issues/4271
  59. # Note: Class-level constant, not a dataclass field
  60. _AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = {
  61. "claude": {}, # No mapping needed
  62. "opencode": {
  63. "plan": "trellis-plan", # 'plan' is built-in in OpenCode
  64. },
  65. }
  66. def get_agent_name(self, agent: str) -> str:
  67. """Get platform-specific agent name.
  68. Args:
  69. agent: Original agent name (e.g., 'plan', 'dispatch')
  70. Returns:
  71. Platform-specific agent name (e.g., 'trellis-plan' for OpenCode)
  72. """
  73. mapping = self._AGENT_NAME_MAP.get(self.platform, {})
  74. return mapping.get(agent, agent)
  75. # =========================================================================
  76. # Agent Path
  77. # =========================================================================
  78. @property
  79. def config_dir_name(self) -> str:
  80. """Get platform-specific config directory name.
  81. Returns:
  82. Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.windsurf', '.qoder', '.codebuddy', '.github/copilot', '.factory', or '.pi')
  83. """
  84. if self.platform == "opencode":
  85. return ".opencode"
  86. elif self.platform == "cursor":
  87. return ".cursor"
  88. elif self.platform == "iflow":
  89. return ".iflow"
  90. elif self.platform == "codex":
  91. return ".codex"
  92. elif self.platform == "kilo":
  93. return ".kilocode"
  94. elif self.platform == "kiro":
  95. return ".kiro"
  96. elif self.platform == "gemini":
  97. return ".gemini"
  98. elif self.platform == "antigravity":
  99. return ".agent"
  100. elif self.platform == "windsurf":
  101. return ".windsurf"
  102. elif self.platform == "qoder":
  103. return ".qoder"
  104. elif self.platform == "codebuddy":
  105. return ".codebuddy"
  106. elif self.platform == "copilot":
  107. return ".github/copilot"
  108. elif self.platform == "droid":
  109. return ".factory"
  110. elif self.platform == "pi":
  111. return ".pi"
  112. else:
  113. return ".claude"
  114. def get_config_dir(self, project_root: Path) -> Path:
  115. """Get platform-specific config directory.
  116. Args:
  117. project_root: Project root directory
  118. Returns:
  119. Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .windsurf, .qoder, .codebuddy, .github/copilot, .factory, or .pi)
  120. """
  121. return project_root / self.config_dir_name
  122. def get_agent_path(self, agent: str, project_root: Path) -> Path:
  123. """Get path to agent definition file.
  124. Args:
  125. agent: Agent name (original, before mapping)
  126. project_root: Project root directory
  127. Returns:
  128. Path to agent definition file (.md for most platforms, .toml for Codex)
  129. """
  130. mapped_name = self.get_agent_name(agent)
  131. if self.platform == "codex":
  132. return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml"
  133. return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md"
  134. def get_commands_path(self, project_root: Path, *parts: str) -> Path:
  135. """Get path to commands directory or specific command file.
  136. Args:
  137. project_root: Project root directory
  138. *parts: Additional path parts (e.g., 'trellis', 'finish-work.md')
  139. Returns:
  140. Path to commands directory or file
  141. Note:
  142. Cursor uses prefix naming: .cursor/commands/trellis-<name>.md
  143. Antigravity uses workflow directory: .agent/workflows/<name>.md
  144. Windsurf uses workflow directory: .windsurf/workflows/trellis-<name>.md
  145. Copilot uses prompt files: .github/prompts/<name>.prompt.md
  146. Pi uses prompt templates: .pi/prompts/trellis-<name>.md
  147. Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md
  148. """
  149. if self.platform == "pi":
  150. prompts_dir = self.get_config_dir(project_root) / "prompts"
  151. if not parts:
  152. return prompts_dir
  153. if len(parts) >= 2 and parts[0] == "trellis":
  154. filename = parts[-1]
  155. if filename.endswith(".md"):
  156. filename = filename[:-3]
  157. return prompts_dir / f"trellis-{filename}.md"
  158. return prompts_dir / Path(*parts)
  159. if self.platform == "windsurf":
  160. workflow_dir = self.get_config_dir(project_root) / "workflows"
  161. if not parts:
  162. return workflow_dir
  163. if len(parts) >= 2 and parts[0] == "trellis":
  164. filename = parts[-1]
  165. return workflow_dir / f"trellis-{filename}"
  166. return workflow_dir / Path(*parts)
  167. if self.platform in ("antigravity", "kilo"):
  168. workflow_dir = self.get_config_dir(project_root) / "workflows"
  169. if not parts:
  170. return workflow_dir
  171. if len(parts) >= 2 and parts[0] == "trellis":
  172. filename = parts[-1]
  173. return workflow_dir / filename
  174. return workflow_dir / Path(*parts)
  175. if self.platform == "copilot":
  176. prompts_dir = project_root / ".github" / "prompts"
  177. if not parts:
  178. return prompts_dir
  179. if len(parts) >= 2 and parts[0] == "trellis":
  180. filename = parts[-1]
  181. if filename.endswith(".md"):
  182. filename = filename[:-3]
  183. return prompts_dir / f"{filename}.prompt.md"
  184. return prompts_dir / Path(*parts)
  185. if not parts:
  186. return self.get_config_dir(project_root) / "commands"
  187. # Cursor uses prefix naming instead of subdirectory
  188. if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis":
  189. # Convert trellis/<name>.md to trellis-<name>.md
  190. filename = parts[-1]
  191. return (
  192. self.get_config_dir(project_root) / "commands" / f"trellis-{filename}"
  193. )
  194. return self.get_config_dir(project_root) / "commands" / Path(*parts)
  195. def get_trellis_command_path(self, name: str) -> str:
  196. """Get relative path to a trellis command file.
  197. Args:
  198. name: Command name without extension (e.g., 'finish-work', 'check')
  199. Returns:
  200. Relative path string for use in JSONL entries
  201. Note:
  202. Cursor: .cursor/commands/trellis-<name>.md
  203. Codex: .agents/skills/trellis-<name>/SKILL.md
  204. Kiro: .kiro/skills/trellis-<name>/SKILL.md
  205. Gemini: .gemini/commands/trellis/<name>.toml
  206. Antigravity: .agent/workflows/<name>.md
  207. Windsurf: .windsurf/workflows/trellis-<name>.md
  208. Pi: .pi/prompts/trellis-<name>.md
  209. Others: .{platform}/commands/trellis/<name>.md
  210. """
  211. if self.platform == "cursor":
  212. return f".cursor/commands/trellis-{name}.md"
  213. elif self.platform == "codex":
  214. # 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix
  215. # (see that release's manifest for the 60+ rename entries).
  216. return f".agents/skills/trellis-{name}/SKILL.md"
  217. elif self.platform == "kiro":
  218. return f".kiro/skills/trellis-{name}/SKILL.md"
  219. elif self.platform == "gemini":
  220. return f".gemini/commands/trellis/{name}.toml"
  221. elif self.platform == "antigravity":
  222. return f".agent/workflows/{name}.md"
  223. elif self.platform == "windsurf":
  224. return f".windsurf/workflows/trellis-{name}.md"
  225. elif self.platform == "kilo":
  226. return f".kilocode/workflows/{name}.md"
  227. elif self.platform == "copilot":
  228. return f".github/prompts/{name}.prompt.md"
  229. elif self.platform == "droid":
  230. return f".factory/commands/trellis/{name}.md"
  231. elif self.platform == "pi":
  232. return f".pi/prompts/trellis-{name}.md"
  233. else:
  234. return f"{self.config_dir_name}/commands/trellis/{name}.md"
  235. # =========================================================================
  236. # Environment Variables
  237. # =========================================================================
  238. def get_non_interactive_env(self) -> dict[str, str]:
  239. """Get environment variables for non-interactive mode.
  240. Returns:
  241. Dict of environment variables to set
  242. """
  243. if self.platform == "opencode":
  244. return {"OPENCODE_NON_INTERACTIVE": "1"}
  245. elif self.platform == "iflow":
  246. return {"IFLOW_NON_INTERACTIVE": "1"}
  247. elif self.platform == "codex":
  248. return {"CODEX_NON_INTERACTIVE": "1"}
  249. elif self.platform == "kiro":
  250. return {"KIRO_NON_INTERACTIVE": "1"}
  251. elif self.platform == "gemini":
  252. return {} # Gemini CLI doesn't have a non-interactive env var
  253. elif self.platform == "antigravity":
  254. return {}
  255. elif self.platform == "windsurf":
  256. return {}
  257. elif self.platform == "qoder":
  258. return {}
  259. elif self.platform == "codebuddy":
  260. return {}
  261. elif self.platform == "copilot":
  262. return {}
  263. elif self.platform == "droid":
  264. return {}
  265. elif self.platform == "pi":
  266. return {}
  267. else:
  268. return {"CLAUDE_NON_INTERACTIVE": "1"}
  269. # =========================================================================
  270. # CLI Command Building
  271. # =========================================================================
  272. def build_run_command(
  273. self,
  274. agent: str,
  275. prompt: str,
  276. session_id: str | None = None,
  277. skip_permissions: bool = True,
  278. verbose: bool = True,
  279. json_output: bool = True,
  280. ) -> list[str]:
  281. """Build CLI command for running an agent.
  282. Args:
  283. agent: Agent name (will be mapped if needed)
  284. prompt: Prompt to send to the agent
  285. session_id: Optional session ID (Claude Code only for creation)
  286. skip_permissions: Whether to skip permission prompts
  287. verbose: Whether to enable verbose output
  288. json_output: Whether to use JSON output format
  289. Returns:
  290. List of command arguments
  291. """
  292. mapped_agent = self.get_agent_name(agent)
  293. if self.platform == "opencode":
  294. cmd = ["opencode", "run"]
  295. cmd.extend(["--agent", mapped_agent])
  296. # Note: OpenCode 'run' mode is non-interactive by default
  297. # No equivalent to Claude Code's --dangerously-skip-permissions
  298. # See: https://github.com/anomalyco/opencode/issues/9070
  299. if json_output:
  300. cmd.extend(["--format", "json"])
  301. if verbose:
  302. cmd.extend(["--log-level", "DEBUG", "--print-logs"])
  303. # Note: OpenCode doesn't support --session-id on creation
  304. # Session ID must be extracted from logs after startup
  305. cmd.append(prompt)
  306. elif self.platform == "iflow":
  307. cmd = ["iflow", "-y", "-p"]
  308. cmd.append(f"${mapped_agent} {prompt}")
  309. elif self.platform == "codex":
  310. cmd = ["codex", "exec"]
  311. cmd.append(prompt)
  312. elif self.platform == "kiro":
  313. cmd = ["kiro", "run", prompt]
  314. elif self.platform == "gemini":
  315. cmd = ["gemini"]
  316. cmd.append(prompt)
  317. elif self.platform == "antigravity":
  318. raise ValueError(
  319. "Antigravity workflows are UI slash commands; CLI agent run is not supported."
  320. )
  321. elif self.platform == "windsurf":
  322. raise ValueError(
  323. "Windsurf workflows are UI slash commands; CLI agent run is not supported."
  324. )
  325. elif self.platform == "qoder":
  326. cmd = ["qodercli", "-p", prompt]
  327. elif self.platform == "codebuddy":
  328. raise ValueError(
  329. "CodeBuddy does not support non-interactive mode (no CLI agent)"
  330. )
  331. elif self.platform == "copilot":
  332. raise ValueError(
  333. "GitHub Copilot is IDE-only; CLI agent run is not supported."
  334. )
  335. elif self.platform == "droid":
  336. raise ValueError(
  337. "Factory Droid CLI agent run is not yet supported."
  338. )
  339. elif self.platform == "pi":
  340. cmd = ["pi", "-p", prompt]
  341. else: # claude
  342. cmd = ["claude", "-p"]
  343. cmd.extend(["--agent", mapped_agent])
  344. if session_id:
  345. cmd.extend(["--session-id", session_id])
  346. if skip_permissions:
  347. cmd.append("--dangerously-skip-permissions")
  348. if json_output:
  349. cmd.extend(["--output-format", "stream-json"])
  350. if verbose:
  351. cmd.append("--verbose")
  352. cmd.append(prompt)
  353. return cmd
  354. def build_resume_command(self, session_id: str) -> list[str]:
  355. """Build CLI command for resuming a session.
  356. Args:
  357. session_id: Session ID to resume (ignored for iFlow)
  358. Returns:
  359. List of command arguments
  360. """
  361. if self.platform == "opencode":
  362. return ["opencode", "run", "--session", session_id]
  363. elif self.platform == "iflow":
  364. # iFlow uses -c to continue most recent conversation
  365. # session_id is ignored as iFlow doesn't support session IDs
  366. return ["iflow", "-c"]
  367. elif self.platform == "codex":
  368. return ["codex", "resume", session_id]
  369. elif self.platform == "kiro":
  370. return ["kiro", "resume", session_id]
  371. elif self.platform == "gemini":
  372. return ["gemini", "--resume", session_id]
  373. elif self.platform == "antigravity":
  374. raise ValueError(
  375. "Antigravity workflows are UI slash commands; CLI resume is not supported."
  376. )
  377. elif self.platform == "windsurf":
  378. raise ValueError(
  379. "Windsurf workflows are UI slash commands; CLI resume is not supported."
  380. )
  381. elif self.platform == "qoder":
  382. return ["qodercli", "--resume", session_id]
  383. elif self.platform == "codebuddy":
  384. raise ValueError(
  385. "CodeBuddy does not support non-interactive mode (no CLI agent)"
  386. )
  387. elif self.platform == "copilot":
  388. raise ValueError(
  389. "GitHub Copilot is IDE-only; CLI resume is not supported."
  390. )
  391. elif self.platform == "droid":
  392. raise ValueError(
  393. "Factory Droid CLI resume is not yet supported."
  394. )
  395. elif self.platform == "pi":
  396. return ["pi", "-c", session_id]
  397. else:
  398. return ["claude", "--resume", session_id]
  399. def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str:
  400. """Get human-readable resume command string.
  401. Args:
  402. session_id: Session ID to resume
  403. cwd: Optional working directory to cd into
  404. Returns:
  405. Command string for display
  406. """
  407. cmd = self.build_resume_command(session_id)
  408. cmd_str = " ".join(cmd)
  409. if cwd:
  410. return f"cd {cwd} && {cmd_str}"
  411. return cmd_str
  412. # =========================================================================
  413. # Platform Detection Helpers
  414. # =========================================================================
  415. @property
  416. def is_opencode(self) -> bool:
  417. """Check if platform is OpenCode."""
  418. return self.platform == "opencode"
  419. @property
  420. def is_claude(self) -> bool:
  421. """Check if platform is Claude Code."""
  422. return self.platform == "claude"
  423. @property
  424. def is_cursor(self) -> bool:
  425. """Check if platform is Cursor."""
  426. return self.platform == "cursor"
  427. @property
  428. def is_iflow(self) -> bool:
  429. """Check if platform is iFlow CLI."""
  430. return self.platform == "iflow"
  431. @property
  432. def cli_name(self) -> str:
  433. """Get CLI executable name.
  434. Note: Cursor doesn't have a CLI tool, returns None-like value.
  435. """
  436. if self.is_opencode:
  437. return "opencode"
  438. elif self.is_cursor:
  439. return "cursor" # Note: Cursor is IDE-only, no CLI
  440. elif self.platform == "iflow":
  441. return "iflow"
  442. elif self.platform == "kiro":
  443. return "kiro"
  444. elif self.platform == "gemini":
  445. return "gemini"
  446. elif self.platform == "antigravity":
  447. return "agy"
  448. elif self.platform == "windsurf":
  449. return "windsurf"
  450. elif self.platform == "qoder":
  451. return "qodercli"
  452. elif self.platform == "codebuddy":
  453. return "codebuddy"
  454. elif self.platform == "copilot":
  455. return "copilot"
  456. elif self.platform == "droid":
  457. return "droid"
  458. elif self.platform == "pi":
  459. return "pi"
  460. else:
  461. return "claude"
  462. @property
  463. def supports_cli_agents(self) -> bool:
  464. """Check if platform supports running agents via CLI.
  465. Claude Code, OpenCode, iFlow, and Codex support CLI agent execution.
  466. Cursor is IDE-only and doesn't support CLI agents.
  467. """
  468. return self.platform in ("claude", "opencode", "iflow", "codex", "pi")
  469. @property
  470. def requires_agent_definition_file(self) -> bool:
  471. """Check if platform requires an agent definition file (.md/.toml) to run.
  472. Claude Code, OpenCode, iFlow: require agent .md files (--agent flag).
  473. Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag.
  474. """
  475. return self.platform in ("claude", "opencode", "iflow")
  476. # =========================================================================
  477. # Session ID Handling
  478. # =========================================================================
  479. @property
  480. def supports_session_id_on_create(self) -> bool:
  481. """Check if platform supports specifying session ID on creation.
  482. Claude Code: Yes (--session-id)
  483. OpenCode: No (auto-generated, extract from logs)
  484. iFlow: No (no session ID support)
  485. """
  486. return self.platform == "claude"
  487. def extract_session_id_from_log(self, log_content: str) -> str | None:
  488. """Extract session ID from log output (OpenCode only).
  489. OpenCode generates session IDs in format: ses_xxx
  490. Args:
  491. log_content: Log file content
  492. Returns:
  493. Session ID if found, None otherwise
  494. """
  495. import re
  496. # OpenCode session ID pattern
  497. match = re.search(r"ses_[a-zA-Z0-9]+", log_content)
  498. if match:
  499. return match.group(0)
  500. return None
  501. # =============================================================================
  502. # Factory Function
  503. # =============================================================================
  504. def get_cli_adapter(platform: str = "claude") -> CLIAdapter:
  505. """Get CLI adapter for the specified platform.
  506. Args:
  507. platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')
  508. Returns:
  509. CLIAdapter instance
  510. Raises:
  511. ValueError: If platform is not supported
  512. """
  513. if platform not in (
  514. "claude",
  515. "opencode",
  516. "cursor",
  517. "iflow",
  518. "codex",
  519. "kilo",
  520. "kiro",
  521. "gemini",
  522. "antigravity",
  523. "windsurf",
  524. "qoder",
  525. "codebuddy",
  526. "copilot",
  527. "droid",
  528. "pi",
  529. ):
  530. raise ValueError(
  531. f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')"
  532. )
  533. return CLIAdapter(platform=platform) # type: ignore
  534. _ALL_PLATFORM_CONFIG_DIRS = (
  535. ".claude",
  536. ".cursor",
  537. ".iflow",
  538. ".opencode",
  539. ".codex",
  540. ".kilocode",
  541. ".kiro",
  542. ".gemini",
  543. ".agent",
  544. ".windsurf",
  545. ".qoder",
  546. ".codebuddy",
  547. ".github/copilot",
  548. ".factory",
  549. ".pi",
  550. )
  551. """Platform-specific config directory names used by detect_platform exclusion
  552. checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform
  553. layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the
  554. agentskills.io standard), not a single-platform signal. Its presence must not
  555. block detection of Kiro, Antigravity, Windsurf, or other platforms."""
  556. def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool:
  557. """Check if any platform config dir exists besides those in *exclude*."""
  558. return any(
  559. (project_root / d).is_dir()
  560. for d in _ALL_PLATFORM_CONFIG_DIRS
  561. if d not in exclude
  562. )
  563. def detect_platform(project_root: Path) -> Platform:
  564. """Auto-detect platform based on existing config directories.
  565. Detection order:
  566. 1. TRELLIS_PLATFORM environment variable (if set)
  567. 2. .opencode directory exists → opencode
  568. 3. .iflow directory exists → iflow
  569. 4. .cursor directory exists (without .claude) → cursor
  570. 5. .codex exists and no other platform dirs → codex
  571. 6. .kilocode directory exists → kilo
  572. 7. .kiro/skills exists and no other platform dirs → kiro
  573. 8. .gemini directory exists → gemini
  574. 9. .agent/workflows exists and no other platform dirs → antigravity
  575. 10. .windsurf/workflows exists and no other platform dirs → windsurf
  576. 11. .codebuddy directory exists → codebuddy
  577. 12. .qoder directory exists → qoder
  578. 13. .pi directory exists → pi
  579. 14. Default → claude
  580. Args:
  581. project_root: Project root directory
  582. Returns:
  583. Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or default 'claude')
  584. """
  585. import os
  586. # Check environment variable first
  587. env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower()
  588. if env_platform in (
  589. "claude",
  590. "opencode",
  591. "cursor",
  592. "iflow",
  593. "codex",
  594. "kilo",
  595. "kiro",
  596. "gemini",
  597. "antigravity",
  598. "windsurf",
  599. "qoder",
  600. "codebuddy",
  601. "copilot",
  602. "droid",
  603. "pi",
  604. ):
  605. return env_platform # type: ignore
  606. # Check for .opencode directory (OpenCode-specific)
  607. if (project_root / ".opencode").is_dir():
  608. return "opencode"
  609. # Check for .iflow directory (iFlow-specific)
  610. if (project_root / ".iflow").is_dir():
  611. return "iflow"
  612. # Check for .cursor directory (Cursor-specific)
  613. # Only detect as cursor if .claude doesn't exist (to avoid confusion)
  614. if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir():
  615. return "cursor"
  616. # Check for .gemini directory (Gemini CLI-specific)
  617. if (project_root / ".gemini").is_dir():
  618. return "gemini"
  619. # Check for .codex directory (Codex-specific)
  620. # .agents/skills/ alone does NOT trigger codex detection (it's a shared standard)
  621. if (project_root / ".codex").is_dir() and not _has_other_platform_dir(
  622. project_root, {".codex", ".agents"}
  623. ):
  624. return "codex"
  625. # Check for .kilocode directory (Kilo-specific)
  626. if (project_root / ".kilocode").is_dir():
  627. return "kilo"
  628. # Check for Kiro skills directory only when no other platform config exists
  629. if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir(
  630. project_root, {".kiro"}
  631. ):
  632. return "kiro"
  633. # Check for Antigravity workflow directory only when no other platform config exists
  634. if (
  635. project_root / ".agent" / "workflows"
  636. ).is_dir() and not _has_other_platform_dir(
  637. project_root, {".agent", ".gemini"}
  638. ):
  639. return "antigravity"
  640. # Check for Windsurf workflow directory only when no other platform config exists
  641. if (
  642. project_root / ".windsurf" / "workflows"
  643. ).is_dir() and not _has_other_platform_dir(
  644. project_root, {".windsurf"}
  645. ):
  646. return "windsurf"
  647. # Check for .codebuddy directory (CodeBuddy-specific)
  648. if (project_root / ".codebuddy").is_dir():
  649. return "codebuddy"
  650. # Check for .qoder directory (Qoder-specific)
  651. if (project_root / ".qoder").is_dir():
  652. return "qoder"
  653. # Check for .github/copilot directory (GitHub Copilot-specific)
  654. if (project_root / ".github" / "copilot").is_dir():
  655. return "copilot"
  656. # Check for .factory directory (Factory Droid-specific)
  657. if (project_root / ".factory").is_dir():
  658. return "droid"
  659. # Check for .pi directory (Pi Agent-specific)
  660. if (project_root / ".pi").is_dir():
  661. return "pi"
  662. # Fallback: checkout only has the Codex shared-skills layer
  663. # (.agents/skills/trellis-* dirs) and no explicit platform config dir.
  664. # Happens on fresh clones where .codex/ is gitignored/absent but the
  665. # shared skills were committed to git. Must guard against the case
  666. # where .claude/ or any other platform dir also exists — .agents/skills/
  667. # can legitimately coexist with any platform as a shared consumption
  668. # layer for Amp/Cline/Warp/etc.
  669. agents_skills = project_root / ".agents" / "skills"
  670. if agents_skills.is_dir() and not _has_other_platform_dir(
  671. project_root, set()
  672. ):
  673. try:
  674. for entry in agents_skills.iterdir():
  675. if entry.is_dir() and entry.name.startswith("trellis-"):
  676. return "codex"
  677. except OSError:
  678. pass
  679. return "claude"
  680. def get_cli_adapter_auto(project_root: Path) -> CLIAdapter:
  681. """Get CLI adapter with auto-detected platform.
  682. Args:
  683. project_root: Project root directory
  684. Returns:
  685. CLIAdapter instance for detected platform
  686. """
  687. platform = detect_platform(project_root)
  688. return CLIAdapter(platform=platform)