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.
Files changed (113) hide show
  1. crossby/__init__.py +3 -0
  2. crossby/ai_tools/__init__.py +17 -0
  3. crossby/ai_tools/antigravity.py +59 -0
  4. crossby/ai_tools/base.py +484 -0
  5. crossby/ai_tools/claude.py +179 -0
  6. crossby/ai_tools/codex.py +89 -0
  7. crossby/ai_tools/copilot.py +96 -0
  8. crossby/ai_tools/cursor.py +157 -0
  9. crossby/ai_tools/gemini.py +73 -0
  10. crossby/ai_tools/model_utils.py +49 -0
  11. crossby/ai_tools/opencode.py +82 -0
  12. crossby/ai_tools/transcript.py +668 -0
  13. crossby/ai_tools/vscode.py +58 -0
  14. crossby/cli/__init__.py +0 -0
  15. crossby/cli/agents.py +134 -0
  16. crossby/cli/convert.py +106 -0
  17. crossby/cli/handoff.py +422 -0
  18. crossby/cli/init.py +450 -0
  19. crossby/cli/launch.py +263 -0
  20. crossby/cli/main.py +180 -0
  21. crossby/cli/stats.py +82 -0
  22. crossby/cli/sync.py +748 -0
  23. crossby/config/__init__.py +17 -0
  24. crossby/config/allowlist_util.py +81 -0
  25. crossby/config/claude_allowlist.py +128 -0
  26. crossby/config/copilot_hooks.py +59 -0
  27. crossby/config/cursor_allowlist.py +109 -0
  28. crossby/config/cursor_hooks.py +55 -0
  29. crossby/config/defaults.py +60 -0
  30. crossby/config/detection.py +273 -0
  31. crossby/config/gemini_hooks.py +47 -0
  32. crossby/config/instructions.py +52 -0
  33. crossby/config/json_utils.py +41 -0
  34. crossby/config/linker.py +76 -0
  35. crossby/config/loader.py +249 -0
  36. crossby/config/skills.py +63 -0
  37. crossby/data/__init__.py +18 -0
  38. crossby/data/models.json +243 -0
  39. crossby/data/prompts/launch_initial_message.md +1 -0
  40. crossby/data/prompts/summarize_cc_compact.md +72 -0
  41. crossby/data/prompts/summarize_default.md +13 -0
  42. crossby/data/skill/SKILL.md +175 -0
  43. crossby/data/skill/__init__.py +0 -0
  44. crossby/data/skill/agents/__init__.py +0 -0
  45. crossby/data/skill/agents/openai.yaml +4 -0
  46. crossby/data/skill/references/__init__.py +0 -0
  47. crossby/data/skill/references/differences.md +108 -0
  48. crossby/handoff/__init__.py +30 -0
  49. crossby/handoff/_utils.py +28 -0
  50. crossby/handoff/models.py +82 -0
  51. crossby/handoff/picker.py +22 -0
  52. crossby/handoff/prompts.py +54 -0
  53. crossby/handoff/readers/__init__.py +7 -0
  54. crossby/handoff/readers/claude.py +210 -0
  55. crossby/handoff/readers/codex.py +202 -0
  56. crossby/handoff/readers/copilot.py +187 -0
  57. crossby/handoff/readers/cursor.py +177 -0
  58. crossby/handoff/summarizer.py +343 -0
  59. crossby/handoff/truncate.py +66 -0
  60. crossby/handoff/writer.py +97 -0
  61. crossby/logging/__init__.py +5 -0
  62. crossby/logging/setup.py +42 -0
  63. crossby/models/__init__.py +0 -0
  64. crossby/models/ai.py +105 -0
  65. crossby/models/config.py +277 -0
  66. crossby/services/__init__.py +0 -0
  67. crossby/services/ai_resolution.py +360 -0
  68. crossby/services/confirm.py +145 -0
  69. crossby/services/handoff_resolution.py +85 -0
  70. crossby/services/prompt_delivery.py +37 -0
  71. crossby/services/sync_resolution.py +74 -0
  72. crossby/subagents/__init__.py +38 -0
  73. crossby/subagents/api.py +61 -0
  74. crossby/subagents/emitters.py +428 -0
  75. crossby/subagents/ir.py +101 -0
  76. crossby/subagents/parsers.py +342 -0
  77. crossby/subagents/tool_map.py +78 -0
  78. crossby/sync/__init__.py +202 -0
  79. crossby/sync/agent_models.py +230 -0
  80. crossby/sync/agents.py +1241 -0
  81. crossby/sync/base.py +136 -0
  82. crossby/sync/file_utils.py +55 -0
  83. crossby/sync/gitignore_utils.py +96 -0
  84. crossby/sync/hooks.py +600 -0
  85. crossby/sync/instruction_markers.py +127 -0
  86. crossby/sync/json_utils.py +83 -0
  87. crossby/sync/manual_fix.py +130 -0
  88. crossby/sync/mcp.py +291 -0
  89. crossby/sync/mcp_discovery.py +137 -0
  90. crossby/sync/mcp_transports.py +160 -0
  91. crossby/sync/permissions.py +433 -0
  92. crossby/sync/plan.py +200 -0
  93. crossby/sync/plugins.py +164 -0
  94. crossby/sync/readers.py +730 -0
  95. crossby/sync/report.py +174 -0
  96. crossby/sync/rules.py +397 -0
  97. crossby/sync/skill_install.py +154 -0
  98. crossby/sync/skills.py +550 -0
  99. crossby/sync/slash_commands.py +156 -0
  100. crossby/sync/translation.py +237 -0
  101. crossby/sync/validate.py +444 -0
  102. crossby/ui/__init__.py +0 -0
  103. crossby/ui/console.py +366 -0
  104. crossby/ui/prompts.py +192 -0
  105. crossby/utils/__init__.py +0 -0
  106. crossby/utils/clipboard.py +43 -0
  107. crossby/utils/process.py +130 -0
  108. crossby/utils/terminal.py +528 -0
  109. crossby-0.2.4.dist-info/METADATA +260 -0
  110. crossby-0.2.4.dist-info/RECORD +113 -0
  111. crossby-0.2.4.dist-info/WHEEL +4 -0
  112. crossby-0.2.4.dist-info/entry_points.txt +2 -0
  113. crossby-0.2.4.dist-info/licenses/LICENSE +21 -0
crossby/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CROSSBY — Cross-platform Bridge for Your AI agents."""
2
+
3
+ __version__ = "0.2.4"
@@ -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()
@@ -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]