cortexshift 0.1.0__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 (100) hide show
  1. cortexshift/__init__.py +10 -0
  2. cortexshift/__main__.py +6 -0
  3. cortexshift/adapters/__init__.py +22 -0
  4. cortexshift/adapters/command_runner.py +116 -0
  5. cortexshift/adapters/discovery.py +55 -0
  6. cortexshift/adapters/git/__init__.py +10 -0
  7. cortexshift/adapters/git/inspector.py +321 -0
  8. cortexshift/adapters/git/parser.py +140 -0
  9. cortexshift/adapters/headless_runner.py +92 -0
  10. cortexshift/adapters/process_runner.py +56 -0
  11. cortexshift/adapters/providers/__init__.py +4 -0
  12. cortexshift/adapters/providers/antigravity.py +530 -0
  13. cortexshift/adapters/providers/claude.py +375 -0
  14. cortexshift/adapters/providers/codex.py +434 -0
  15. cortexshift/adapters/sqlite/__init__.py +10 -0
  16. cortexshift/adapters/sqlite/migrations.py +268 -0
  17. cortexshift/adapters/sqlite/store.py +914 -0
  18. cortexshift/adapters/workspace_lease.py +123 -0
  19. cortexshift/application/__init__.py +42 -0
  20. cortexshift/application/checkpoint_builder.py +218 -0
  21. cortexshift/application/checkpoint_service.py +273 -0
  22. cortexshift/application/doctor.py +80 -0
  23. cortexshift/application/handoff_builder.py +281 -0
  24. cortexshift/application/handoff_renderer.py +430 -0
  25. cortexshift/application/handoff_service.py +66 -0
  26. cortexshift/application/init_service.py +86 -0
  27. cortexshift/application/locator.py +48 -0
  28. cortexshift/application/native_session.py +65 -0
  29. cortexshift/application/recovery_service.py +235 -0
  30. cortexshift/application/repository_service.py +146 -0
  31. cortexshift/application/resume_service.py +124 -0
  32. cortexshift/application/run_service.py +270 -0
  33. cortexshift/application/session_launcher.py +183 -0
  34. cortexshift/application/session_service.py +63 -0
  35. cortexshift/application/source_session.py +62 -0
  36. cortexshift/application/status_service.py +73 -0
  37. cortexshift/application/switch_service.py +671 -0
  38. cortexshift/application/task_service.py +201 -0
  39. cortexshift/application/task_workspace.py +152 -0
  40. cortexshift/cli/__init__.py +5 -0
  41. cortexshift/cli/app.py +2477 -0
  42. cortexshift/domain/__init__.py +153 -0
  43. cortexshift/domain/checkpoint.py +174 -0
  44. cortexshift/domain/doctor.py +68 -0
  45. cortexshift/domain/errors.py +277 -0
  46. cortexshift/domain/git.py +102 -0
  47. cortexshift/domain/handoff.py +241 -0
  48. cortexshift/domain/identifiers.py +27 -0
  49. cortexshift/domain/launch.py +58 -0
  50. cortexshift/domain/mcp_binding.py +81 -0
  51. cortexshift/domain/native_session.py +19 -0
  52. cortexshift/domain/project.py +37 -0
  53. cortexshift/domain/provider.py +67 -0
  54. cortexshift/domain/session.py +92 -0
  55. cortexshift/domain/status.py +40 -0
  56. cortexshift/domain/task.py +191 -0
  57. cortexshift/mcp/__init__.py +38 -0
  58. cortexshift/mcp/context.py +165 -0
  59. cortexshift/mcp/facade.py +513 -0
  60. cortexshift/mcp/models.py +178 -0
  61. cortexshift/mcp/resources.py +45 -0
  62. cortexshift/mcp/server.py +52 -0
  63. cortexshift/mcp/tools.py +176 -0
  64. cortexshift/ports/__init__.py +39 -0
  65. cortexshift/ports/checkpoint_store.py +45 -0
  66. cortexshift/ports/command_runner.py +56 -0
  67. cortexshift/ports/discovery.py +41 -0
  68. cortexshift/ports/handoff_delivery.py +91 -0
  69. cortexshift/ports/handoff_store.py +43 -0
  70. cortexshift/ports/headless_runner.py +58 -0
  71. cortexshift/ports/native_session.py +20 -0
  72. cortexshift/ports/process_runner.py +31 -0
  73. cortexshift/ports/provider.py +152 -0
  74. cortexshift/ports/repository.py +44 -0
  75. cortexshift/ports/session_store.py +27 -0
  76. cortexshift/ports/state_store.py +55 -0
  77. cortexshift/ports/workspace_lease.py +39 -0
  78. cortexshift/tui/__init__.py +24 -0
  79. cortexshift/tui/actions.py +58 -0
  80. cortexshift/tui/app.py +1051 -0
  81. cortexshift/tui/coordinator.py +173 -0
  82. cortexshift/tui/cortexshift.tcss +258 -0
  83. cortexshift/tui/facade.py +614 -0
  84. cortexshift/tui/modals.py +594 -0
  85. cortexshift/tui/models.py +503 -0
  86. cortexshift/tui/screens/__init__.py +81 -0
  87. cortexshift/tui/screens/checkpoints.py +188 -0
  88. cortexshift/tui/screens/handoffs.py +180 -0
  89. cortexshift/tui/screens/help.py +117 -0
  90. cortexshift/tui/screens/overview.py +200 -0
  91. cortexshift/tui/screens/providers.py +169 -0
  92. cortexshift/tui/screens/repository.py +143 -0
  93. cortexshift/tui/screens/sessions.py +146 -0
  94. cortexshift/tui/screens/task.py +174 -0
  95. cortexshift/tui/widgets.py +209 -0
  96. cortexshift-0.1.0.dist-info/METADATA +202 -0
  97. cortexshift-0.1.0.dist-info/RECORD +100 -0
  98. cortexshift-0.1.0.dist-info/WHEEL +4 -0
  99. cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
  100. cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,92 @@
1
+ """Subprocess implementation of the HeadlessProviderRunner port."""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from cortexshift.ports.headless_runner import (
8
+ DEFAULT_HEADLESS_TIMEOUT_SECONDS,
9
+ HeadlessProviderRunner,
10
+ HeadlessResult,
11
+ )
12
+
13
+ # Captured provider output is parsed for a handful of machine fields only. The bound
14
+ # stops a pathological provider from streaming unbounded output into memory; it is not
15
+ # a redaction mechanism, because the output is discarded rather than stored.
16
+ _MAX_CAPTURED_OUTPUT_CHARS = 2_000_000
17
+
18
+
19
+ def _bound(text: str | None) -> str:
20
+ """Bound captured output length without altering its leading content."""
21
+ if not text:
22
+ return ""
23
+ if len(text) > _MAX_CAPTURED_OUTPUT_CHARS:
24
+ return text[:_MAX_CAPTURED_OUTPUT_CHARS]
25
+ return text
26
+
27
+
28
+ class SubprocessHeadlessProviderRunner(HeadlessProviderRunner):
29
+ """Runs one non-interactive provider turn, capturing stdout and stderr.
30
+
31
+ Invariants:
32
+ - Never uses ``shell=True``; commands are pre-tokenized argument vectors.
33
+ - Requires no TTY and never inherits the user's terminal.
34
+ - Always enforces a finite (but model-turn appropriate) timeout.
35
+ - Never logs or prints captured output; callers parse and discard it.
36
+ """
37
+
38
+ def __init__(self, default_timeout: float = DEFAULT_HEADLESS_TIMEOUT_SECONDS) -> None:
39
+ self.default_timeout = default_timeout
40
+
41
+ def run_headless(
42
+ self,
43
+ argv: list[str],
44
+ cwd: Path | str,
45
+ timeout: float = DEFAULT_HEADLESS_TIMEOUT_SECONDS,
46
+ env: dict[str, str] | None = None,
47
+ ) -> HeadlessResult:
48
+ """Execute a bounded headless provider process without a shell or TTY."""
49
+ if not argv:
50
+ return HeadlessResult(
51
+ exit_code=1,
52
+ stdout="",
53
+ stderr="Empty command provided",
54
+ )
55
+
56
+ full_env = dict(os.environ)
57
+ if env:
58
+ full_env.update(env)
59
+
60
+ try:
61
+ completed = subprocess.run(
62
+ argv,
63
+ cwd=str(cwd),
64
+ env=full_env,
65
+ capture_output=True,
66
+ text=True,
67
+ timeout=timeout,
68
+ shell=False,
69
+ check=False,
70
+ stdin=subprocess.DEVNULL,
71
+ )
72
+ return HeadlessResult(
73
+ exit_code=completed.returncode,
74
+ stdout=_bound(completed.stdout),
75
+ stderr=_bound(completed.stderr),
76
+ )
77
+ except subprocess.TimeoutExpired:
78
+ return HeadlessResult(
79
+ exit_code=-1,
80
+ stdout="",
81
+ stderr="",
82
+ timed_out=True,
83
+ )
84
+ except (FileNotFoundError, PermissionError):
85
+ return HeadlessResult(
86
+ exit_code=127,
87
+ stdout="",
88
+ stderr="",
89
+ not_found=True,
90
+ )
91
+ except OSError:
92
+ return HeadlessResult(exit_code=1, stdout="", stderr="")
@@ -0,0 +1,56 @@
1
+ """Subprocess implementation of the InteractiveProcessRunner port."""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from cortexshift.ports.process_runner import InteractiveProcessRunner
8
+
9
+
10
+ class SubprocessInteractiveProcessRunner(InteractiveProcessRunner):
11
+ """Executes native provider processes interactively inheriting terminal streams."""
12
+
13
+ def run_interactive(
14
+ self,
15
+ argv: list[str],
16
+ cwd: Path | str,
17
+ env: dict[str, str] | None = None,
18
+ ) -> int:
19
+ """Run a native provider process interactively without short timeouts.
20
+
21
+ Args:
22
+ argv: Argument vector to execute directly (never through a shell).
23
+ cwd: Working directory (canonical project root).
24
+ env: Optional environment overlay.
25
+
26
+ Returns:
27
+ The exit code of the process (e.g. 0 on success, 130 on SIGINT).
28
+ """
29
+ full_env = dict(os.environ)
30
+ if env:
31
+ full_env.update(env)
32
+
33
+ proc = None
34
+ try:
35
+ proc = subprocess.Popen(
36
+ argv,
37
+ cwd=str(cwd),
38
+ env=full_env,
39
+ stdin=None,
40
+ stdout=None,
41
+ stderr=None,
42
+ shell=False,
43
+ )
44
+ return proc.wait()
45
+ except KeyboardInterrupt:
46
+ if proc is not None:
47
+ try:
48
+ return proc.wait(timeout=2.0)
49
+ except subprocess.TimeoutExpired:
50
+ proc.terminate()
51
+ try:
52
+ return proc.wait(timeout=2.0)
53
+ except subprocess.TimeoutExpired:
54
+ proc.kill()
55
+ return proc.wait()
56
+ return 130
@@ -0,0 +1,4 @@
1
+ """Provider adapter implementations for external AI coding agents.
2
+
3
+ Concrete adapters (Claude Code, OpenAI Codex, Google Antigravity) will reside here.
4
+ """
@@ -0,0 +1,530 @@
1
+ """Google Antigravity native CLI probe, runtime, and handoff delivery adapters."""
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from cortexshift.adapters.headless_runner import SubprocessHeadlessProviderRunner
11
+ from cortexshift.domain.doctor import AuthenticationStatus, ProviderDiagnostic
12
+ from cortexshift.domain.errors import (
13
+ CortexShiftError,
14
+ HandoffDeliveryError,
15
+ NativeResumeError,
16
+ UnsupportedPromptError,
17
+ )
18
+ from cortexshift.domain.handoff import HandoffFailureCode
19
+ from cortexshift.domain.launch import LaunchSpecification
20
+ from cortexshift.domain.native_session import NativeSessionCapabilities, valid_native_id
21
+ from cortexshift.domain.provider import PROVIDER_ANTIGRAVITY, ProviderCapabilities, ProviderId
22
+ from cortexshift.ports.command_runner import CommandRunner
23
+ from cortexshift.ports.discovery import ProviderProbe
24
+ from cortexshift.ports.handoff_delivery import (
25
+ HandoffDeliveryPreparation,
26
+ HandoffDeliveryStrategy,
27
+ ProviderHandoffAdapter,
28
+ )
29
+ from cortexshift.ports.headless_runner import (
30
+ DEFAULT_HEADLESS_TIMEOUT_SECONDS,
31
+ HeadlessProviderRunner,
32
+ )
33
+ from cortexshift.ports.provider import ProviderRuntimeAdapter
34
+
35
+ _VERSION_RE = re.compile(r"(\d+\.\d+(?:\.\d+)?(?:[-.][a-zA-Z0-9]+)?)")
36
+
37
+
38
+ def _extract_version(text: str) -> str | None:
39
+ """Extract a clean semver-like version string from CLI output."""
40
+ match = _VERSION_RE.search(text)
41
+ if match:
42
+ return match.group(1)
43
+ first_line = text.splitlines()[0].strip() if text else ""
44
+ return first_line[:32] if first_line and len(first_line) <= 32 else None
45
+
46
+
47
+ class AntigravityProviderProbe(ProviderProbe):
48
+ """Probe for detecting and diagnosing the Google Antigravity native CLI (`agy`).
49
+
50
+ CRITICAL INVARIANTS:
51
+ - Never invoke `agy -p ...` or headless prompt commands to check authentication.
52
+ Prompt execution consumes model quota and violates passive discovery.
53
+ - Never read config or credential files from ~/.gemini/... or Keychain.
54
+ - Since `agy` does not currently expose a documented passive, non-model auth status
55
+ command, authentication MUST be reported as UNKNOWN.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ command_runner: CommandRunner,
61
+ which_fn: Callable[[str], str | None] = shutil.which,
62
+ ) -> None:
63
+ self._runner = command_runner
64
+ self._which = which_fn
65
+
66
+ @property
67
+ def provider_id(self) -> ProviderId:
68
+ return PROVIDER_ANTIGRAVITY
69
+
70
+ @property
71
+ def display_name(self) -> str:
72
+ return "Antigravity"
73
+
74
+ @property
75
+ def executable(self) -> str:
76
+ return "agy"
77
+
78
+ def get_capabilities(self) -> ProviderCapabilities:
79
+ return ProviderCapabilities(
80
+ provider_id=self.provider_id,
81
+ display_name=self.display_name,
82
+ supports_interactive=True,
83
+ supports_headless=True,
84
+ supports_native_resume=True,
85
+ supports_structured_output=True,
86
+ supports_mcp=True,
87
+ supports_usage_metrics=True,
88
+ )
89
+
90
+ def probe(self) -> ProviderDiagnostic:
91
+ capabilities = self.get_capabilities()
92
+ resolved_path = self._which(self.executable)
93
+
94
+ if not resolved_path:
95
+ return ProviderDiagnostic(
96
+ provider_id=self.provider_id,
97
+ display_name=self.display_name,
98
+ executable=self.executable,
99
+ installed=False,
100
+ resolved_path=None,
101
+ version=None,
102
+ authentication_status=AuthenticationStatus.UNKNOWN,
103
+ capabilities=capabilities,
104
+ diagnostics=["Not found in PATH"],
105
+ )
106
+
107
+ # 1. Version probe (passive --version)
108
+ version_result = self._runner.run([resolved_path, "--version"], timeout=5.0)
109
+ version: str | None = None
110
+ diagnostics: list[str] = []
111
+
112
+ if version_result.success:
113
+ version = _extract_version(version_result.stdout)
114
+ if not version:
115
+ diagnostics.append("Version output could not be parsed")
116
+ else:
117
+ if version_result.timed_out:
118
+ diagnostics.append("Version probe timed out")
119
+ else:
120
+ diagnostics.append("Version probe failed")
121
+
122
+ # 2. Authentication probe:
123
+ # Passive-only: agy does not offer a non-model auth status command.
124
+ # Report UNKNOWN without running any prompts.
125
+ diagnostics.append("Passive authentication probe not supported")
126
+
127
+ return ProviderDiagnostic(
128
+ provider_id=self.provider_id,
129
+ display_name=self.display_name,
130
+ executable=self.executable,
131
+ installed=True,
132
+ resolved_path=resolved_path,
133
+ version=version,
134
+ authentication_status=AuthenticationStatus.UNKNOWN,
135
+ capabilities=capabilities,
136
+ diagnostics=diagnostics,
137
+ )
138
+
139
+
140
+ class AntigravityRuntimeAdapter(ProviderRuntimeAdapter):
141
+ """Runtime adapter for launching Google Antigravity interactive sessions."""
142
+
143
+ @property
144
+ def provider_id(self) -> ProviderId:
145
+ return PROVIDER_ANTIGRAVITY
146
+
147
+ @property
148
+ def display_name(self) -> str:
149
+ return "Antigravity"
150
+
151
+ @property
152
+ def executable(self) -> str:
153
+ return "agy"
154
+
155
+ def get_capabilities(self) -> ProviderCapabilities:
156
+ return ProviderCapabilities(
157
+ provider_id=self.provider_id,
158
+ display_name=self.display_name,
159
+ supports_interactive=True,
160
+ supports_headless=True,
161
+ supports_native_resume=True,
162
+ supports_structured_output=True,
163
+ supports_mcp=True,
164
+ supports_usage_metrics=True,
165
+ )
166
+
167
+ def get_native_capabilities(self) -> NativeSessionCapabilities:
168
+ return NativeSessionCapabilities(
169
+ supports_exact_resume=True,
170
+ can_capture_native_id_during_bootstrap=True,
171
+ can_resume_with_followup_context=True,
172
+ requires_model_turn_for_handoff_resume=True,
173
+ supports_managed_new_session=True,
174
+ )
175
+
176
+ def build_exact_resume(
177
+ self, project_root: Path, executable_path: str, native_session_id: str
178
+ ) -> LaunchSpecification:
179
+ if not valid_native_id(native_session_id):
180
+ raise NativeResumeError("Invalid native session identifier.")
181
+ return LaunchSpecification(
182
+ provider_id=self.provider_id,
183
+ executable=executable_path,
184
+ cwd=project_root,
185
+ argv=[executable_path, "--conversation", native_session_id],
186
+ native_session_id=native_session_id,
187
+ )
188
+
189
+ def build_launch_spec(
190
+ self,
191
+ project_root: Path,
192
+ executable_path: str,
193
+ prompt: str | None = None,
194
+ ) -> LaunchSpecification:
195
+ """Build argument vector for native Antigravity launch.
196
+
197
+ Raises UnsupportedPromptError if an initial prompt is provided.
198
+ """
199
+ if prompt is not None and prompt.strip():
200
+ raise UnsupportedPromptError(
201
+ "Antigravity does not currently expose a supported interactive "
202
+ "initial-prompt launch path through CortexShift.\n\n"
203
+ "Launch without --prompt and enter the prompt in the native Antigravity UI."
204
+ )
205
+
206
+ return LaunchSpecification(
207
+ provider_id=self.provider_id,
208
+ executable=executable_path,
209
+ cwd=project_root,
210
+ argv=[executable_path],
211
+ interactive=True,
212
+ initial_prompt_supported=False,
213
+ prompt_supplied=False,
214
+ )
215
+
216
+
217
+ ANTIGRAVITY_BOOTSTRAP_PREFIX = """This is a CortexShift handoff bootstrap.
218
+
219
+ Analyze and ingest the task context.
220
+ Remain read-only.
221
+ Do not modify files.
222
+ Do not run mutating commands.
223
+ Produce a concise continuation plan.
224
+
225
+ The same conversation will be resumed immediately
226
+ in the native interactive Antigravity UI.
227
+
228
+ --------------------------------------------------
229
+ """
230
+
231
+
232
+ def _parse_bootstrap_metadata(stdout: str) -> tuple[str | None, str | None]:
233
+ """Extract only the required machine fields from Antigravity's JSON bootstrap output.
234
+
235
+ Returns a `(conversation_id, status)` tuple. The provider response body, reasoning,
236
+ usage, and tool details are intentionally not read, returned, logged, or persisted.
237
+
238
+ Raises:
239
+ HandoffDeliveryError: If the output is not parseable structured JSON.
240
+ """
241
+ text = stdout.strip()
242
+ if not text:
243
+ raise HandoffDeliveryError(
244
+ HandoffFailureCode.BOOTSTRAP_INVALID_OUTPUT.value,
245
+ "The Antigravity handoff bootstrap returned no structured output.",
246
+ )
247
+
248
+ try:
249
+ data = json.loads(text)
250
+ except json.JSONDecodeError as err:
251
+ raise HandoffDeliveryError(
252
+ HandoffFailureCode.BOOTSTRAP_INVALID_OUTPUT.value,
253
+ "The Antigravity handoff bootstrap did not return parseable JSON output.",
254
+ ) from err
255
+
256
+ if not isinstance(data, dict):
257
+ raise HandoffDeliveryError(
258
+ HandoffFailureCode.BOOTSTRAP_INVALID_OUTPUT.value,
259
+ "The Antigravity handoff bootstrap returned an unexpected JSON structure.",
260
+ )
261
+
262
+ raw_conversation_id = data.get("conversation_id")
263
+ conversation_id = raw_conversation_id.strip() if isinstance(raw_conversation_id, str) else None
264
+
265
+ raw_status = data.get("status")
266
+ status = raw_status.strip() if isinstance(raw_status, str) else None
267
+
268
+ return (conversation_id or None), status
269
+
270
+
271
+ class AntigravityHandoffAdapter(ProviderHandoffAdapter):
272
+ """Delivers canonical handoff context to Antigravity via a safe two-stage flow.
273
+
274
+ Antigravity's documented native interactive startup does not expose the same direct
275
+ positional initial-prompt path as Claude Code and Codex. Rather than emulating
276
+ keystrokes, scraping the TUI, or silently dropping the handoff, CortexShift uses two
277
+ documented native capabilities:
278
+
279
+ 1. A read-only headless planning turn (`--mode=plan -p <context> --output-format json`)
280
+ that ingests the canonical context and produces a continuation plan. Plan mode is
281
+ required here because the purpose of the first turn is context ingestion and
282
+ planning, never unattended workspace mutation.
283
+ 2. An interactive resume of that same conversation (`--conversation <id>`) so the user
284
+ lands in the native TUI with the handoff already loaded.
285
+
286
+ Only `conversation_id` and `status` are read from the bootstrap output; the response
287
+ body is discarded. Permission bypass flags are never used.
288
+ """
289
+
290
+ def __init__(
291
+ self,
292
+ headless_runner: HeadlessProviderRunner | None = None,
293
+ runtime_adapter: "AntigravityRuntimeAdapter | None" = None,
294
+ timeout: float = DEFAULT_HEADLESS_TIMEOUT_SECONDS,
295
+ ) -> None:
296
+ self._headless = headless_runner or SubprocessHeadlessProviderRunner()
297
+ self._runtime = runtime_adapter or AntigravityRuntimeAdapter()
298
+ self._timeout = timeout
299
+
300
+ @property
301
+ def provider_id(self) -> ProviderId:
302
+ return PROVIDER_ANTIGRAVITY
303
+
304
+ @property
305
+ def display_name(self) -> str:
306
+ return self._runtime.display_name
307
+
308
+ @property
309
+ def executable(self) -> str:
310
+ return self._runtime.executable
311
+
312
+ @property
313
+ def delivery_strategy(self) -> HandoffDeliveryStrategy:
314
+ return HandoffDeliveryStrategy.PLAN_BOOTSTRAP_THEN_RESUME
315
+
316
+ @property
317
+ def bootstrap_model_turn_required(self) -> bool:
318
+ return True
319
+
320
+ def prepare_delivery(
321
+ self,
322
+ executable_path: str,
323
+ project_root: Path,
324
+ rendered_context: str,
325
+ native_session_id: str | None = None,
326
+ ) -> HandoffDeliveryPreparation:
327
+ """Run the read-only plan bootstrap, then prepare interactive conversation resume."""
328
+ if native_session_id is not None and not valid_native_id(native_session_id):
329
+ raise NativeResumeError("Invalid native session identifier.")
330
+ bootstrap_prompt = f"{ANTIGRAVITY_BOOTSTRAP_PREFIX}\n{rendered_context}"
331
+
332
+ result = self._headless.run_headless(
333
+ argv=[
334
+ executable_path,
335
+ "--mode=plan",
336
+ "-p",
337
+ bootstrap_prompt,
338
+ "--output-format",
339
+ "json",
340
+ *(["--conversation", native_session_id] if native_session_id else []),
341
+ ],
342
+ cwd=project_root,
343
+ timeout=self._timeout,
344
+ env={"CORTEXSHIFT_MCP_READ_ONLY": "1"},
345
+ )
346
+
347
+ if result.timed_out:
348
+ raise HandoffDeliveryError(
349
+ HandoffFailureCode.BOOTSTRAP_TIMEOUT.value,
350
+ "The Antigravity handoff bootstrap timed out before returning a plan.",
351
+ )
352
+
353
+ if result.not_found:
354
+ raise HandoffDeliveryError(
355
+ HandoffFailureCode.SPAWN_FAILED.value,
356
+ "The Antigravity executable could not be started for the handoff bootstrap.",
357
+ )
358
+
359
+ if result.exit_code != 0:
360
+ raise HandoffDeliveryError(
361
+ HandoffFailureCode.BOOTSTRAP_FAILED.value,
362
+ "The Antigravity handoff bootstrap exited unsuccessfully.",
363
+ )
364
+
365
+ conversation_id, status = _parse_bootstrap_metadata(result.stdout)
366
+
367
+ if status != "SUCCESS":
368
+ raise HandoffDeliveryError(
369
+ HandoffFailureCode.BOOTSTRAP_FAILED.value,
370
+ "The Antigravity handoff bootstrap did not report a successful status.",
371
+ )
372
+
373
+ if not valid_native_id(conversation_id):
374
+ raise HandoffDeliveryError(
375
+ HandoffFailureCode.BOOTSTRAP_INVALID_OUTPUT.value,
376
+ "The Antigravity handoff bootstrap did not return a conversation identifier.",
377
+ )
378
+
379
+ assert conversation_id is not None
380
+ if native_session_id is not None and conversation_id != native_session_id:
381
+ raise HandoffDeliveryError(
382
+ HandoffFailureCode.BOOTSTRAP_INVALID_OUTPUT.value,
383
+ "Antigravity returned a different conversation; refusing continuity fallback.",
384
+ )
385
+
386
+ launch_spec = LaunchSpecification(
387
+ provider_id=self.provider_id,
388
+ executable=executable_path,
389
+ cwd=project_root,
390
+ argv=[executable_path, "--conversation", conversation_id],
391
+ native_session_id=conversation_id,
392
+ interactive=True,
393
+ initial_prompt_supported=False,
394
+ prompt_supplied=False,
395
+ metadata={"resumed_conversation": True},
396
+ )
397
+
398
+ return HandoffDeliveryPreparation(
399
+ launch_spec=launch_spec,
400
+ native_session_id=conversation_id,
401
+ bootstrap_performed=True,
402
+ )
403
+
404
+
405
+ ANTIGRAVITY_MCP_CONFIG_REL_PATH = Path(".agents/mcp_config.json")
406
+
407
+ CORTEXSHIFT_ANTIGRAVITY_MCP_SERVER = {
408
+ "command": "cortexshift",
409
+ "args": ["mcp", "serve"],
410
+ }
411
+
412
+ ANTIGRAVITY_MCP_MISSING_NOTICE = (
413
+ "CortexShift MCP is not configured for Antigravity in this workspace.\n\n"
414
+ "Run:\n cortexshift mcp setup antigravity"
415
+ )
416
+
417
+
418
+ def is_antigravity_mcp_configured(project_root: Path) -> bool:
419
+ """Check if Antigravity workspace MCP config contains the cortexshift server."""
420
+ config_path = project_root / ANTIGRAVITY_MCP_CONFIG_REL_PATH
421
+ if not config_path.is_file():
422
+ return False
423
+ try:
424
+ data = json.loads(config_path.read_text(encoding="utf-8"))
425
+ if not isinstance(data, dict):
426
+ return False
427
+ servers = data.get("mcpServers")
428
+ if not isinstance(servers, dict):
429
+ return False
430
+ entry = servers.get("cortexshift")
431
+ return (
432
+ isinstance(entry, dict)
433
+ and entry.get("command") == CORTEXSHIFT_ANTIGRAVITY_MCP_SERVER["command"]
434
+ and entry.get("args") == CORTEXSHIFT_ANTIGRAVITY_MCP_SERVER["args"]
435
+ )
436
+ except Exception:
437
+ return False
438
+
439
+
440
+ def setup_antigravity_mcp(
441
+ project_root: Path,
442
+ dry_run: bool = False,
443
+ force: bool = False,
444
+ ) -> dict[str, Any]:
445
+ """Safely configure project-local .agents/mcp_config.json for Antigravity.
446
+
447
+ Preserves unrelated MCP servers and top-level keys.
448
+ Fails on configuration conflicts unless force=True.
449
+ Supports dry_run without modifying the filesystem.
450
+
451
+ Returns:
452
+ A dictionary describing the action performed and configuration details.
453
+
454
+ Raises:
455
+ CortexShiftError: If the existing file contains invalid JSON or has a conflict.
456
+ """
457
+ config_path = project_root / ANTIGRAVITY_MCP_CONFIG_REL_PATH
458
+ target_entry = dict(CORTEXSHIFT_ANTIGRAVITY_MCP_SERVER)
459
+
460
+ if not config_path.is_file():
461
+ new_data: dict[str, Any] = {
462
+ "mcpServers": {
463
+ "cortexshift": target_entry,
464
+ }
465
+ }
466
+ if not dry_run:
467
+ config_path.parent.mkdir(parents=True, exist_ok=True)
468
+ config_path.write_text(json.dumps(new_data, indent=2) + "\n", encoding="utf-8")
469
+ return {
470
+ "action": "created",
471
+ "path": str(config_path),
472
+ "changed": True,
473
+ "dry_run": dry_run,
474
+ "servers": ["cortexshift"],
475
+ }
476
+
477
+ # File exists: read and parse safely
478
+ raw_content = config_path.read_text(encoding="utf-8")
479
+ try:
480
+ data = json.loads(raw_content)
481
+ except json.JSONDecodeError as err:
482
+ raise CortexShiftError(
483
+ f"Cannot safely configure MCP: {config_path} contains invalid JSON."
484
+ ) from err
485
+
486
+ if not isinstance(data, dict):
487
+ raise CortexShiftError(
488
+ f"Cannot safely configure MCP: {config_path} must contain a JSON object."
489
+ )
490
+
491
+ mcp_servers = data.get("mcpServers")
492
+ if mcp_servers is None:
493
+ mcp_servers = {}
494
+ data["mcpServers"] = mcp_servers
495
+ elif not isinstance(mcp_servers, dict):
496
+ raise CortexShiftError(
497
+ f"Cannot safely configure MCP: 'mcpServers' in {config_path} must be a JSON object."
498
+ )
499
+
500
+ existing_entry = mcp_servers.get("cortexshift")
501
+ if existing_entry == target_entry:
502
+ return {
503
+ "action": "noop",
504
+ "path": str(config_path),
505
+ "changed": False,
506
+ "dry_run": dry_run,
507
+ "servers": list(mcp_servers.keys()),
508
+ }
509
+
510
+ if existing_entry is not None and not force:
511
+ raise CortexShiftError(
512
+ f"Conflicting configuration for 'cortexshift' already exists in {config_path}.\n"
513
+ "Use --force to overwrite only the 'cortexshift' entry while preserving other servers."
514
+ )
515
+
516
+ mcp_servers["cortexshift"] = target_entry
517
+
518
+ if not dry_run:
519
+ # Atomic write: write to temp file then replace
520
+ temp_path = config_path.with_suffix(".tmp")
521
+ temp_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
522
+ temp_path.replace(config_path)
523
+
524
+ return {
525
+ "action": "updated",
526
+ "path": str(config_path),
527
+ "changed": True,
528
+ "dry_run": dry_run,
529
+ "servers": list(mcp_servers.keys()),
530
+ }