crossby 0.2.4__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.
- crossby/__init__.py +3 -0
- crossby/ai_tools/__init__.py +17 -0
- crossby/ai_tools/antigravity.py +59 -0
- crossby/ai_tools/base.py +484 -0
- crossby/ai_tools/claude.py +179 -0
- crossby/ai_tools/codex.py +89 -0
- crossby/ai_tools/copilot.py +96 -0
- crossby/ai_tools/cursor.py +157 -0
- crossby/ai_tools/gemini.py +73 -0
- crossby/ai_tools/model_utils.py +49 -0
- crossby/ai_tools/opencode.py +82 -0
- crossby/ai_tools/transcript.py +668 -0
- crossby/ai_tools/vscode.py +58 -0
- crossby/cli/__init__.py +0 -0
- crossby/cli/agents.py +134 -0
- crossby/cli/convert.py +106 -0
- crossby/cli/handoff.py +422 -0
- crossby/cli/init.py +450 -0
- crossby/cli/launch.py +263 -0
- crossby/cli/main.py +180 -0
- crossby/cli/stats.py +82 -0
- crossby/cli/sync.py +748 -0
- crossby/config/__init__.py +17 -0
- crossby/config/allowlist_util.py +81 -0
- crossby/config/claude_allowlist.py +128 -0
- crossby/config/copilot_hooks.py +59 -0
- crossby/config/cursor_allowlist.py +109 -0
- crossby/config/cursor_hooks.py +55 -0
- crossby/config/defaults.py +60 -0
- crossby/config/detection.py +273 -0
- crossby/config/gemini_hooks.py +47 -0
- crossby/config/instructions.py +52 -0
- crossby/config/json_utils.py +41 -0
- crossby/config/linker.py +76 -0
- crossby/config/loader.py +249 -0
- crossby/config/skills.py +63 -0
- crossby/data/__init__.py +18 -0
- crossby/data/models.json +243 -0
- crossby/data/prompts/launch_initial_message.md +1 -0
- crossby/data/prompts/summarize_cc_compact.md +72 -0
- crossby/data/prompts/summarize_default.md +13 -0
- crossby/data/skill/SKILL.md +175 -0
- crossby/data/skill/__init__.py +0 -0
- crossby/data/skill/agents/__init__.py +0 -0
- crossby/data/skill/agents/openai.yaml +4 -0
- crossby/data/skill/references/__init__.py +0 -0
- crossby/data/skill/references/differences.md +108 -0
- crossby/handoff/__init__.py +30 -0
- crossby/handoff/_utils.py +28 -0
- crossby/handoff/models.py +82 -0
- crossby/handoff/picker.py +22 -0
- crossby/handoff/prompts.py +54 -0
- crossby/handoff/readers/__init__.py +7 -0
- crossby/handoff/readers/claude.py +210 -0
- crossby/handoff/readers/codex.py +202 -0
- crossby/handoff/readers/copilot.py +187 -0
- crossby/handoff/readers/cursor.py +177 -0
- crossby/handoff/summarizer.py +343 -0
- crossby/handoff/truncate.py +66 -0
- crossby/handoff/writer.py +97 -0
- crossby/logging/__init__.py +5 -0
- crossby/logging/setup.py +42 -0
- crossby/models/__init__.py +0 -0
- crossby/models/ai.py +105 -0
- crossby/models/config.py +277 -0
- crossby/services/__init__.py +0 -0
- crossby/services/ai_resolution.py +360 -0
- crossby/services/confirm.py +145 -0
- crossby/services/handoff_resolution.py +85 -0
- crossby/services/prompt_delivery.py +37 -0
- crossby/services/sync_resolution.py +74 -0
- crossby/subagents/__init__.py +38 -0
- crossby/subagents/api.py +61 -0
- crossby/subagents/emitters.py +428 -0
- crossby/subagents/ir.py +101 -0
- crossby/subagents/parsers.py +342 -0
- crossby/subagents/tool_map.py +78 -0
- crossby/sync/__init__.py +202 -0
- crossby/sync/agent_models.py +230 -0
- crossby/sync/agents.py +1241 -0
- crossby/sync/base.py +136 -0
- crossby/sync/file_utils.py +55 -0
- crossby/sync/gitignore_utils.py +96 -0
- crossby/sync/hooks.py +600 -0
- crossby/sync/instruction_markers.py +127 -0
- crossby/sync/json_utils.py +83 -0
- crossby/sync/manual_fix.py +130 -0
- crossby/sync/mcp.py +291 -0
- crossby/sync/mcp_discovery.py +137 -0
- crossby/sync/mcp_transports.py +160 -0
- crossby/sync/permissions.py +433 -0
- crossby/sync/plan.py +200 -0
- crossby/sync/plugins.py +164 -0
- crossby/sync/readers.py +730 -0
- crossby/sync/report.py +174 -0
- crossby/sync/rules.py +397 -0
- crossby/sync/skill_install.py +154 -0
- crossby/sync/skills.py +550 -0
- crossby/sync/slash_commands.py +156 -0
- crossby/sync/translation.py +237 -0
- crossby/sync/validate.py +444 -0
- crossby/ui/__init__.py +0 -0
- crossby/ui/console.py +366 -0
- crossby/ui/prompts.py +192 -0
- crossby/utils/__init__.py +0 -0
- crossby/utils/clipboard.py +43 -0
- crossby/utils/process.py +130 -0
- crossby/utils/terminal.py +528 -0
- crossby-0.2.4.dist-info/METADATA +260 -0
- crossby-0.2.4.dist-info/RECORD +113 -0
- crossby-0.2.4.dist-info/WHEEL +4 -0
- crossby-0.2.4.dist-info/entry_points.txt +2 -0
- crossby-0.2.4.dist-info/licenses/LICENSE +21 -0
crossby/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""AI tool adapters — ABC with self-registering concrete implementations.
|
|
2
|
+
|
|
3
|
+
Import all adapters here to trigger __init_subclass__ registration.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Import adapters to trigger registration
|
|
7
|
+
import crossby.ai_tools.antigravity
|
|
8
|
+
import crossby.ai_tools.claude
|
|
9
|
+
import crossby.ai_tools.codex
|
|
10
|
+
import crossby.ai_tools.copilot
|
|
11
|
+
import crossby.ai_tools.cursor
|
|
12
|
+
import crossby.ai_tools.gemini
|
|
13
|
+
import crossby.ai_tools.opencode
|
|
14
|
+
import crossby.ai_tools.vscode # noqa: F401
|
|
15
|
+
from crossby.ai_tools.base import AbstractAITool, pick_best_model
|
|
16
|
+
|
|
17
|
+
__all__ = ["AbstractAITool", "pick_best_model"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Antigravity CLI adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from crossby.ai_tools.base import AbstractAITool
|
|
11
|
+
from crossby.models.ai import (
|
|
12
|
+
AIToolCapabilities,
|
|
13
|
+
AIToolID,
|
|
14
|
+
AIToolType,
|
|
15
|
+
EffortLevel,
|
|
16
|
+
TokenUsage,
|
|
17
|
+
)
|
|
18
|
+
from crossby.utils.process import run_with_transcript
|
|
19
|
+
|
|
20
|
+
logger = structlog.get_logger()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AntigravityAdapter(AbstractAITool):
|
|
24
|
+
"""Adapter for Antigravity CLI."""
|
|
25
|
+
|
|
26
|
+
TOOL_ID: ClassVar[AIToolID] = AIToolID.ANTIGRAVITY
|
|
27
|
+
|
|
28
|
+
def capabilities(self) -> AIToolCapabilities:
|
|
29
|
+
return AIToolCapabilities(
|
|
30
|
+
tool_id=AIToolID.ANTIGRAVITY,
|
|
31
|
+
display_name="Antigravity",
|
|
32
|
+
binary="antigravity",
|
|
33
|
+
tool_type=AIToolType.TERMINAL,
|
|
34
|
+
supports_model_flag=False,
|
|
35
|
+
headless_flag=None,
|
|
36
|
+
supports_headless=False,
|
|
37
|
+
supports_initial_message=False,
|
|
38
|
+
blocks_until_exit=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def launch(
|
|
42
|
+
self,
|
|
43
|
+
working_dir: Path,
|
|
44
|
+
model: str | None = None,
|
|
45
|
+
prompt: str | None = None,
|
|
46
|
+
detach: bool = False,
|
|
47
|
+
transcript_path: Path | None = None,
|
|
48
|
+
trusted_dirs: list[str] | None = None,
|
|
49
|
+
effort: EffortLevel | None = None,
|
|
50
|
+
allowed_commands: list[str] | None = None,
|
|
51
|
+
yolo: bool = False,
|
|
52
|
+
plan_mode: bool = False,
|
|
53
|
+
) -> int:
|
|
54
|
+
cmd = [self.capabilities().binary, "."]
|
|
55
|
+
logger.info("ai_tool.launch", tool="antigravity", cwd=str(working_dir))
|
|
56
|
+
return run_with_transcript(cmd, transcript_path, cwd=working_dir)
|
|
57
|
+
|
|
58
|
+
def parse_transcript(self, transcript_path: Path) -> TokenUsage:
|
|
59
|
+
return TokenUsage()
|
crossby/ai_tools/base.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Abstract base class for AI tool adapters with self-registration.
|
|
2
|
+
|
|
3
|
+
Adding a new AI tool = one file with one class. No other files to modify.
|
|
4
|
+
The `__init_subclass__` hook auto-registers each concrete adapter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
import shutil
|
|
11
|
+
import warnings
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from crossby.handoff.models import ConversationTranscript, SessionRef
|
|
18
|
+
|
|
19
|
+
import structlog
|
|
20
|
+
|
|
21
|
+
from crossby.models.ai import (
|
|
22
|
+
AIModel,
|
|
23
|
+
AIToolCapabilities,
|
|
24
|
+
AIToolID,
|
|
25
|
+
EffortLevel,
|
|
26
|
+
ModelTier,
|
|
27
|
+
TokenUsage,
|
|
28
|
+
)
|
|
29
|
+
from crossby.models.config import ComplexityModelMapping
|
|
30
|
+
|
|
31
|
+
logger = structlog.get_logger()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AbstractAITool(ABC):
|
|
35
|
+
"""Base for all AI tool adapters.
|
|
36
|
+
|
|
37
|
+
Concrete subclasses must set TOOL_ID as a class variable.
|
|
38
|
+
Registration happens automatically via __init_subclass__.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
TOOL_ID: ClassVar[AIToolID]
|
|
42
|
+
_registry: ClassVar[dict[AIToolID, type[AbstractAITool]]] = {}
|
|
43
|
+
|
|
44
|
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
45
|
+
super().__init_subclass__(**kwargs)
|
|
46
|
+
if hasattr(cls, "TOOL_ID") and not inspect.isabstract(cls):
|
|
47
|
+
if cls.TOOL_ID in AbstractAITool._registry:
|
|
48
|
+
existing_cls = AbstractAITool._registry[cls.TOOL_ID]
|
|
49
|
+
warnings.warn(
|
|
50
|
+
f"TOOL_ID '{cls.TOOL_ID}' already registered by {existing_cls.__name__}; "
|
|
51
|
+
f"overwriting with {cls.__name__}",
|
|
52
|
+
UserWarning,
|
|
53
|
+
stacklevel=2,
|
|
54
|
+
)
|
|
55
|
+
AbstractAITool._registry[cls.TOOL_ID] = cls
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def get(cls, tool_id: str | AIToolID) -> AbstractAITool:
|
|
59
|
+
"""Get an adapter instance by tool ID."""
|
|
60
|
+
tid = AIToolID(tool_id) if not isinstance(tool_id, AIToolID) else tool_id
|
|
61
|
+
if tid not in cls._registry:
|
|
62
|
+
raise ValueError(f"Unknown AI tool: {tool_id}")
|
|
63
|
+
return cls._registry[tid]()
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def available_tools(cls) -> list[AIToolID]:
|
|
67
|
+
"""List all registered tool IDs."""
|
|
68
|
+
return list(cls._registry.keys())
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def detect_installed(cls) -> list[AIToolID]:
|
|
72
|
+
"""Detect which registered AI tools are installed on the system."""
|
|
73
|
+
installed = []
|
|
74
|
+
for tool_id, tool_cls in cls._registry.items():
|
|
75
|
+
adapter = tool_cls()
|
|
76
|
+
if shutil.which(adapter.capabilities().binary):
|
|
77
|
+
installed.append(tool_id)
|
|
78
|
+
return installed
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def capabilities(self) -> AIToolCapabilities:
|
|
82
|
+
"""Declare what this tool can do."""
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
|
|
85
|
+
def get_models(self) -> list[AIModel]:
|
|
86
|
+
"""Return known models from the static registry.
|
|
87
|
+
|
|
88
|
+
Uses universal tier classification. Override for tools with
|
|
89
|
+
special model ID formats (e.g. OpenCode's provider/model).
|
|
90
|
+
Returns an empty list if no models are registered for this tool.
|
|
91
|
+
"""
|
|
92
|
+
from crossby.ai_tools.model_utils import classify_tier_universal, has_date_suffix
|
|
93
|
+
from crossby.data import get_models_for_tool
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
AIModel(
|
|
97
|
+
id=mid,
|
|
98
|
+
tier=classify_tier_universal(mid),
|
|
99
|
+
is_alias=not has_date_suffix(mid),
|
|
100
|
+
)
|
|
101
|
+
for mid in get_models_for_tool(str(self.TOOL_ID))
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
def get_default_model(self, tier: ModelTier) -> AIModel | None:
|
|
105
|
+
"""Get the best model for a given tier.
|
|
106
|
+
|
|
107
|
+
Override in subclasses for tool-specific tier keywords.
|
|
108
|
+
"""
|
|
109
|
+
models = self.get_models()
|
|
110
|
+
tier_models = [m for m in models if m.tier == tier]
|
|
111
|
+
if not tier_models:
|
|
112
|
+
return None
|
|
113
|
+
return pick_best_model(tier_models)
|
|
114
|
+
|
|
115
|
+
def get_recommended_mapping(self) -> ComplexityModelMapping:
|
|
116
|
+
"""Get recommended model mapping for all complexity levels."""
|
|
117
|
+
fast = self.get_default_model(ModelTier.FAST)
|
|
118
|
+
balanced = self.get_default_model(ModelTier.BALANCED)
|
|
119
|
+
powerful = self.get_default_model(ModelTier.POWERFUL)
|
|
120
|
+
|
|
121
|
+
return ComplexityModelMapping(
|
|
122
|
+
easy=fast.id if fast else None,
|
|
123
|
+
medium=balanced.id if balanced else None,
|
|
124
|
+
complex=balanced.id if balanced else None,
|
|
125
|
+
very_complex=powerful.id if powerful else None,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def launch(
|
|
129
|
+
self,
|
|
130
|
+
working_dir: Path,
|
|
131
|
+
model: str | None = None,
|
|
132
|
+
prompt: str | None = None,
|
|
133
|
+
detach: bool = False,
|
|
134
|
+
transcript_path: Path | None = None,
|
|
135
|
+
trusted_dirs: list[str] | None = None,
|
|
136
|
+
effort: EffortLevel | None = None,
|
|
137
|
+
allowed_commands: list[str] | None = None,
|
|
138
|
+
yolo: bool = False,
|
|
139
|
+
plan_mode: bool = False,
|
|
140
|
+
) -> int:
|
|
141
|
+
"""Launch the AI tool in the given directory.
|
|
142
|
+
|
|
143
|
+
Default implementation builds a command via build_launch_command()
|
|
144
|
+
and runs it with transcript capture. Override for tools with
|
|
145
|
+
non-standard launch behavior (e.g. GUI tools).
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
working_dir: Directory to run in.
|
|
149
|
+
model: Model ID to use (or None for tool default).
|
|
150
|
+
prompt: Optional initial message passed to the tool on launch.
|
|
151
|
+
detach: If True, launch in background (GUI tools).
|
|
152
|
+
transcript_path: Optional path to write session transcript for
|
|
153
|
+
token usage extraction.
|
|
154
|
+
trusted_dirs: Optional list of directory paths to pre-authorize.
|
|
155
|
+
Tools that support directory-trust flags (e.g. --add-dir) will
|
|
156
|
+
pass these so the user is not prompted for confirmation.
|
|
157
|
+
effort: Optional reasoning effort level for the AI tool.
|
|
158
|
+
allowed_commands: Optional list of canonical command patterns to
|
|
159
|
+
pre-authorize (e.g. ``["myapp:*", "./scripts/check.sh:*"]``).
|
|
160
|
+
yolo: If True, skip all permission prompts (YOLO mode).
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Exit code from the tool process (0 for detached).
|
|
164
|
+
"""
|
|
165
|
+
from crossby.utils.process import run_with_transcript
|
|
166
|
+
|
|
167
|
+
cmd = self.build_launch_command(
|
|
168
|
+
model=model,
|
|
169
|
+
initial_message=prompt,
|
|
170
|
+
plan_mode=plan_mode,
|
|
171
|
+
trusted_dirs=trusted_dirs,
|
|
172
|
+
effort=effort,
|
|
173
|
+
allowed_commands=allowed_commands,
|
|
174
|
+
yolo=yolo,
|
|
175
|
+
)
|
|
176
|
+
logger.info("ai_tool.launch", tool=str(self.TOOL_ID), model=model, cwd=str(working_dir))
|
|
177
|
+
return run_with_transcript(cmd, transcript_path, cwd=working_dir)
|
|
178
|
+
|
|
179
|
+
def parse_transcript(self, transcript_path: Path) -> TokenUsage:
|
|
180
|
+
"""Parse a transcript file for token usage.
|
|
181
|
+
|
|
182
|
+
Default implementation extracts token usage from transcript text.
|
|
183
|
+
Override for tools with special parsing needs (e.g. premium requests).
|
|
184
|
+
Returns TokenUsage with whatever fields could be parsed.
|
|
185
|
+
"""
|
|
186
|
+
from crossby.ai_tools.transcript import parse_transcript_common
|
|
187
|
+
|
|
188
|
+
return parse_transcript_common(transcript_path)
|
|
189
|
+
|
|
190
|
+
def is_model_compatible(self, model: str) -> bool:
|
|
191
|
+
"""Check if a model ID is valid for this tool."""
|
|
192
|
+
return True # Default: allow all. Override per tool.
|
|
193
|
+
|
|
194
|
+
# Session handoff — concrete readers own per-tool cwd matching.
|
|
195
|
+
|
|
196
|
+
def locate_sessions(self, project_path: Path) -> list[SessionRef]:
|
|
197
|
+
"""Return all session refs this tool has recorded for ``project_path``.
|
|
198
|
+
|
|
199
|
+
Default: no handoff support — concrete adapters override.
|
|
200
|
+
"""
|
|
201
|
+
raise NotImplementedError(f"{self.TOOL_ID} does not support session handoff")
|
|
202
|
+
|
|
203
|
+
def read_session(self, ref: SessionRef) -> ConversationTranscript:
|
|
204
|
+
"""Parse the session pointed to by ``ref`` into a ConversationTranscript.
|
|
205
|
+
|
|
206
|
+
Default: no handoff support — concrete adapters override.
|
|
207
|
+
"""
|
|
208
|
+
raise NotImplementedError(f"{self.TOOL_ID} does not support session handoff")
|
|
209
|
+
|
|
210
|
+
def initial_message_args(self, prompt: str) -> list[str]:
|
|
211
|
+
"""Get CLI args to pass an initial message for an interactive session.
|
|
212
|
+
|
|
213
|
+
Default: no support (returns empty list). Override per tool.
|
|
214
|
+
Tools that support positional initial messages return [prompt].
|
|
215
|
+
Tools that require a flag return [flag, prompt].
|
|
216
|
+
"""
|
|
217
|
+
return []
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
# Extended API — used by build_launch_command() and library consumers
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def plan_mode_args(self) -> list[str]:
|
|
224
|
+
"""Get extra CLI args for native plan/approval mode."""
|
|
225
|
+
return [] # Default: no plan mode support
|
|
226
|
+
|
|
227
|
+
def plan_dir_args(self, plan_dir: str) -> list[str]:
|
|
228
|
+
"""Get extra CLI args to grant write access to a plan output directory."""
|
|
229
|
+
return [] # Default: no plan dir support
|
|
230
|
+
|
|
231
|
+
def trusted_dirs_args(self, dirs: list[str]) -> list[str]:
|
|
232
|
+
"""Get extra CLI args to grant access to a list of trusted directories.
|
|
233
|
+
|
|
234
|
+
Default implementation delegates to plan_dir_args() per directory, so
|
|
235
|
+
any adapter that overrides plan_dir_args() automatically supports this
|
|
236
|
+
method. Adapters without directory-trust support return [].
|
|
237
|
+
"""
|
|
238
|
+
result: list[str] = []
|
|
239
|
+
for d in dirs:
|
|
240
|
+
result.extend(self.plan_dir_args(d))
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
def normalize_model_format(self, model_id: str) -> str:
|
|
244
|
+
"""Normalize a model ID to this tool's expected format.
|
|
245
|
+
|
|
246
|
+
For example, Copilot uses dotted format (claude-haiku-4.5) while
|
|
247
|
+
Claude uses dashed format (claude-haiku-4-5).
|
|
248
|
+
|
|
249
|
+
Default: return as-is. Override per tool.
|
|
250
|
+
"""
|
|
251
|
+
return model_id
|
|
252
|
+
|
|
253
|
+
def standardize_model_id(self, raw_model_id: str) -> str:
|
|
254
|
+
"""Convert a tool-specific model ID to the internal standard format.
|
|
255
|
+
|
|
256
|
+
Our internal registry uses dotted notation (e.g. claude-haiku-4.5).
|
|
257
|
+
Tools that output dashed notation (claude-haiku-4-5) should override
|
|
258
|
+
this to convert it back to dotted notation.
|
|
259
|
+
|
|
260
|
+
Default: return as-is. Override per tool.
|
|
261
|
+
"""
|
|
262
|
+
return raw_model_id
|
|
263
|
+
|
|
264
|
+
def allowed_commands_args(self, commands: list[str]) -> list[str]:
|
|
265
|
+
"""Get CLI args to pre-authorize a list of command patterns.
|
|
266
|
+
|
|
267
|
+
Canonical patterns use colon-separated syntax (e.g. ``"myapp:*"``,
|
|
268
|
+
``"./scripts/check.sh:*"``). Each adapter translates them into
|
|
269
|
+
tool-specific flags.
|
|
270
|
+
|
|
271
|
+
Default: no support (returns empty list). Override per tool.
|
|
272
|
+
"""
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
def structured_output_args(self, json_schema: dict[str, Any]) -> list[str]:
|
|
276
|
+
"""Get extra CLI args to enforce structured JSON output according to a schema.
|
|
277
|
+
|
|
278
|
+
Default: return empty list. Override per tool if they support it.
|
|
279
|
+
"""
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
def effort_args(self, effort: EffortLevel) -> list[str]:
|
|
283
|
+
"""Get extra CLI args to set reasoning effort level.
|
|
284
|
+
|
|
285
|
+
Default: return empty list. Override per tool.
|
|
286
|
+
"""
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
def yolo_args(self) -> list[str]:
|
|
290
|
+
"""Get extra CLI args to skip all permission prompts (YOLO mode).
|
|
291
|
+
|
|
292
|
+
Default: return empty list. Override per tool.
|
|
293
|
+
"""
|
|
294
|
+
return []
|
|
295
|
+
|
|
296
|
+
def resolve_effort_model(self, model: str | None, effort: EffortLevel) -> str | None:
|
|
297
|
+
"""Resolve model variant based on effort level.
|
|
298
|
+
|
|
299
|
+
Some tools use different model IDs for higher effort (e.g., thinking
|
|
300
|
+
model variants). Default: return model unchanged. Override per tool.
|
|
301
|
+
"""
|
|
302
|
+
return model
|
|
303
|
+
|
|
304
|
+
def preserve_session_data(self, source_dir: Path, target_dir: Path) -> bool:
|
|
305
|
+
"""Preserve AI tool session data from one directory to another.
|
|
306
|
+
|
|
307
|
+
Called before removing a temporary working directory so that sessions
|
|
308
|
+
can be resumed from the main project directory after cleanup.
|
|
309
|
+
|
|
310
|
+
Default: no-op (return True). Override in tools that store path-bound
|
|
311
|
+
session data.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
source_dir: The directory being removed (e.g. a worktree).
|
|
315
|
+
target_dir: The main directory to migrate session data into.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
True if preservation succeeded (or is not needed), False on failure.
|
|
319
|
+
"""
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
def session_data_dirs(self) -> list[str]:
|
|
323
|
+
"""Return directory names that indicate this tool may have session data.
|
|
324
|
+
|
|
325
|
+
Used as a fallback when there is no record of the tool used in a
|
|
326
|
+
directory. If any of these directories exist, this adapter is
|
|
327
|
+
selected for preservation.
|
|
328
|
+
|
|
329
|
+
Default: empty list (no detection). Override per tool.
|
|
330
|
+
"""
|
|
331
|
+
return []
|
|
332
|
+
|
|
333
|
+
def build_resume_command(self, session_id: str) -> list[str] | None:
|
|
334
|
+
"""Build a command to resume a previous session.
|
|
335
|
+
|
|
336
|
+
Returns None if the tool does not support session resume.
|
|
337
|
+
Override in adapters that support resume (and set supports_resume=True
|
|
338
|
+
in capabilities).
|
|
339
|
+
"""
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
def build_launch_command(
|
|
343
|
+
self,
|
|
344
|
+
model: str | None = None,
|
|
345
|
+
prompt: str | None = None,
|
|
346
|
+
plan_mode: bool = False,
|
|
347
|
+
json_schema: dict[str, Any] | None = None,
|
|
348
|
+
trusted_dirs: list[str] | None = None,
|
|
349
|
+
initial_message: str | None = None,
|
|
350
|
+
effort: EffortLevel | None = None,
|
|
351
|
+
allowed_commands: list[str] | None = None,
|
|
352
|
+
yolo: bool = False,
|
|
353
|
+
) -> list[str]:
|
|
354
|
+
"""Build the command line for launching this tool."""
|
|
355
|
+
caps = self.capabilities()
|
|
356
|
+
cmd = [caps.binary]
|
|
357
|
+
|
|
358
|
+
# Initial message comes first so it is the first positional arg seen by
|
|
359
|
+
# the tool's parser (before any flags that could interfere).
|
|
360
|
+
if initial_message:
|
|
361
|
+
cmd.extend(self.initial_message_args(initial_message))
|
|
362
|
+
|
|
363
|
+
# Resolve effort-based model variant before applying model flag
|
|
364
|
+
effective_model = model
|
|
365
|
+
if effort and effective_model:
|
|
366
|
+
effective_model = self.resolve_effort_model(effective_model, effort)
|
|
367
|
+
elif effort and not effective_model:
|
|
368
|
+
effective_model = self.resolve_effort_model(None, effort)
|
|
369
|
+
|
|
370
|
+
# Cross-provider translation: if the user passed a model id that
|
|
371
|
+
# belongs to another provider's family (Claude → Codex or Codex →
|
|
372
|
+
# Claude), try to translate via the canonical family mappings rather
|
|
373
|
+
# than handing the tool an id it will reject. Other tools that
|
|
374
|
+
# accept arbitrary model ids (Cursor, Copilot, OpenCode) skip this
|
|
375
|
+
# branch via is_model_compatible() returning True.
|
|
376
|
+
if effective_model and not self.is_model_compatible(effective_model):
|
|
377
|
+
translated = _maybe_translate_cross_provider(effective_model, self.TOOL_ID, effort)
|
|
378
|
+
if translated is not None:
|
|
379
|
+
translated_model, translated_effort = translated
|
|
380
|
+
effort_note = (
|
|
381
|
+
f" (effort {effort.value} → {translated_effort.value})"
|
|
382
|
+
if effort and translated_effort and translated_effort != effort
|
|
383
|
+
else ""
|
|
384
|
+
)
|
|
385
|
+
warnings.warn(
|
|
386
|
+
f"Translating model {effective_model!r} → {translated_model!r} "
|
|
387
|
+
f"for {caps.display_name}{effort_note}; "
|
|
388
|
+
f"pass --model with a native id to skip this translation.",
|
|
389
|
+
UserWarning,
|
|
390
|
+
stacklevel=2,
|
|
391
|
+
)
|
|
392
|
+
effective_model = translated_model
|
|
393
|
+
if translated_effort is not None:
|
|
394
|
+
effort = translated_effort
|
|
395
|
+
|
|
396
|
+
if effective_model and caps.supports_model_flag:
|
|
397
|
+
cmd.extend([caps.model_flag, self.normalize_model_format(effective_model)])
|
|
398
|
+
|
|
399
|
+
if prompt and caps.supports_headless and caps.headless_flag:
|
|
400
|
+
cmd.extend([caps.headless_flag, prompt])
|
|
401
|
+
|
|
402
|
+
# YOLO mode supersedes plan_mode: YOLO grants full-auto permissions
|
|
403
|
+
# which is a superset of plan permissions. If the tool doesn't support
|
|
404
|
+
# YOLO, emit a warning and fall back to plan_mode_args.
|
|
405
|
+
if yolo:
|
|
406
|
+
if caps.supports_yolo:
|
|
407
|
+
cmd.extend(self.yolo_args())
|
|
408
|
+
else:
|
|
409
|
+
warnings.warn(
|
|
410
|
+
f"{caps.display_name} does not support YOLO mode; "
|
|
411
|
+
f"{'falling back to plan mode' if plan_mode else 'ignoring yolo'}",
|
|
412
|
+
UserWarning,
|
|
413
|
+
stacklevel=2,
|
|
414
|
+
)
|
|
415
|
+
if plan_mode:
|
|
416
|
+
cmd.extend(self.plan_mode_args())
|
|
417
|
+
elif plan_mode:
|
|
418
|
+
cmd.extend(self.plan_mode_args())
|
|
419
|
+
|
|
420
|
+
if json_schema:
|
|
421
|
+
cmd.extend(self.structured_output_args(json_schema))
|
|
422
|
+
|
|
423
|
+
if trusted_dirs:
|
|
424
|
+
cmd.extend(self.trusted_dirs_args(trusted_dirs))
|
|
425
|
+
|
|
426
|
+
# Effort args (tool-specific flags like --settings, --variant, etc.)
|
|
427
|
+
if effort and caps.supports_effort:
|
|
428
|
+
cmd.extend(self.effort_args(effort))
|
|
429
|
+
|
|
430
|
+
if allowed_commands:
|
|
431
|
+
cmd.extend(self.allowed_commands_args(allowed_commands))
|
|
432
|
+
|
|
433
|
+
return cmd
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _maybe_translate_cross_provider(
|
|
437
|
+
model: str,
|
|
438
|
+
tool_id: AIToolID,
|
|
439
|
+
effort: EffortLevel | None,
|
|
440
|
+
) -> tuple[str, EffortLevel | None] | None:
|
|
441
|
+
"""Apply Claude↔Codex family mapping when ``model`` isn't native to
|
|
442
|
+
``tool_id``.
|
|
443
|
+
|
|
444
|
+
Returns ``(translated_model, translated_effort)`` when a translation is
|
|
445
|
+
known, ``None`` otherwise. Effort is family-biased (Sonnet shifts up
|
|
446
|
+
one tier on the way to Codex, reverse picks the lowest source tier).
|
|
447
|
+
|
|
448
|
+
Imports the translation helpers lazily to avoid a circular dependency
|
|
449
|
+
via ``crossby.sync`` package init.
|
|
450
|
+
"""
|
|
451
|
+
from crossby.sync.translation import (
|
|
452
|
+
find_claude_family,
|
|
453
|
+
map_effort_claude_to_codex,
|
|
454
|
+
map_effort_codex_to_claude,
|
|
455
|
+
map_model_claude_to_codex,
|
|
456
|
+
map_model_codex_to_claude,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Claude model → Codex family
|
|
460
|
+
if tool_id == AIToolID.CODEX and find_claude_family(model) is not None:
|
|
461
|
+
translated_effort = map_effort_claude_to_codex(model, effort) if effort else None
|
|
462
|
+
return map_model_claude_to_codex(model), translated_effort
|
|
463
|
+
|
|
464
|
+
# Codex/GPT model → Claude family
|
|
465
|
+
if tool_id == AIToolID.CLAUDE and model.startswith("gpt-"):
|
|
466
|
+
target = map_model_codex_to_claude(model)
|
|
467
|
+
translated_effort = map_effort_codex_to_claude(model, target, effort) if effort else None
|
|
468
|
+
return target, translated_effort
|
|
469
|
+
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def pick_best_model(models: list[AIModel]) -> AIModel | None:
|
|
474
|
+
"""Pick the best model from a list — prefer aliases (no date suffix)."""
|
|
475
|
+
if not models:
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
# Prefer models without date suffix (alias models)
|
|
479
|
+
aliases = [m for m in models if m.is_alias]
|
|
480
|
+
if aliases:
|
|
481
|
+
return aliases[0]
|
|
482
|
+
|
|
483
|
+
# Fallback: sort by ID and take the last (newest)
|
|
484
|
+
return sorted(models, key=lambda m: m.id)[-1]
|