docs: 更新 HTTP 错误码、用户积分、占卜运行及用户资料协议文档

This commit is contained in:
qzl
2026-04-10 16:45:45 +08:00
parent 1bc8bc6a27
commit 17ef460391
78 changed files with 18680 additions and 25 deletions
+628
View File
@@ -0,0 +1,628 @@
"""
CLI Adapter for Multi-Platform Support.
Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, and Qoder interfaces.
Supported platforms:
- claude: Claude Code (default)
- opencode: OpenCode
- cursor: Cursor IDE
- iflow: iFlow CLI
- codex: Codex CLI (skills-based)
- kilo: Kilo CLI
- kiro: Kiro Code (skills-based)
- gemini: Gemini CLI
- antigravity: Antigravity (workflow-based)
- qoder: Qoder
Usage:
from common.cli_adapter import CLIAdapter
adapter = CLIAdapter("opencode")
cmd = adapter.build_run_command(
agent="dispatch",
session_id="abc123",
prompt="Start the pipeline"
)
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar, Literal
Platform = Literal[
"claude",
"opencode",
"cursor",
"iflow",
"codex",
"kilo",
"kiro",
"gemini",
"antigravity",
"qoder",
]
@dataclass
class CLIAdapter:
"""Adapter for different AI coding CLI tools."""
platform: Platform
# =========================================================================
# Agent Name Mapping
# =========================================================================
# OpenCode has built-in agents that cannot be overridden
# See: https://github.com/sst/opencode/issues/4271
# Note: Class-level constant, not a dataclass field
_AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = {
"claude": {}, # No mapping needed
"opencode": {
"plan": "trellis-plan", # 'plan' is built-in in OpenCode
},
}
def get_agent_name(self, agent: str) -> str:
"""Get platform-specific agent name.
Args:
agent: Original agent name (e.g., 'plan', 'dispatch')
Returns:
Platform-specific agent name (e.g., 'trellis-plan' for OpenCode)
"""
mapping = self._AGENT_NAME_MAP.get(self.platform, {})
return mapping.get(agent, agent)
# =========================================================================
# Agent Path
# =========================================================================
@property
def config_dir_name(self) -> str:
"""Get platform-specific config directory name.
Returns:
Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.agents', '.kilocode', '.kiro', '.gemini', '.agent', or '.qoder')
"""
if self.platform == "opencode":
return ".opencode"
elif self.platform == "cursor":
return ".cursor"
elif self.platform == "iflow":
return ".iflow"
elif self.platform == "codex":
return ".agents"
elif self.platform == "kilo":
return ".kilocode"
elif self.platform == "kiro":
return ".kiro"
elif self.platform == "gemini":
return ".gemini"
elif self.platform == "antigravity":
return ".agent"
elif self.platform == "qoder":
return ".qoder"
else:
return ".claude"
def get_config_dir(self, project_root: Path) -> Path:
"""Get platform-specific config directory.
Args:
project_root: Project root directory
Returns:
Path to config directory (.claude, .opencode, .cursor, .iflow, .agents, .kilocode, .kiro, .gemini, .agent, or .qoder)
"""
return project_root / self.config_dir_name
def get_agent_path(self, agent: str, project_root: Path) -> Path:
"""Get path to agent definition file.
Args:
agent: Agent name (original, before mapping)
project_root: Project root directory
Returns:
Path to agent .md file
"""
mapped_name = self.get_agent_name(agent)
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md"
def get_commands_path(self, project_root: Path, *parts: str) -> Path:
"""Get path to commands directory or specific command file.
Args:
project_root: Project root directory
*parts: Additional path parts (e.g., 'trellis', 'finish-work.md')
Returns:
Path to commands directory or file
Note:
Cursor uses prefix naming: .cursor/commands/trellis-<name>.md
Antigravity uses workflow directory: .agent/workflows/<name>.md
Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md
"""
if self.platform in ("antigravity", "kilo"):
workflow_dir = self.get_config_dir(project_root) / "workflows"
if not parts:
return workflow_dir
if len(parts) >= 2 and parts[0] == "trellis":
filename = parts[-1]
return workflow_dir / filename
return workflow_dir / Path(*parts)
if not parts:
return self.get_config_dir(project_root) / "commands"
# Cursor uses prefix naming instead of subdirectory
if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis":
# Convert trellis/<name>.md to trellis-<name>.md
filename = parts[-1]
return (
self.get_config_dir(project_root) / "commands" / f"trellis-{filename}"
)
return self.get_config_dir(project_root) / "commands" / Path(*parts)
def get_trellis_command_path(self, name: str) -> str:
"""Get relative path to a trellis command file.
Args:
name: Command name without extension (e.g., 'finish-work', 'check-backend')
Returns:
Relative path string for use in JSONL entries
Note:
Cursor: .cursor/commands/trellis-<name>.md
Codex: .agents/skills/<name>/SKILL.md
Kiro: .kiro/skills/<name>/SKILL.md
Gemini: .gemini/commands/trellis/<name>.toml
Antigravity: .agent/workflows/<name>.md
Others: .{platform}/commands/trellis/<name>.md
"""
if self.platform == "cursor":
return f".cursor/commands/trellis-{name}.md"
elif self.platform == "codex":
return f".agents/skills/{name}/SKILL.md"
elif self.platform == "kiro":
return f".kiro/skills/{name}/SKILL.md"
elif self.platform == "gemini":
return f".gemini/commands/trellis/{name}.toml"
elif self.platform == "antigravity":
return f".agent/workflows/{name}.md"
elif self.platform == "kilo":
return f".kilocode/workflows/{name}.md"
else:
return f"{self.config_dir_name}/commands/trellis/{name}.md"
# =========================================================================
# Environment Variables
# =========================================================================
def get_non_interactive_env(self) -> dict[str, str]:
"""Get environment variables for non-interactive mode.
Returns:
Dict of environment variables to set
"""
if self.platform == "opencode":
return {"OPENCODE_NON_INTERACTIVE": "1"}
elif self.platform == "iflow":
return {"IFLOW_NON_INTERACTIVE": "1"}
elif self.platform == "codex":
return {"CODEX_NON_INTERACTIVE": "1"}
elif self.platform == "kiro":
return {"KIRO_NON_INTERACTIVE": "1"}
elif self.platform == "gemini":
return {} # Gemini CLI doesn't have a non-interactive env var
elif self.platform == "antigravity":
return {}
elif self.platform == "qoder":
return {}
else:
return {"CLAUDE_NON_INTERACTIVE": "1"}
# =========================================================================
# CLI Command Building
# =========================================================================
def build_run_command(
self,
agent: str,
prompt: str,
session_id: str | None = None,
skip_permissions: bool = True,
verbose: bool = True,
json_output: bool = True,
) -> list[str]:
"""Build CLI command for running an agent.
Args:
agent: Agent name (will be mapped if needed)
prompt: Prompt to send to the agent
session_id: Optional session ID (Claude Code only for creation)
skip_permissions: Whether to skip permission prompts
verbose: Whether to enable verbose output
json_output: Whether to use JSON output format
Returns:
List of command arguments
"""
mapped_agent = self.get_agent_name(agent)
if self.platform == "opencode":
cmd = ["opencode", "run"]
cmd.extend(["--agent", mapped_agent])
# Note: OpenCode 'run' mode is non-interactive by default
# No equivalent to Claude Code's --dangerously-skip-permissions
# See: https://github.com/anomalyco/opencode/issues/9070
if json_output:
cmd.extend(["--format", "json"])
if verbose:
cmd.extend(["--log-level", "DEBUG", "--print-logs"])
# Note: OpenCode doesn't support --session-id on creation
# Session ID must be extracted from logs after startup
cmd.append(prompt)
elif self.platform == "iflow":
cmd = ["iflow", "-p"]
cmd.extend(["-y", "--agent", mapped_agent])
# iFlow doesn't support --session-id on creation
if verbose:
cmd.append("--verbose")
cmd.append(prompt)
elif self.platform == "codex":
cmd = ["codex", "exec"]
cmd.append(prompt)
elif self.platform == "kiro":
cmd = ["kiro", "run", prompt]
elif self.platform == "gemini":
cmd = ["gemini"]
cmd.append(prompt)
elif self.platform == "antigravity":
raise ValueError(
"Antigravity workflows are UI slash commands; CLI agent run is not supported."
)
elif self.platform == "qoder":
cmd = ["qodercli", "-p", prompt]
else: # claude
cmd = ["claude", "-p"]
cmd.extend(["--agent", mapped_agent])
if session_id:
cmd.extend(["--session-id", session_id])
if skip_permissions:
cmd.append("--dangerously-skip-permissions")
if json_output:
cmd.extend(["--output-format", "stream-json"])
if verbose:
cmd.append("--verbose")
cmd.append(prompt)
return cmd
def build_resume_command(self, session_id: str) -> list[str]:
"""Build CLI command for resuming a session.
Args:
session_id: Session ID to resume (ignored for iFlow)
Returns:
List of command arguments
"""
if self.platform == "opencode":
return ["opencode", "run", "--session", session_id]
elif self.platform == "iflow":
# iFlow uses -c to continue most recent conversation
# session_id is ignored as iFlow doesn't support session IDs
return ["iflow", "-c"]
elif self.platform == "codex":
return ["codex", "resume", session_id]
elif self.platform == "kiro":
return ["kiro", "resume", session_id]
elif self.platform == "gemini":
return ["gemini", "--resume", session_id]
elif self.platform == "antigravity":
raise ValueError(
"Antigravity workflows are UI slash commands; CLI resume is not supported."
)
elif self.platform == "qoder":
return ["qodercli", "--resume", session_id]
else:
return ["claude", "--resume", session_id]
def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str:
"""Get human-readable resume command string.
Args:
session_id: Session ID to resume
cwd: Optional working directory to cd into
Returns:
Command string for display
"""
cmd = self.build_resume_command(session_id)
cmd_str = " ".join(cmd)
if cwd:
return f"cd {cwd} && {cmd_str}"
return cmd_str
# =========================================================================
# Platform Detection Helpers
# =========================================================================
@property
def is_opencode(self) -> bool:
"""Check if platform is OpenCode."""
return self.platform == "opencode"
@property
def is_claude(self) -> bool:
"""Check if platform is Claude Code."""
return self.platform == "claude"
@property
def is_cursor(self) -> bool:
"""Check if platform is Cursor."""
return self.platform == "cursor"
@property
def is_iflow(self) -> bool:
"""Check if platform is iFlow CLI."""
return self.platform == "iflow"
@property
def cli_name(self) -> str:
"""Get CLI executable name.
Note: Cursor doesn't have a CLI tool, returns None-like value.
"""
if self.is_opencode:
return "opencode"
elif self.is_cursor:
return "cursor" # Note: Cursor is IDE-only, no CLI
elif self.platform == "iflow":
return "iflow"
elif self.platform == "kiro":
return "kiro"
elif self.platform == "gemini":
return "gemini"
elif self.platform == "antigravity":
return "agy"
elif self.platform == "qoder":
return "qodercli"
else:
return "claude"
@property
def supports_cli_agents(self) -> bool:
"""Check if platform supports running agents via CLI.
Claude Code, OpenCode, and iFlow support CLI agent execution.
Cursor is IDE-only and doesn't support CLI agents.
"""
return self.platform in ("claude", "opencode", "iflow")
# =========================================================================
# Session ID Handling
# =========================================================================
@property
def supports_session_id_on_create(self) -> bool:
"""Check if platform supports specifying session ID on creation.
Claude Code: Yes (--session-id)
OpenCode: No (auto-generated, extract from logs)
iFlow: No (no session ID support)
"""
return self.platform == "claude"
def extract_session_id_from_log(self, log_content: str) -> str | None:
"""Extract session ID from log output (OpenCode only).
OpenCode generates session IDs in format: ses_xxx
Args:
log_content: Log file content
Returns:
Session ID if found, None otherwise
"""
import re
# OpenCode session ID pattern
match = re.search(r"ses_[a-zA-Z0-9]+", log_content)
if match:
return match.group(0)
return None
# =============================================================================
# Factory Function
# =============================================================================
def get_cli_adapter(platform: str = "claude") -> CLIAdapter:
"""Get CLI adapter for the specified platform.
Args:
platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')
Returns:
CLIAdapter instance
Raises:
ValueError: If platform is not supported
"""
if platform not in (
"claude",
"opencode",
"cursor",
"iflow",
"codex",
"kilo",
"kiro",
"gemini",
"antigravity",
"qoder",
):
raise ValueError(
f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')"
)
return CLIAdapter(platform=platform) # type: ignore
def detect_platform(project_root: Path) -> Platform:
"""Auto-detect platform based on existing config directories.
Detection order:
1. TRELLIS_PLATFORM environment variable (if set)
2. .opencode directory exists → opencode
3. .iflow directory exists → iflow
4. .cursor directory exists (without .claude) → cursor
5. .agents/skills exists and no other platform dirs → codex
6. .kilocode directory exists → kilo
7. .kiro/skills exists and no other platform dirs → kiro
8. .gemini directory exists → gemini
9. .agent/workflows exists and no other platform dirs → antigravity
10. .qoder directory exists → qoder
11. Default → claude
Args:
project_root: Project root directory
Returns:
Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')
"""
import os
# Check environment variable first
env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower()
if env_platform in (
"claude",
"opencode",
"cursor",
"iflow",
"codex",
"kilo",
"kiro",
"gemini",
"antigravity",
"qoder",
):
return env_platform # type: ignore
# Check for .opencode directory (OpenCode-specific)
# Note: .claude might exist in both platforms during migration
if (project_root / ".opencode").is_dir():
return "opencode"
# Check for .iflow directory (iFlow-specific)
# Note: .claude might exist in both platforms during migration
if (project_root / ".iflow").is_dir():
return "iflow"
# Check for .cursor directory (Cursor-specific)
# Only detect as cursor if .claude doesn't exist (to avoid confusion)
if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir():
return "cursor"
# Check for .gemini directory (Gemini CLI-specific)
if (project_root / ".gemini").is_dir():
return "gemini"
# Check for Codex skills directory only when no other platform config exists
other_platform_dirs_codex = (
".claude",
".cursor",
".iflow",
".opencode",
".kilocode",
".kiro",
".gemini",
".agent",
)
has_other_platform_config = any(
(project_root / directory).is_dir() for directory in other_platform_dirs_codex
)
if (project_root / ".agents" / "skills").is_dir() and not has_other_platform_config:
return "codex"
# Check for .kilocode directory (Kilo-specific)
if (project_root / ".kilocode").is_dir():
return "kilo"
# Check for Kiro skills directory only when no other platform config exists
other_platform_dirs_kiro = (
".claude",
".cursor",
".iflow",
".opencode",
".agents",
".kilocode",
".gemini",
".agent",
)
has_other_platform_config = any(
(project_root / directory).is_dir() for directory in other_platform_dirs_kiro
)
if (project_root / ".kiro" / "skills").is_dir() and not has_other_platform_config:
return "kiro"
# Check for Antigravity workflow directory only when no other platform config exists
other_platform_dirs_antigravity = (
".claude",
".cursor",
".iflow",
".opencode",
".agents",
".kilocode",
".kiro",
)
has_other_platform_config = any(
(project_root / directory).is_dir()
for directory in other_platform_dirs_antigravity
)
if (
project_root / ".agent" / "workflows"
).is_dir() and not has_other_platform_config:
return "antigravity"
# Check for .qoder directory (Qoder-specific)
if (project_root / ".qoder").is_dir():
return "qoder"
return "claude"
def get_cli_adapter_auto(project_root: Path) -> CLIAdapter:
"""Get CLI adapter with auto-detected platform.
Args:
project_root: Project root directory
Returns:
CLIAdapter instance for detected platform
"""
platform = detect_platform(project_root)
return CLIAdapter(platform=platform)