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
cortexshift/cli/app.py ADDED
@@ -0,0 +1,2477 @@
1
+ """CortexShift Typer CLI application."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated, Any
8
+
9
+ import mcp
10
+ import typer
11
+ from rich import box
12
+ from rich.console import Console
13
+ from rich.markup import escape
14
+ from rich.table import Table
15
+
16
+ from cortexshift import __version__
17
+ from cortexshift.adapters.providers.antigravity import (
18
+ ANTIGRAVITY_MCP_CONFIG_REL_PATH,
19
+ is_antigravity_mcp_configured,
20
+ setup_antigravity_mcp,
21
+ )
22
+ from cortexshift.adapters.sqlite.store import SQLiteStateStore
23
+ from cortexshift.application.checkpoint_service import CheckpointService
24
+ from cortexshift.application.doctor import DoctorService, UnknownProviderError
25
+ from cortexshift.application.handoff_renderer import RenderedHandoffContext
26
+ from cortexshift.application.handoff_service import HandoffService
27
+ from cortexshift.application.init_service import ProjectInitializationService
28
+ from cortexshift.application.locator import DATABASE_FILE_NAME, STATE_DIR_NAME, ProjectLocator
29
+ from cortexshift.application.native_session import is_native_resumable, native_capabilities
30
+ from cortexshift.application.recovery_service import RecoveryReport, RecoveryService
31
+ from cortexshift.application.repository_service import RepositoryService
32
+ from cortexshift.application.resume_service import ResumeDryRunResult, ResumeService
33
+ from cortexshift.application.run_service import ProviderRuntimeRegistry, RunService
34
+ from cortexshift.application.session_service import SessionService
35
+ from cortexshift.application.status_service import ProjectStatusService
36
+ from cortexshift.application.switch_service import SwitchService
37
+ from cortexshift.application.task_service import TaskService
38
+ from cortexshift.domain.checkpoint import CheckpointKind, CheckpointRecord
39
+ from cortexshift.domain.doctor import AuthenticationStatus, DoctorReport
40
+ from cortexshift.domain.errors import (
41
+ CheckpointNotFoundError,
42
+ CortexShiftError,
43
+ DatabaseStateError,
44
+ HandoffDeliveryError,
45
+ HandoffNotFoundError,
46
+ InvalidCheckpointInputError,
47
+ McpContextError,
48
+ McpReadOnlyError,
49
+ NativeResumeError,
50
+ NoActiveTaskError,
51
+ NoSourceSessionError,
52
+ ProjectConflictError,
53
+ ProjectNotInitializedError,
54
+ ProviderNotFoundError,
55
+ RepositoryInspectionError,
56
+ SameProviderSwitchError,
57
+ SessionNotFoundError,
58
+ SessionRecoveryError,
59
+ SessionTaskMismatchError,
60
+ SnapshotNotFoundError,
61
+ StateCorruptionError,
62
+ TaskAlreadyCompletedError,
63
+ TaskNotActivatableError,
64
+ TaskNotFoundError,
65
+ TerminalRequiredError,
66
+ UnsupportedPromptError,
67
+ UnsupportedSchemaVersionError,
68
+ WorkspaceLockedError,
69
+ )
70
+ from cortexshift.domain.git import (
71
+ RepositoryInspectionStatus,
72
+ )
73
+ from cortexshift.domain.handoff import HandoffRecord, HandoffStatus
74
+ from cortexshift.domain.launch import LaunchSpecification
75
+ from cortexshift.domain.project import Project
76
+ from cortexshift.domain.provider import ProviderId
77
+ from cortexshift.domain.session import Session, SessionStatus
78
+ from cortexshift.domain.status import ProjectStatus
79
+ from cortexshift.domain.task import Task, TaskStatus
80
+ from cortexshift.mcp.context import (
81
+ ENV_MCP_READ_ONLY,
82
+ ENV_SESSION_ID,
83
+ resolve_mcp_context,
84
+ )
85
+ from cortexshift.mcp.server import run_mcp_server
86
+ from cortexshift.tui.coordinator import TuiCoordinator
87
+
88
+ # The status glyphs the reports below render. Probed rather than assumed, so the check
89
+ # stays true if the vocabulary grows.
90
+ _REPORT_GLYPHS = "✓✗—…│"
91
+
92
+
93
+ def _ensure_output_can_encode_reports() -> None:
94
+ """Keep an un-encodable glyph from taking a whole command down.
95
+
96
+ Windows defaults redirected output to the ANSI code page, which has no `✓`. Writing
97
+ the doctor table into a pipe or a file therefore raised `UnicodeEncodeError` and the
98
+ command died with a traceback and exit code 1 -- `cortexshift doctor > report.txt`
99
+ was simply broken there, while the same command on a UTF-8 terminal was fine.
100
+
101
+ The stream keeps its own encoding. Switching it to UTF-8 would stop the crash but
102
+ emit bytes the environment did not ask for: whoever reads the output -- a file, a
103
+ pipe, another program calling us with the platform decoder -- would then hit a
104
+ *decode* error instead, which is the same bug wearing the other shoe. Only the error
105
+ policy changes, so every byte written stays valid in the declared encoding and a
106
+ character that genuinely cannot be represented degrades instead of the command.
107
+
108
+ Rich already drops to ASCII box drawing when it sees a narrow encoding, so what a
109
+ legacy console gets is a readable report, not a wall of escapes.
110
+ """
111
+ for stream in (sys.stdout, sys.stderr):
112
+ reconfigure = getattr(stream, "reconfigure", None)
113
+ if reconfigure is None: # a capture object, or a stream detached from a console
114
+ continue
115
+ try:
116
+ _REPORT_GLYPHS.encode(getattr(stream, "encoding", None) or "ascii")
117
+ except (LookupError, UnicodeEncodeError):
118
+ reconfigure(errors="backslashreplace")
119
+
120
+
121
+ _ensure_output_can_encode_reports()
122
+
123
+ console = Console()
124
+ err_console = Console(stderr=True)
125
+
126
+ app = typer.Typer(
127
+ name="cortexshift",
128
+ help="Switch agents. Keep the context. Provider-agnostic task handoff for coding agents.",
129
+ add_completion=False,
130
+ )
131
+
132
+ task_app = typer.Typer(
133
+ name="task",
134
+ help="Manage persistent development tasks.",
135
+ no_args_is_help=True,
136
+ )
137
+ app.add_typer(task_app, name="task")
138
+
139
+ repo_app = typer.Typer(
140
+ name="repo",
141
+ help="Inspect and snapshot Git repository state.",
142
+ no_args_is_help=True,
143
+ )
144
+ app.add_typer(repo_app, name="repo")
145
+
146
+ session_app = typer.Typer(
147
+ name="session",
148
+ help="Inspect agent execution session history.",
149
+ no_args_is_help=True,
150
+ )
151
+ app.add_typer(session_app, name="session")
152
+
153
+ handoff_app = typer.Typer(
154
+ name="handoff",
155
+ help="Preview and inspect canonical cross-provider handoffs.",
156
+ no_args_is_help=True,
157
+ )
158
+ app.add_typer(handoff_app, name="handoff")
159
+
160
+ checkpoint_app = typer.Typer(
161
+ name="checkpoint",
162
+ help="Manage immutable development checkpoints.",
163
+ no_args_is_help=True,
164
+ )
165
+ app.add_typer(checkpoint_app, name="checkpoint")
166
+
167
+ mcp_app = typer.Typer(
168
+ name="mcp",
169
+ help="Model Context Protocol (MCP) server, diagnostics, and setup.",
170
+ no_args_is_help=True,
171
+ )
172
+ app.add_typer(mcp_app, name="mcp")
173
+
174
+ mcp_setup_app = typer.Typer(
175
+ name="setup",
176
+ help="Configure provider workspace integration for CortexShift MCP.",
177
+ no_args_is_help=True,
178
+ )
179
+ mcp_app.add_typer(mcp_setup_app, name="setup")
180
+
181
+
182
+ def print_version() -> None:
183
+ """Print the version string."""
184
+ console.print(f"CortexShift {__version__}")
185
+
186
+
187
+ def _handle_error(err: Exception) -> None:
188
+ """Render friendly domain errors without Python tracebacks and exit."""
189
+ if isinstance(err, ProjectNotInitializedError):
190
+ err_console.print("\nCortexShift is not initialized here.\n\nRun:\n cortexshift init\n")
191
+ raise typer.Exit(code=1)
192
+ if isinstance(err, NoActiveTaskError):
193
+ err_console.print(
194
+ "\nNo active task.\n\n"
195
+ "No active CortexShift task.\n\n"
196
+ "Create or activate one first:\n\n cortexshift task start ...\n"
197
+ )
198
+ raise typer.Exit(code=1)
199
+
200
+ if isinstance(
201
+ err,
202
+ (
203
+ UnsupportedPromptError,
204
+ ProviderNotFoundError,
205
+ WorkspaceLockedError,
206
+ TerminalRequiredError,
207
+ NoSourceSessionError,
208
+ SameProviderSwitchError,
209
+ SessionTaskMismatchError,
210
+ HandoffDeliveryError,
211
+ NativeResumeError,
212
+ CheckpointNotFoundError,
213
+ InvalidCheckpointInputError,
214
+ SessionRecoveryError,
215
+ ),
216
+ ):
217
+ err_console.print(f"\n{err}\n")
218
+ raise typer.Exit(code=1)
219
+ if isinstance(
220
+ err,
221
+ (
222
+ TaskNotFoundError,
223
+ TaskNotActivatableError,
224
+ TaskAlreadyCompletedError,
225
+ ProjectConflictError,
226
+ UnsupportedSchemaVersionError,
227
+ StateCorruptionError,
228
+ DatabaseStateError,
229
+ RepositoryInspectionError,
230
+ SnapshotNotFoundError,
231
+ SessionNotFoundError,
232
+ HandoffNotFoundError,
233
+ McpContextError,
234
+ McpReadOnlyError,
235
+ UnknownProviderError,
236
+ ),
237
+ ):
238
+ err_console.print(f"[red]Error:[/red] {err}")
239
+ raise typer.Exit(code=1)
240
+ if isinstance(err, CortexShiftError):
241
+ err_console.print(f"[red]Error:[/red] {err}")
242
+ raise typer.Exit(code=1)
243
+ err_console.print(f"[red]Unexpected error:[/red] {err}")
244
+ raise typer.Exit(code=1)
245
+
246
+
247
+ def _get_project_and_store() -> tuple[Project, SQLiteStateStore]:
248
+ """Discover initialized project root and return canonical Project and SQLiteStateStore."""
249
+ project_root = ProjectLocator.find_project_root()
250
+ if project_root is None:
251
+ raise ProjectNotInitializedError()
252
+
253
+ db_path = ProjectLocator.get_database_path(project_root)
254
+ store = SQLiteStateStore(db_path, auto_migrate=False)
255
+ project = store.get_default_project()
256
+ if project is None:
257
+ store.close()
258
+ raise ProjectNotInitializedError()
259
+
260
+ return project, store
261
+
262
+
263
+ @app.callback(invoke_without_command=True)
264
+ def main(
265
+ ctx: typer.Context,
266
+ version_flag: Annotated[
267
+ bool,
268
+ typer.Option(
269
+ "--version",
270
+ "-v",
271
+ help="Show CortexShift version and exit.",
272
+ is_eager=True,
273
+ ),
274
+ ] = False,
275
+ ) -> None:
276
+ """CortexShift root command callback."""
277
+ if version_flag:
278
+ print_version()
279
+ raise typer.Exit()
280
+ if ctx.invoked_subcommand is None:
281
+ console.print(ctx.get_help())
282
+
283
+
284
+ @app.command(name="version")
285
+ def version_cmd() -> None:
286
+ """Display the installed CortexShift version."""
287
+ print_version()
288
+
289
+
290
+ # --- Doctor Command ---
291
+
292
+
293
+ def _render_rich_doctor(report: DoctorReport) -> None:
294
+ """Render the doctor diagnostic report using Rich tables and formatting."""
295
+ console.print("\n[bold]CortexShift Doctor[/bold]\n")
296
+
297
+ # Environment section
298
+ env_table = Table.grid(padding=(0, 2))
299
+ env_table.add_column(style="dim", min_width=14)
300
+ env_table.add_column()
301
+ env_table.add_row("CortexShift", report.cortexshift_version)
302
+ env_table.add_row("Python", report.python_version)
303
+ env_table.add_row("Platform", f"{report.platform.system} / {report.platform.machine}")
304
+
305
+ console.print("[bold]Environment[/bold]")
306
+ console.print(env_table)
307
+ console.print()
308
+
309
+ # Providers table
310
+ table = Table(box=box.ROUNDED, show_header=True, header_style="bold")
311
+ table.add_column("Provider", min_width=14)
312
+ table.add_column("CLI", justify="center", width=5)
313
+ table.add_column("Version", min_width=12)
314
+ table.add_column("Authentication", min_width=18)
315
+ table.add_column("Notes", min_width=24)
316
+
317
+ for p in report.providers:
318
+ cli_str = "[green]✓[/green]" if p.installed else "[red]✗[/red]"
319
+ if p.installed:
320
+ version_str = p.version if p.version else "[dim]unknown[/dim]"
321
+ else:
322
+ version_str = "[dim]—[/dim]"
323
+
324
+ if not p.installed:
325
+ auth_str = "[dim]—[/dim]"
326
+ elif p.authentication_status == AuthenticationStatus.AUTHENTICATED:
327
+ auth_str = "[green]Authenticated[/green]"
328
+ elif p.authentication_status == AuthenticationStatus.NOT_AUTHENTICATED:
329
+ auth_str = "[yellow]Not authenticated[/yellow]"
330
+ elif p.authentication_status == AuthenticationStatus.NOT_PROBED:
331
+ auth_str = "[dim]Not probed[/dim]"
332
+ else:
333
+ auth_str = "[dim]Unknown[/dim]"
334
+
335
+ notes_str = ", ".join(p.diagnostics) if p.diagnostics else ""
336
+
337
+ table.add_row(
338
+ p.display_name,
339
+ cli_str,
340
+ version_str,
341
+ auth_str,
342
+ notes_str,
343
+ )
344
+
345
+ console.print("[bold]Providers[/bold]\n")
346
+ console.print(table)
347
+
348
+ installed_count = sum(1 for p in report.providers if p.installed)
349
+ total_count = len(report.providers)
350
+ noun = "provider" if installed_count == 1 else "providers"
351
+ console.print(f"\n{installed_count} of {total_count} {noun} detected.\n")
352
+
353
+
354
+ @app.command(name="doctor")
355
+ def doctor_cmd(
356
+ json_output: Annotated[
357
+ bool,
358
+ typer.Option(
359
+ "--json",
360
+ help="Output diagnostics as clean, machine-readable JSON.",
361
+ ),
362
+ ] = False,
363
+ provider: Annotated[
364
+ list[str] | None,
365
+ typer.Option(
366
+ "--provider",
367
+ "-p",
368
+ help="Filter diagnostics to specific provider(s). Can be specified multiple times.",
369
+ ),
370
+ ] = None,
371
+ ) -> None:
372
+ """Inspect the local environment and detect installed AI coding agent CLIs."""
373
+ service = DoctorService()
374
+
375
+ provider_ids: list[ProviderId] | None = None
376
+ if provider:
377
+ try:
378
+ provider_ids = [ProviderId(p) for p in provider]
379
+ except ValueError as err:
380
+ err_console.print(f"[red]Error:[/red] {err}")
381
+ raise typer.Exit(code=2) from err
382
+
383
+ try:
384
+ report = service.run_diagnostics(provider_ids=provider_ids)
385
+ except UnknownProviderError as err:
386
+ err_console.print(f"[red]Error:[/red] {err}")
387
+ raise typer.Exit(code=2) from err
388
+
389
+ if json_output:
390
+ sys.stdout.write(report.model_dump_json(indent=2) + "\n")
391
+ else:
392
+ _render_rich_doctor(report)
393
+
394
+
395
+ # --- Init Command ---
396
+
397
+
398
+ @app.command(name="init")
399
+ def init_cmd(
400
+ path: Annotated[
401
+ str | None,
402
+ typer.Argument(
403
+ help="Target repository directory to initialize. Defaults to current directory.",
404
+ ),
405
+ ] = None,
406
+ name: Annotated[
407
+ str | None,
408
+ typer.Option(
409
+ "--name",
410
+ "-n",
411
+ help="Custom project name. Defaults to directory name.",
412
+ ),
413
+ ] = None,
414
+ ) -> None:
415
+ """Initialize a project-local CortexShift workspace."""
416
+ service = ProjectInitializationService()
417
+ target_path = Path(path) if path else Path.cwd()
418
+
419
+ try:
420
+ result = service.initialize(target_path=target_path, name=name)
421
+ except Exception as err:
422
+ _handle_error(err)
423
+ return
424
+
425
+ if result.already_initialized:
426
+ console.print(f"CortexShift is already initialized for {result.project.name}.")
427
+ else:
428
+ console.print("\n[bold]Initialized CortexShift[/bold]\n")
429
+ table = Table.grid(padding=(0, 2))
430
+ table.add_column(style="dim", min_width=10)
431
+ table.add_column()
432
+ table.add_row("Project", result.project.name)
433
+ table.add_row("Path", result.project.repo_path)
434
+ table.add_row("State", f"{STATE_DIR_NAME}/{DATABASE_FILE_NAME}")
435
+ console.print(table)
436
+ console.print()
437
+
438
+
439
+ # --- Status Command ---
440
+
441
+
442
+ def _render_rich_status(status: ProjectStatus) -> None:
443
+ """Render ProjectStatus using Rich formatting."""
444
+ console.print("\n[bold]CortexShift Status[/bold]\n")
445
+
446
+ console.print("[bold]Project[/bold]")
447
+ console.print(f" {status.name}")
448
+ console.print(f" [dim]{status.project_id}[/dim]\n")
449
+
450
+ console.print("[bold]State[/bold]")
451
+ console.print(f" {status.state_file}")
452
+ console.print(f" [dim]Schema v{status.schema_version}[/dim]\n")
453
+
454
+ console.print("[bold]Active Task[/bold]")
455
+ if status.active_task is None:
456
+ console.print(" [dim]None[/dim]\n")
457
+ else:
458
+ task = status.active_task
459
+ console.print(f" [dim]{task.id}[/dim]")
460
+ console.print(f" {task.title}")
461
+ console.print(f" [cyan]{task.status.value}[/cyan]\n")
462
+
463
+ console.print("[bold]Progress[/bold]")
464
+ console.print(f" Completed {task.progress.completed}")
465
+ console.print(f" Remaining {task.progress.remaining}")
466
+ console.print(f" Issues {task.progress.issues}\n")
467
+
468
+
469
+ @app.command(name="status")
470
+ def status_cmd(
471
+ json_output: Annotated[
472
+ bool,
473
+ typer.Option(
474
+ "--json",
475
+ help="Output status as clean, machine-readable JSON.",
476
+ ),
477
+ ] = False,
478
+ ) -> None:
479
+ """Display project identity, state location, active task, and progress."""
480
+ service = ProjectStatusService()
481
+
482
+ try:
483
+ status = service.get_status()
484
+ except Exception as err:
485
+ _handle_error(err)
486
+ return
487
+
488
+ if json_output:
489
+ sys.stdout.write(status.model_dump_json(indent=2) + "\n")
490
+ else:
491
+ _render_rich_status(status)
492
+
493
+
494
+ # --- Task Commands ---
495
+
496
+
497
+ @task_app.command(name="start")
498
+ def task_start_cmd(
499
+ title_arg: Annotated[
500
+ str | None,
501
+ typer.Argument(
502
+ help="Title of the task (can also be passed via --title).",
503
+ ),
504
+ ] = None,
505
+ title: Annotated[
506
+ str | None,
507
+ typer.Option(
508
+ "--title",
509
+ "-t",
510
+ help="Title of the task.",
511
+ ),
512
+ ] = None,
513
+ objective: Annotated[
514
+ str | None,
515
+ typer.Option(
516
+ "--objective",
517
+ "-o",
518
+ help="High-level objective and requirements for the task.",
519
+ ),
520
+ ] = None,
521
+ requirement: Annotated[
522
+ list[str] | None,
523
+ typer.Option(
524
+ "--requirement",
525
+ "-r",
526
+ help="Requirement for the task. Can be specified multiple times.",
527
+ ),
528
+ ] = None,
529
+ constraint: Annotated[
530
+ list[str] | None,
531
+ typer.Option(
532
+ "--constraint",
533
+ "-c",
534
+ help="Constraint for the task. Can be specified multiple times.",
535
+ ),
536
+ ] = None,
537
+ set_active: Annotated[
538
+ bool,
539
+ typer.Option(
540
+ "--set-active/--no-set-active",
541
+ help="Set the newly created task as active immediately.",
542
+ ),
543
+ ] = True,
544
+ ) -> None:
545
+ """Create a new task and optionally set it as active."""
546
+ effective_title = title or title_arg
547
+ if not effective_title:
548
+ if sys.stdin.isatty():
549
+ effective_title = typer.prompt("Task title")
550
+ else:
551
+ err_console.print(
552
+ "[red]Error:[/red] Missing required option '--title' (or positional title)."
553
+ )
554
+ raise typer.Exit(code=2)
555
+
556
+ if not objective:
557
+ if sys.stdin.isatty():
558
+ objective = typer.prompt("Task objective")
559
+ else:
560
+ err_console.print("[red]Error:[/red] Missing required option '--objective'.")
561
+ raise typer.Exit(code=2)
562
+
563
+ try:
564
+ project, store = _get_project_and_store()
565
+ except Exception as err:
566
+ _handle_error(err)
567
+ return
568
+
569
+ try:
570
+ with store:
571
+ service = TaskService(store)
572
+ task = service.start_task(
573
+ project_id=project.id,
574
+ title=effective_title,
575
+ objective=objective,
576
+ requirements=requirement,
577
+ constraints=constraint,
578
+ set_active=set_active,
579
+ )
580
+ except Exception as err:
581
+ _handle_error(err)
582
+ return
583
+
584
+ active_tag = " [green](active)[/green]" if set_active else ""
585
+ console.print(f"\n[bold]Started task[/bold] [cyan]{task.id}[/cyan]{active_tag}\n")
586
+ table = Table.grid(padding=(0, 2))
587
+ table.add_column(style="dim", min_width=14)
588
+ table.add_column()
589
+ table.add_row("Title", task.title)
590
+ table.add_row("Status", task.status.value)
591
+ table.add_row("Objective", task.objective)
592
+ if task.requirements:
593
+ table.add_row("Requirements", f"{len(task.requirements)} item(s)")
594
+ if task.constraints:
595
+ table.add_row("Constraints", f"{len(task.constraints)} item(s)")
596
+ console.print(table)
597
+ console.print()
598
+
599
+
600
+ @task_app.command(name="list")
601
+ def task_list_cmd(
602
+ status: Annotated[
603
+ str | None,
604
+ typer.Option(
605
+ "--status",
606
+ "-s",
607
+ help="Filter tasks by status (e.g. in_progress, completed).",
608
+ ),
609
+ ] = None,
610
+ all_tasks: Annotated[
611
+ bool,
612
+ typer.Option(
613
+ "--all",
614
+ "-a",
615
+ help="Include all tasks (default behavior).",
616
+ ),
617
+ ] = False,
618
+ json_output: Annotated[
619
+ bool,
620
+ typer.Option(
621
+ "--json",
622
+ help="Output task list as clean, machine-readable JSON.",
623
+ ),
624
+ ] = False,
625
+ ) -> None:
626
+ """List all persisted tasks for the current project."""
627
+ try:
628
+ project, store = _get_project_and_store()
629
+ except Exception as err:
630
+ _handle_error(err)
631
+ return
632
+
633
+ try:
634
+ with store:
635
+ service = TaskService(store)
636
+ tasks = service.list_tasks(project.id)
637
+ active_id = store.get_active_task_id(project.id)
638
+ except Exception as err:
639
+ _handle_error(err)
640
+ return
641
+
642
+ if status and not all_tasks:
643
+ norm_status = status.strip().lower()
644
+ valid_statuses = {s.value for s in TaskStatus}
645
+ if norm_status not in valid_statuses:
646
+ allowed = ", ".join(sorted(valid_statuses))
647
+ err_console.print(
648
+ f"[red]Error:[/red] Invalid status '{status}'. Valid options: {allowed}."
649
+ )
650
+ raise typer.Exit(code=2)
651
+ tasks = [t for t in tasks if t.status.value == norm_status]
652
+
653
+ if json_output:
654
+ tasks_data = [
655
+ {
656
+ **t.model_dump(mode="json"),
657
+ "is_active": (t.id == active_id),
658
+ }
659
+ for t in tasks
660
+ ]
661
+ sys.stdout.write(json.dumps(tasks_data, indent=2) + "\n")
662
+ return
663
+
664
+ if not tasks:
665
+ console.print("\nNo tasks found. Create one with `cortexshift task start`.\n")
666
+ return
667
+
668
+ table = Table(box=box.ROUNDED, show_header=True, header_style="bold")
669
+ table.add_column("ID", min_width=16)
670
+ table.add_column("Status", min_width=12)
671
+ table.add_column("Active", justify="center", min_width=8)
672
+ table.add_column("Title", min_width=24)
673
+
674
+ for t in tasks:
675
+ is_active = t.id == active_id
676
+ active_str = "[green]yes[/green]" if is_active else "[dim]no[/dim]"
677
+ table.add_row(
678
+ t.id,
679
+ t.status.value,
680
+ active_str,
681
+ t.title,
682
+ )
683
+
684
+ console.print()
685
+ console.print(table)
686
+ console.print()
687
+
688
+
689
+ def _render_rich_task_details(task: Task, is_active: bool) -> None:
690
+ """Render comprehensive task details in Rich format."""
691
+ console.print(f"\n[bold]Task Details[/bold] — [cyan]{task.id}[/cyan]\n")
692
+
693
+ active_tag = " [green](active)[/green]" if is_active else ""
694
+ table = Table.grid(padding=(0, 2))
695
+ table.add_column(style="dim", min_width=16)
696
+ table.add_column()
697
+ table.add_row("Title", task.title)
698
+ table.add_row("Status", f"{task.status.value}{active_tag}")
699
+ table.add_row("Objective", task.objective)
700
+
701
+ if task.current_work:
702
+ table.add_row("Current Work", task.current_work)
703
+
704
+ table.add_row("Created", task.created_at.isoformat())
705
+ table.add_row("Updated", task.updated_at.isoformat())
706
+ console.print(table)
707
+
708
+ def _render_list_section(title: str, items: list[str]) -> None:
709
+ console.print(f"\n[bold]{title}[/bold]")
710
+ if not items:
711
+ console.print(" [dim]—[/dim]")
712
+ else:
713
+ for item in items:
714
+ console.print(f" • {item}")
715
+
716
+ _render_list_section("Requirements", task.requirements)
717
+ _render_list_section("Constraints", task.constraints)
718
+ _render_list_section("Completed Items", task.completed_items)
719
+ _render_list_section("Remaining Items", task.remaining_items)
720
+ _render_list_section("Known Issues", task.known_issues)
721
+ console.print()
722
+
723
+
724
+ @task_app.command(name="show")
725
+ def task_show_cmd(
726
+ task_id: Annotated[
727
+ str | None,
728
+ typer.Argument(
729
+ help="Identifier of task to show. If omitted, shows the active task.",
730
+ ),
731
+ ] = None,
732
+ json_output: Annotated[
733
+ bool,
734
+ typer.Option(
735
+ "--json",
736
+ help="Output task details as clean, machine-readable JSON.",
737
+ ),
738
+ ] = False,
739
+ ) -> None:
740
+ """Inspect full details of a task (or active task if none specified)."""
741
+ try:
742
+ project, store = _get_project_and_store()
743
+ except Exception as err:
744
+ _handle_error(err)
745
+ return
746
+
747
+ try:
748
+ with store:
749
+ service = TaskService(store)
750
+ active_id = store.get_active_task_id(project.id)
751
+
752
+ if task_id is None:
753
+ if active_id is None:
754
+ raise NoActiveTaskError("No active task for this project.")
755
+ target_id = active_id
756
+ else:
757
+ target_id = task_id
758
+
759
+ task = service.get_task(target_id)
760
+ if task.project_id != project.id:
761
+ raise TaskNotFoundError(target_id)
762
+ except Exception as err:
763
+ _handle_error(err)
764
+ return
765
+
766
+ if json_output:
767
+ task_data = {
768
+ **task.model_dump(mode="json"),
769
+ "is_active": (task.id == active_id),
770
+ }
771
+ sys.stdout.write(json.dumps(task_data, indent=2) + "\n")
772
+ else:
773
+ _render_rich_task_details(task, is_active=(task.id == active_id))
774
+
775
+
776
+ @task_app.command(name="activate")
777
+ def task_activate_cmd(
778
+ task_id: Annotated[
779
+ str,
780
+ typer.Argument(
781
+ help="Identifier of the task to make active.",
782
+ ),
783
+ ],
784
+ ) -> None:
785
+ """Set an existing task as the active task."""
786
+ try:
787
+ project, store = _get_project_and_store()
788
+ except Exception as err:
789
+ _handle_error(err)
790
+ return
791
+
792
+ try:
793
+ with store:
794
+ service = TaskService(store)
795
+ task = service.activate_task(project.id, task_id)
796
+ except Exception as err:
797
+ _handle_error(err)
798
+ return
799
+
800
+ console.print(f"Activated task {task.id}")
801
+
802
+
803
+ @task_app.command(name="complete")
804
+ def task_complete_cmd(
805
+ task_id: Annotated[
806
+ str | None,
807
+ typer.Argument(
808
+ help="Identifier of the task to complete. Defaults to active task.",
809
+ ),
810
+ ] = None,
811
+ ) -> None:
812
+ """Mark the active task (or specified task) as completed."""
813
+ try:
814
+ project, store = _get_project_and_store()
815
+ except Exception as err:
816
+ _handle_error(err)
817
+ return
818
+
819
+ try:
820
+ with store:
821
+ service = TaskService(store)
822
+ task = service.complete_task(project.id, task_id)
823
+ except Exception as err:
824
+ _handle_error(err)
825
+ return
826
+
827
+ console.print(f"Completed task {task.id}")
828
+
829
+
830
+ @task_app.command(name="update")
831
+ def task_update_cmd(
832
+ task_id: Annotated[
833
+ str | None,
834
+ typer.Argument(
835
+ help="Identifier of the task to update. Defaults to active task.",
836
+ ),
837
+ ] = None,
838
+ current_work: Annotated[
839
+ str | None,
840
+ typer.Option(
841
+ "--current-work",
842
+ "--work",
843
+ "-w",
844
+ help="Description of currently active work in flight.",
845
+ ),
846
+ ] = None,
847
+ clear_current_work: Annotated[
848
+ bool,
849
+ typer.Option(
850
+ "--clear-current-work",
851
+ help="Clear in-flight work description.",
852
+ ),
853
+ ] = False,
854
+ add_completed: Annotated[
855
+ list[str] | None,
856
+ typer.Option(
857
+ "--add-completed",
858
+ help="Add item to completed list. Can be repeated.",
859
+ ),
860
+ ] = None,
861
+ add_remaining: Annotated[
862
+ list[str] | None,
863
+ typer.Option(
864
+ "--add-remaining",
865
+ help="Add item to remaining list. Can be repeated.",
866
+ ),
867
+ ] = None,
868
+ add_issue: Annotated[
869
+ list[str] | None,
870
+ typer.Option(
871
+ "--add-issue",
872
+ "--add-known-issue",
873
+ help="Add item to known issues list. Can be repeated.",
874
+ ),
875
+ ] = None,
876
+ ) -> None:
877
+ """Update progress, current work, and issues on a task."""
878
+ try:
879
+ project, store = _get_project_and_store()
880
+ except Exception as err:
881
+ _handle_error(err)
882
+ return
883
+
884
+ try:
885
+ with store:
886
+ service = TaskService(store)
887
+ task = service.update_task(
888
+ project_id=project.id,
889
+ task_id=task_id,
890
+ current_work=current_work,
891
+ clear_current_work=clear_current_work,
892
+ add_completed=add_completed,
893
+ add_remaining=add_remaining,
894
+ add_issues=add_issue,
895
+ )
896
+ except Exception as err:
897
+ _handle_error(err)
898
+ return
899
+
900
+ console.print(f"Updated task {task.id}")
901
+
902
+
903
+ # --- Repository CLI Commands ---
904
+
905
+
906
+ def _format_safe_path(path: str) -> str:
907
+ """Safely escape paths for terminal display avoiding ANSI/control code injection."""
908
+ sanitized = "".join(c if (c >= " " and c != "\x7f") else f"\\x{ord(c):02x}" for c in path)
909
+ return escape(sanitized)
910
+
911
+
912
+ def _render_file_list(title: str, files: list[str], max_display: int = 20) -> None:
913
+ """Render a bounded list of files in human-readable output."""
914
+ if not files:
915
+ return
916
+ console.print(f" [bold]{title}[/bold] ({len(files)}):")
917
+ for f in files[:max_display]:
918
+ console.print(f" {_format_safe_path(f)}")
919
+ if len(files) > max_display:
920
+ console.print(f" [dim]... and {len(files) - max_display} more[/dim]")
921
+
922
+
923
+ def _format_repo_path(path_str: str, project_root_str: str) -> str:
924
+ """Display project and git paths compactly avoiding unnecessary absolute home paths."""
925
+ try:
926
+ p = Path(path_str).resolve()
927
+ proj = Path(project_root_str).resolve()
928
+ if p == proj:
929
+ return "."
930
+ if proj.is_relative_to(p):
931
+ rel = ".."
932
+ cur = proj.parent
933
+ while cur != p and cur != cur.parent:
934
+ rel += "/.."
935
+ cur = cur.parent
936
+ return rel
937
+ return str(p)
938
+ except Exception:
939
+ return path_str
940
+
941
+
942
+ @repo_app.command("status")
943
+ def repo_status(
944
+ json_output: Annotated[
945
+ bool,
946
+ typer.Option("--json", help="Output repository inspection in JSON format."),
947
+ ] = False,
948
+ ) -> None:
949
+ """Inspect the live repository state and changed files."""
950
+ try:
951
+ service = RepositoryService()
952
+ inspection = service.inspect_repository()
953
+ except Exception as err:
954
+ _handle_error(err)
955
+ return
956
+
957
+ if json_output:
958
+ sys.stdout.write(inspection.model_dump_json(indent=2) + "\n")
959
+ return
960
+
961
+ if inspection.status == RepositoryInspectionStatus.GIT_NOT_INSTALLED:
962
+ console.print("\n[bold]Repository[/bold]\n")
963
+ console.print("[yellow]Git executable was not found in PATH.[/yellow]\n")
964
+ return
965
+
966
+ if inspection.status == RepositoryInspectionStatus.NOT_GIT_REPOSITORY:
967
+ console.print("\n[bold]Repository[/bold]\n")
968
+ console.print(
969
+ "Git is available, but this CortexShift project is not inside a Git repository.\n"
970
+ )
971
+ return
972
+
973
+ if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
974
+ console.print("\n[bold]Repository[/bold]\n")
975
+ console.print(
976
+ f"[red]{inspection.diagnostic or 'Git repository inspection failed.'}[/red]\n"
977
+ )
978
+ return
979
+
980
+ snapshot = inspection.snapshot
981
+ if snapshot is None:
982
+ console.print("\n[bold]Repository[/bold]\n")
983
+ console.print("[yellow]No repository information available.[/yellow]\n")
984
+ return
985
+
986
+ console.print("\n[bold]Repository[/bold]\n")
987
+
988
+ grid = Table.grid(padding=(0, 2))
989
+ grid.add_column(style="bold cyan", justify="left")
990
+ grid.add_column(style="default", justify="left")
991
+
992
+ if snapshot.git_version:
993
+ grid.add_row("Git", snapshot.git_version)
994
+
995
+ git_root_display = _format_repo_path(snapshot.git_root, snapshot.project_root)
996
+ grid.add_row("Root", git_root_display)
997
+ grid.add_row("Branch", snapshot.branch if snapshot.branch else "[dim](none / detached)[/dim]")
998
+ grid.add_row("HEAD", snapshot.head_sha[:8] if snapshot.head_sha else "[dim](unborn)[/dim]")
999
+ state_display = "[yellow]Dirty[/yellow]" if snapshot.dirty else "[green]Clean[/green]"
1000
+ grid.add_row("State", state_display)
1001
+ console.print(grid)
1002
+
1003
+ console.print("\n[bold]Changes[/bold]")
1004
+ ch_grid = Table.grid(padding=(0, 2))
1005
+ ch_grid.add_column(style="dim", justify="left")
1006
+ ch_grid.add_column(style="default", justify="left")
1007
+ ch_grid.add_row(" Staged", str(len(snapshot.staged_files)))
1008
+ ch_grid.add_row(" Modified", str(len(snapshot.modified_files)))
1009
+ ch_grid.add_row(" Untracked", str(len(snapshot.untracked_files)))
1010
+ ch_grid.add_row(" Conflicted", str(len(snapshot.conflicted_files)))
1011
+ console.print(ch_grid)
1012
+
1013
+ if snapshot.working_tree_diff_summary:
1014
+ console.print(f"\n[bold]Working tree[/bold]\n {snapshot.working_tree_diff_summary}")
1015
+ if snapshot.staged_diff_summary:
1016
+ console.print(f"\n[bold]Staged[/bold]\n {snapshot.staged_diff_summary}")
1017
+
1018
+ if snapshot.staged_files:
1019
+ console.print()
1020
+ _render_file_list("Staged", snapshot.staged_files)
1021
+ if snapshot.modified_files:
1022
+ console.print()
1023
+ _render_file_list("Modified", snapshot.modified_files)
1024
+ if snapshot.untracked_files:
1025
+ console.print()
1026
+ _render_file_list("Untracked", snapshot.untracked_files)
1027
+ if snapshot.conflicted_files:
1028
+ console.print()
1029
+ _render_file_list("Conflicted", snapshot.conflicted_files)
1030
+
1031
+ console.print()
1032
+
1033
+
1034
+ @repo_app.command("snapshot")
1035
+ def repo_snapshot(
1036
+ json_output: Annotated[
1037
+ bool,
1038
+ typer.Option("--json", help="Output persisted snapshot in JSON format."),
1039
+ ] = False,
1040
+ ) -> None:
1041
+ """Capture and persist a point-in-time Git repository snapshot."""
1042
+ try:
1043
+ service = RepositoryService()
1044
+ snapshot = service.capture_snapshot()
1045
+ except Exception as err:
1046
+ _handle_error(err)
1047
+ return
1048
+
1049
+ if json_output:
1050
+ sys.stdout.write(snapshot.model_dump_json(indent=2) + "\n")
1051
+ return
1052
+
1053
+ console.print("\n[bold green]Repository snapshot captured[/bold green]\n")
1054
+ grid = Table.grid(padding=(0, 2))
1055
+ grid.add_column(style="bold cyan", justify="left")
1056
+ grid.add_column(style="default", justify="left")
1057
+
1058
+ total_changed = (
1059
+ len(snapshot.staged_files)
1060
+ + len(snapshot.modified_files)
1061
+ + len(snapshot.untracked_files)
1062
+ + len(snapshot.conflicted_files)
1063
+ )
1064
+
1065
+ grid.add_row("Snapshot", snapshot.id)
1066
+ grid.add_row("Branch", snapshot.branch if snapshot.branch else "[dim](none / detached)[/dim]")
1067
+ grid.add_row("HEAD", snapshot.head_sha[:8] if snapshot.head_sha else "[dim](unborn)[/dim]")
1068
+ grid.add_row("State", "[yellow]Dirty[/yellow]" if snapshot.dirty else "[green]Clean[/green]")
1069
+ grid.add_row("Changed", f"{total_changed} files" if total_changed > 0 else "[dim]Clean[/dim]")
1070
+ console.print(grid)
1071
+ console.print()
1072
+
1073
+
1074
+ @repo_app.command("snapshots")
1075
+ def repo_snapshots(
1076
+ limit: Annotated[
1077
+ int,
1078
+ typer.Option("--limit", "-n", help="Maximum number of snapshots to display."),
1079
+ ] = 10,
1080
+ json_output: Annotated[
1081
+ bool,
1082
+ typer.Option("--json", help="Output snapshot list in JSON format."),
1083
+ ] = False,
1084
+ ) -> None:
1085
+ """List historical repository snapshots."""
1086
+ try:
1087
+ service = RepositoryService()
1088
+ snapshots = service.list_snapshots(limit=limit)
1089
+ except Exception as err:
1090
+ _handle_error(err)
1091
+ return
1092
+
1093
+ if json_output:
1094
+ sys.stdout.write(
1095
+ json.dumps([snap.model_dump(mode="json") for snap in snapshots], indent=2) + "\n"
1096
+ )
1097
+ return
1098
+
1099
+ if not snapshots:
1100
+ console.print("\n[dim]No repository snapshots captured yet.[/dim]\n")
1101
+ return
1102
+
1103
+ table = Table(
1104
+ box=box.ROUNDED,
1105
+ show_header=True,
1106
+ header_style="bold cyan",
1107
+ title="\nRepository Snapshots",
1108
+ )
1109
+ table.add_column("Snapshot ID", style="cyan")
1110
+ table.add_column("Captured (UTC)", style="white")
1111
+ table.add_column("Branch", style="white")
1112
+ table.add_column("HEAD", style="dim")
1113
+ table.add_column("Dirty", justify="center")
1114
+
1115
+ for s in snapshots:
1116
+ head_disp = s.head_sha[:8] if s.head_sha else "-"
1117
+ branch_disp = s.branch if s.branch else "(detached)"
1118
+ dirty_disp = "[yellow]yes[/yellow]" if s.dirty else "[green]no[/green]"
1119
+ captured_str = s.captured_at.strftime("%Y-%m-%d %H:%M:%S")
1120
+ table.add_row(s.id, captured_str, branch_disp, head_disp, dirty_disp)
1121
+
1122
+ console.print(table)
1123
+ console.print()
1124
+
1125
+
1126
+ @repo_app.command("show")
1127
+ def repo_show(
1128
+ snapshot_id: Annotated[
1129
+ str,
1130
+ typer.Argument(help="Identifier of the snapshot to inspect."),
1131
+ ],
1132
+ json_output: Annotated[
1133
+ bool,
1134
+ typer.Option("--json", help="Output snapshot details in JSON format."),
1135
+ ] = False,
1136
+ ) -> None:
1137
+ """Display detailed information for a stored repository snapshot."""
1138
+ try:
1139
+ service = RepositoryService()
1140
+ snapshot = service.get_snapshot(snapshot_id)
1141
+ if snapshot is None:
1142
+ raise SnapshotNotFoundError(snapshot_id)
1143
+ except Exception as err:
1144
+ _handle_error(err)
1145
+ return
1146
+
1147
+ if json_output:
1148
+ sys.stdout.write(snapshot.model_dump_json(indent=2) + "\n")
1149
+ return
1150
+
1151
+ console.print(f"\n[bold]Repository Snapshot: {snapshot.id}[/bold]\n")
1152
+ grid = Table.grid(padding=(0, 2))
1153
+ grid.add_column(style="bold cyan", justify="left")
1154
+ grid.add_column(style="default", justify="left")
1155
+
1156
+ grid.add_row("Captured (UTC)", snapshot.captured_at.strftime("%Y-%m-%d %H:%M:%S"))
1157
+ if snapshot.git_version:
1158
+ grid.add_row("Git Version", snapshot.git_version)
1159
+ grid.add_row("Git Root", snapshot.git_root)
1160
+ grid.add_row("Branch", snapshot.branch if snapshot.branch else "[dim](none / detached)[/dim]")
1161
+ grid.add_row("HEAD", snapshot.head_sha if snapshot.head_sha else "[dim](unborn)[/dim]")
1162
+ grid.add_row("State", "[yellow]Dirty[/yellow]" if snapshot.dirty else "[green]Clean[/green]")
1163
+ console.print(grid)
1164
+
1165
+ console.print("\n[bold]Changes[/bold]")
1166
+ ch_grid = Table.grid(padding=(0, 2))
1167
+ ch_grid.add_column(style="dim", justify="left")
1168
+ ch_grid.add_column(style="default", justify="left")
1169
+ ch_grid.add_row(" Staged", str(len(snapshot.staged_files)))
1170
+ ch_grid.add_row(" Modified", str(len(snapshot.modified_files)))
1171
+ ch_grid.add_row(" Untracked", str(len(snapshot.untracked_files)))
1172
+ ch_grid.add_row(" Conflicted", str(len(snapshot.conflicted_files)))
1173
+ console.print(ch_grid)
1174
+
1175
+ if snapshot.working_tree_diff_summary:
1176
+ console.print(f"\n[bold]Working tree[/bold]\n {snapshot.working_tree_diff_summary}")
1177
+ if snapshot.staged_diff_summary:
1178
+ console.print(f"\n[bold]Staged[/bold]\n {snapshot.staged_diff_summary}")
1179
+
1180
+ if snapshot.staged_files:
1181
+ console.print()
1182
+ _render_file_list("Staged", snapshot.staged_files)
1183
+ if snapshot.modified_files:
1184
+ console.print()
1185
+ _render_file_list("Modified", snapshot.modified_files)
1186
+ if snapshot.untracked_files:
1187
+ console.print()
1188
+ _render_file_list("Untracked", snapshot.untracked_files)
1189
+ if snapshot.conflicted_files:
1190
+ console.print()
1191
+ _render_file_list("Conflicted", snapshot.conflicted_files)
1192
+
1193
+ console.print()
1194
+
1195
+
1196
+ # --- Provider Run Command ---
1197
+
1198
+
1199
+ @app.command("run", no_args_is_help=False)
1200
+ def run_command(
1201
+ provider: Annotated[
1202
+ str,
1203
+ typer.Argument(
1204
+ help="Canonical provider identifier to launch (claude, codex, antigravity).",
1205
+ ),
1206
+ ],
1207
+ prompt: Annotated[
1208
+ str | None,
1209
+ typer.Option(
1210
+ "--prompt",
1211
+ "-p",
1212
+ help="Optional initial prompt to pass to the provider.",
1213
+ ),
1214
+ ] = None,
1215
+ dry_run: Annotated[
1216
+ bool,
1217
+ typer.Option(
1218
+ "--dry-run",
1219
+ help="Simulate launch and display specification without starting process.",
1220
+ ),
1221
+ ] = False,
1222
+ json_output: Annotated[
1223
+ bool,
1224
+ typer.Option(
1225
+ "--json",
1226
+ help="Output machine-readable JSON (only valid with --dry-run).",
1227
+ ),
1228
+ ] = False,
1229
+ ) -> None:
1230
+ """Launch an interactive native coding agent session on the active task."""
1231
+ if json_output and not dry_run:
1232
+ err_console.print("[red]Error:[/red] --json is only supported with --dry-run.")
1233
+ raise typer.Exit(code=1)
1234
+
1235
+ run_service = RunService()
1236
+
1237
+ if dry_run:
1238
+ try:
1239
+ result = run_service.dry_run(provider_name=provider, prompt=prompt)
1240
+ except Exception as err:
1241
+ _handle_error(err)
1242
+ return
1243
+
1244
+ if json_output:
1245
+ sys.stdout.write(json.dumps(result.to_dict(), indent=2) + "\n")
1246
+ return
1247
+
1248
+ console.print("\n[bold]CortexShift Provider Launch (Dry Run)[/bold]\n")
1249
+ grid = Table.grid(padding=(0, 2))
1250
+ grid.add_column(style="bold cyan", justify="left")
1251
+ grid.add_column(style="default", justify="left")
1252
+ grid.add_row("Provider", result.display_name)
1253
+ grid.add_row("Executable", result.executable)
1254
+ grid.add_row("Project", f"{result.project_name} ({result.project_id})")
1255
+ grid.add_row("Task", f"{result.task_title} ({result.task_id})")
1256
+ grid.add_row("Directory", str(result.cwd))
1257
+ grid.add_row("Mode", result.mode)
1258
+ grid.add_row("Prompt", "supplied" if result.prompt_supplied else "none")
1259
+ console.print(grid)
1260
+ console.print(f"\n[bold]Command:[/bold] {' '.join(result.argv)}\n")
1261
+ return
1262
+
1263
+ def _on_launch(
1264
+ _spec: LaunchSpecification,
1265
+ session: Session,
1266
+ task: Task,
1267
+ _project: Project,
1268
+ ) -> None:
1269
+
1270
+ console.print("\n[bold]CortexShift[/bold]")
1271
+ grid = Table.grid(padding=(0, 2))
1272
+ grid.add_column(style="bold cyan", justify="left")
1273
+ grid.add_column(style="default", justify="left")
1274
+ grid.add_row("Task", task.title)
1275
+ adapter = run_service._registry.get(provider)
1276
+ display_name = adapter.display_name if adapter else provider
1277
+ grid.add_row("Provider", display_name)
1278
+ grid.add_row("Session", session.id)
1279
+ console.print(grid)
1280
+ console.print("\n[dim]Launching native provider...[/dim]\n")
1281
+
1282
+ try:
1283
+ session = run_service.run(
1284
+ provider_name=provider,
1285
+ prompt=prompt,
1286
+ on_launch=_on_launch,
1287
+ )
1288
+ except Exception as err:
1289
+ _handle_error(err)
1290
+ return
1291
+
1292
+ if session.status == SessionStatus.COMPLETED:
1293
+ console.print("\nSession completed.")
1294
+ elif session.status == SessionStatus.INTERRUPTED:
1295
+ console.print("\nSession interrupted.")
1296
+ else:
1297
+ console.print("\nSession failed.")
1298
+ if session.exit_code:
1299
+ raise typer.Exit(code=session.exit_code)
1300
+ raise typer.Exit(code=1)
1301
+
1302
+
1303
+ @app.command("resume")
1304
+ def resume_command(
1305
+ provider: Annotated[str, typer.Argument(help="Provider to resume exactly.")],
1306
+ session_id: Annotated[
1307
+ str | None, typer.Option("--session", help="Source CortexShift Session ID.")
1308
+ ] = None,
1309
+ dry_run: Annotated[
1310
+ bool, typer.Option("--dry-run", help="Preview without side effects.")
1311
+ ] = False,
1312
+ json_output: Annotated[bool, typer.Option("--json", help="Requires --dry-run.")] = False,
1313
+ ) -> None:
1314
+ """Resume a known native conversation on the active task, without a handoff."""
1315
+ if json_output and not dry_run:
1316
+ err_console.print("[red]Error:[/red] --json is only supported with --dry-run.")
1317
+ raise typer.Exit(code=1)
1318
+ try:
1319
+ result = ResumeService().resume(provider, session_id, dry_run=dry_run)
1320
+ except Exception as err:
1321
+ _handle_error(err)
1322
+ return
1323
+ if isinstance(result, ResumeDryRunResult):
1324
+ if json_output:
1325
+ sys.stdout.write(json.dumps(result.to_dict(), indent=2) + "\n")
1326
+ else:
1327
+ console.print("\n[bold]CortexShift Resume (Dry Run)[/bold]")
1328
+ grid = Table.grid(padding=(0, 2))
1329
+ for key, value in result.to_dict().items():
1330
+ grid.add_row(key, escape(str(value)))
1331
+ console.print(grid)
1332
+ return
1333
+ console.print(f"\nSession {result.id}: {result.status.value}.")
1334
+ if result.status == SessionStatus.FAILED:
1335
+ console.print(
1336
+ "Exact native resume failed. Provider-owned state may no longer exist. "
1337
+ "The historical ID is preserved; no fresh session was started."
1338
+ )
1339
+ raise typer.Exit(code=result.exit_code or 1)
1340
+
1341
+
1342
+ def _native_resumable(session: Session) -> bool:
1343
+ adapter = ProviderRuntimeRegistry().get(str(session.provider_id))
1344
+ return is_native_resumable(session, native_capabilities(adapter))
1345
+
1346
+
1347
+ # --- Session Commands ---
1348
+
1349
+
1350
+ @session_app.command("list")
1351
+ def session_list_command(
1352
+ limit: Annotated[
1353
+ int,
1354
+ typer.Option(
1355
+ "--limit",
1356
+ help="Maximum number of sessions to return.",
1357
+ ),
1358
+ ] = 20,
1359
+ json_output: Annotated[
1360
+ bool,
1361
+ typer.Option(
1362
+ "--json",
1363
+ help="Output machine-readable JSON format.",
1364
+ ),
1365
+ ] = False,
1366
+ ) -> None:
1367
+ """List historical agent execution sessions."""
1368
+ service = SessionService()
1369
+ try:
1370
+ sessions = service.list_sessions(limit=limit)
1371
+ except Exception as err:
1372
+ _handle_error(err)
1373
+ return
1374
+
1375
+ if json_output:
1376
+ data = [
1377
+ {**s.model_dump(mode="json"), "native_resumable": _native_resumable(s)}
1378
+ for s in sessions
1379
+ ]
1380
+ sys.stdout.write(json.dumps(data, indent=2) + "\n")
1381
+ return
1382
+
1383
+ if not sessions:
1384
+ console.print("\nNo sessions found.\n")
1385
+ return
1386
+
1387
+ table = Table(
1388
+ title="Agent Sessions",
1389
+ box=box.ROUNDED,
1390
+ header_style="bold cyan",
1391
+ )
1392
+ table.add_column("Session", style="bold", min_width=12)
1393
+ table.add_column("Provider")
1394
+ table.add_column("Status")
1395
+ table.add_column("Native Resume", max_width=6)
1396
+ table.add_column("Started (UTC)")
1397
+ table.add_column("Task")
1398
+
1399
+ provider_names = {
1400
+ "claude": "Claude Code",
1401
+ "codex": "Codex",
1402
+ "antigravity": "Antigravity",
1403
+ }
1404
+
1405
+ for s in sessions:
1406
+ p_name = provider_names.get(str(s.provider_id).lower(), str(s.provider_id))
1407
+ status_style = (
1408
+ "green"
1409
+ if s.status == SessionStatus.COMPLETED
1410
+ else ("yellow" if s.status == SessionStatus.RUNNING else "red")
1411
+ )
1412
+ table.add_row(
1413
+ s.id,
1414
+ p_name,
1415
+ f"[{status_style}]{s.status.value}[/{status_style}]",
1416
+ "yes" if _native_resumable(s) else "no",
1417
+ s.started_at.strftime("%Y-%m-%d %H:%M:%S"),
1418
+ s.task_id,
1419
+ )
1420
+
1421
+ console.print()
1422
+ console.print(table)
1423
+ console.print()
1424
+
1425
+
1426
+ @session_app.command("show")
1427
+ def session_show_command(
1428
+ session_id: Annotated[
1429
+ str,
1430
+ typer.Argument(
1431
+ help="Identifier of the session to inspect.",
1432
+ ),
1433
+ ],
1434
+ json_output: Annotated[
1435
+ bool,
1436
+ typer.Option(
1437
+ "--json",
1438
+ help="Output machine-readable JSON format.",
1439
+ ),
1440
+ ] = False,
1441
+ ) -> None:
1442
+ """Show details of a specific agent execution session."""
1443
+ service = SessionService()
1444
+ try:
1445
+ session = service.get_session(session_id)
1446
+ except Exception as err:
1447
+ _handle_error(err)
1448
+ return
1449
+
1450
+ if json_output:
1451
+ sys.stdout.write(
1452
+ json.dumps(
1453
+ {**session.model_dump(mode="json"), "native_resumable": _native_resumable(session)},
1454
+ indent=2,
1455
+ )
1456
+ + "\n"
1457
+ )
1458
+ return
1459
+
1460
+ provider_names = {
1461
+ "claude": "Claude Code",
1462
+ "codex": "Codex",
1463
+ "antigravity": "Antigravity",
1464
+ }
1465
+ p_name = provider_names.get(str(session.provider_id).lower(), str(session.provider_id))
1466
+
1467
+ console.print(f"\n[bold]Session: {session.id}[/bold]\n")
1468
+ grid = Table.grid(padding=(0, 2))
1469
+ grid.add_column(style="bold cyan", justify="left")
1470
+ grid.add_column(style="default", justify="left")
1471
+ grid.add_row("Task ID", session.task_id)
1472
+ grid.add_row("Provider", p_name)
1473
+ grid.add_row("Status", session.status.value)
1474
+ grid.add_row("Native Session ID", escape(session.native_session_id or "—"))
1475
+ grid.add_row("Native resumable", "yes" if _native_resumable(session) else "no")
1476
+ grid.add_row("Resumed from CortexShift Session", session.resumed_from_session_id or "—")
1477
+ grid.add_row("Started (UTC)", session.started_at.strftime("%Y-%m-%d %H:%M:%S"))
1478
+ grid.add_row(
1479
+ "Ended (UTC)",
1480
+ session.ended_at.strftime("%Y-%m-%d %H:%M:%S") if session.ended_at else "—",
1481
+ )
1482
+ grid.add_row("Exit Reason", session.exit_reason.value if session.exit_reason else "—")
1483
+ grid.add_row("Exit Code", str(session.exit_code) if session.exit_code is not None else "—")
1484
+ console.print(grid)
1485
+ console.print()
1486
+
1487
+
1488
+ # --- Handoff Commands ---
1489
+
1490
+ PROVIDER_DISPLAY_NAMES = {
1491
+ "claude": "Claude Code",
1492
+ "codex": "Codex",
1493
+ "antigravity": "Antigravity",
1494
+ }
1495
+
1496
+
1497
+ def _provider_label(provider_id: str) -> str:
1498
+ """Render a canonical provider ID as its human-readable display name."""
1499
+ return PROVIDER_DISPLAY_NAMES.get(provider_id.lower(), provider_id)
1500
+
1501
+
1502
+ def _render_handoff_summary(handoff: HandoffRecord) -> None:
1503
+ """Render the canonical engineering context of a handoff for human inspection."""
1504
+ payload = handoff.payload
1505
+
1506
+ console.print(f"\n[bold]Handoff: {handoff.id}[/bold]\n")
1507
+ grid = Table.grid(padding=(0, 2))
1508
+ grid.add_column(style="bold cyan", justify="left")
1509
+ grid.add_column(style="default", justify="left")
1510
+ grid.add_row("Protocol", f"CortexShift Handoff Protocol v{handoff.protocol_version}")
1511
+ grid.add_row("Status", handoff.status.value)
1512
+ grid.add_row("From", f"{_provider_label(str(handoff.source_provider_id))}")
1513
+ grid.add_row("Source Session", handoff.source_session_id)
1514
+ grid.add_row("To", f"{_provider_label(str(handoff.target_provider_id))}")
1515
+ grid.add_row("Target Session", handoff.target_session_id or "—")
1516
+ grid.add_row("Task", f"{escape(payload.task_title)} ({handoff.task_id})")
1517
+ grid.add_row("Git Snapshot", handoff.git_snapshot_id or "—")
1518
+ grid.add_row("Created (UTC)", handoff.created_at.strftime("%Y-%m-%d %H:%M:%S"))
1519
+ grid.add_row(
1520
+ "Delivered (UTC)",
1521
+ handoff.delivered_at.strftime("%Y-%m-%d %H:%M:%S") if handoff.delivered_at else "—",
1522
+ )
1523
+ grid.add_row("Failure", handoff.failure_code.value if handoff.failure_code else "—")
1524
+ console.print(grid)
1525
+
1526
+ console.print(f"\n[bold]Original Objective[/bold]\n {escape(payload.original_objective)}")
1527
+ _render_bullet_section("Requirements", payload.requirements)
1528
+ _render_bullet_section("Constraints", payload.constraints)
1529
+ _render_bullet_section("Completed (recorded, verify against repository)", payload.completed)
1530
+ console.print(
1531
+ f"\n[bold]Current Work[/bold]\n "
1532
+ f"{escape(payload.current_work) if payload.current_work else '[dim](none)[/dim]'}"
1533
+ )
1534
+ _render_bullet_section("Remaining", payload.remaining)
1535
+ console.print(
1536
+ "\n[bold]Important Decisions[/bold]\n "
1537
+ + (
1538
+ "\n ".join(escape(d) for d in payload.important_decisions)
1539
+ if payload.decisions_known and payload.important_decisions
1540
+ else "[dim]No structured decisions are recorded in CortexShift state.[/dim]"
1541
+ )
1542
+ )
1543
+ _render_bullet_section("Known Issues", payload.known_issues)
1544
+
1545
+ console.print("\n[bold]Files Touched[/bold]")
1546
+ if payload.files_touched:
1547
+ _render_file_list("Paths", payload.files_touched)
1548
+ else:
1549
+ console.print(" [dim](none observed)[/dim]")
1550
+
1551
+ console.print(f"\n[bold]Test Status[/bold]\n [dim]{escape(payload.test_status.summary)}[/dim]")
1552
+
1553
+ git = payload.git_state
1554
+ console.print("\n[bold]Git State[/bold]")
1555
+ git_grid = Table.grid(padding=(0, 2))
1556
+ git_grid.add_column(style="dim", justify="left")
1557
+ git_grid.add_column(style="default", justify="left")
1558
+ git_grid.add_row(" Status", git.status.value)
1559
+ if git.available:
1560
+ git_grid.add_row(" Branch", git.branch or "(detached / unborn)")
1561
+ git_grid.add_row(" HEAD", git.head_sha[:12] if git.head_sha else "(unborn)")
1562
+ git_grid.add_row(" Working tree", "dirty" if git.dirty else "clean")
1563
+ git_grid.add_row(
1564
+ " Changes",
1565
+ f"staged {git.staged_count}, modified {git.modified_count}, "
1566
+ f"untracked {git.untracked_count}, conflicted {git.conflicted_count}",
1567
+ )
1568
+ console.print(git_grid)
1569
+ console.print(f" [dim]{escape(git.note)}[/dim]")
1570
+
1571
+ if payload.operator_note:
1572
+ console.print(f"\n[bold]Operator Note[/bold]\n {escape(payload.operator_note)}")
1573
+
1574
+ console.print(
1575
+ f"\n[bold]Recommended Next Action[/bold]\n {escape(payload.recommended_next_action)}\n"
1576
+ )
1577
+
1578
+
1579
+ def _render_bullet_section(title: str, items: list[str], max_display: int = 30) -> None:
1580
+ """Render a bounded bulleted list section for human output."""
1581
+ console.print(f"\n[bold]{title}[/bold]")
1582
+ if not items:
1583
+ console.print(" [dim](none recorded)[/dim]")
1584
+ return
1585
+ for item in items[:max_display]:
1586
+ console.print(f" - {escape(item)}")
1587
+ if len(items) > max_display:
1588
+ console.print(f" [dim]... and {len(items) - max_display} more[/dim]")
1589
+
1590
+
1591
+ @handoff_app.command("preview")
1592
+ def handoff_preview_command(
1593
+ target: Annotated[
1594
+ str,
1595
+ typer.Argument(
1596
+ help="Canonical target provider identifier (claude, codex, antigravity).",
1597
+ ),
1598
+ ],
1599
+ from_session: Annotated[
1600
+ str | None,
1601
+ typer.Option(
1602
+ "--from-session",
1603
+ help="Explicit source session ID to hand off from (defaults to the latest).",
1604
+ ),
1605
+ ] = None,
1606
+ note: Annotated[
1607
+ str | None,
1608
+ typer.Option("--note", help="Optional operator note to include as advisory context."),
1609
+ ] = None,
1610
+ json_output: Annotated[
1611
+ bool,
1612
+ typer.Option("--json", help="Output machine-readable JSON format."),
1613
+ ] = False,
1614
+ ) -> None:
1615
+ """Preview the canonical handoff context a target provider would receive.
1616
+
1617
+ Persists nothing, launches nothing, and consumes zero model quota.
1618
+ """
1619
+ try:
1620
+ result = SwitchService().preview(
1621
+ target_provider_name=target,
1622
+ from_session_id=from_session,
1623
+ note=note,
1624
+ )
1625
+ except Exception as err:
1626
+ _handle_error(err)
1627
+ return
1628
+
1629
+ if json_output:
1630
+ sys.stdout.write(json.dumps(result.to_dict(), indent=2) + "\n")
1631
+ return
1632
+
1633
+ console.print("\n[bold]CortexShift Handoff Preview[/bold]")
1634
+ console.print(
1635
+ "[dim]Preview only. Nothing was persisted and no provider was launched.\n"
1636
+ "The repository may change after this preview, so it can become stale.[/dim]\n"
1637
+ )
1638
+ grid = Table.grid(padding=(0, 2))
1639
+ grid.add_column(style="bold cyan", justify="left")
1640
+ grid.add_column(style="default", justify="left")
1641
+ grid.add_row("Protocol", f"CortexShift Handoff Protocol v{result.protocol_version}")
1642
+ grid.add_row("Target", result.target_provider_name)
1643
+ grid.add_row("Delivery", result.delivery_strategy)
1644
+ grid.add_row(
1645
+ "Bootstrap model turn",
1646
+ "yes" if result.bootstrap_model_turn_required else "no",
1647
+ )
1648
+ grid.add_row(
1649
+ "Context size",
1650
+ f"{result.context_characters} / {result.context_max_characters} characters"
1651
+ + (" (truncated)" if result.context_truncated else ""),
1652
+ )
1653
+ console.print(grid)
1654
+
1655
+ console.print("\n[bold]Receiving Agent Context[/bold]\n")
1656
+ console.print(escape(result.rendered_context))
1657
+ console.print()
1658
+
1659
+
1660
+ @handoff_app.command("list")
1661
+ def handoff_list_command(
1662
+ limit: Annotated[
1663
+ int,
1664
+ typer.Option("--limit", "-n", help="Maximum number of handoffs to display."),
1665
+ ] = 20,
1666
+ json_output: Annotated[
1667
+ bool,
1668
+ typer.Option("--json", help="Output machine-readable JSON format."),
1669
+ ] = False,
1670
+ ) -> None:
1671
+ """List historical canonical handoffs for this project."""
1672
+ try:
1673
+ handoffs = HandoffService().list_handoffs(limit=limit)
1674
+ except Exception as err:
1675
+ _handle_error(err)
1676
+ return
1677
+
1678
+ if json_output:
1679
+ data = [h.model_dump(mode="json") for h in handoffs]
1680
+ sys.stdout.write(json.dumps(data, indent=2) + "\n")
1681
+ return
1682
+
1683
+ if not handoffs:
1684
+ console.print("\n[dim]No handoffs recorded yet.[/dim]\n")
1685
+ return
1686
+
1687
+ table = Table(title="Handoffs", box=box.ROUNDED, header_style="bold cyan")
1688
+ table.add_column("Handoff", style="bold")
1689
+ table.add_column("From")
1690
+ table.add_column("To")
1691
+ table.add_column("Status")
1692
+ table.add_column("Created (UTC)")
1693
+
1694
+ for h in handoffs:
1695
+ status_style = {
1696
+ HandoffStatus.DELIVERED: "green",
1697
+ HandoffStatus.PREPARED: "yellow",
1698
+ HandoffStatus.FAILED: "red",
1699
+ }.get(h.status, "white")
1700
+ table.add_row(
1701
+ h.id,
1702
+ _provider_label(str(h.source_provider_id)),
1703
+ _provider_label(str(h.target_provider_id)),
1704
+ f"[{status_style}]{h.status.value}[/{status_style}]",
1705
+ h.created_at.strftime("%Y-%m-%d %H:%M:%S"),
1706
+ )
1707
+
1708
+ console.print()
1709
+ console.print(table)
1710
+ console.print()
1711
+
1712
+
1713
+ @handoff_app.command("show")
1714
+ def handoff_show_command(
1715
+ handoff_id: Annotated[
1716
+ str,
1717
+ typer.Argument(help="Identifier of the handoff to inspect."),
1718
+ ],
1719
+ json_output: Annotated[
1720
+ bool,
1721
+ typer.Option("--json", help="Output machine-readable JSON format."),
1722
+ ] = False,
1723
+ ) -> None:
1724
+ """Show the canonical engineering context of a stored handoff."""
1725
+ try:
1726
+ handoff = HandoffService().get_handoff(handoff_id)
1727
+ except Exception as err:
1728
+ _handle_error(err)
1729
+ return
1730
+
1731
+ if json_output:
1732
+ sys.stdout.write(json.dumps(handoff.model_dump(mode="json"), indent=2) + "\n")
1733
+ return
1734
+
1735
+ _render_handoff_summary(handoff)
1736
+
1737
+
1738
+ # --- Switch Command ---
1739
+
1740
+
1741
+ @app.command("switch")
1742
+ def switch_command(
1743
+ provider: Annotated[
1744
+ str,
1745
+ typer.Argument(
1746
+ help="Canonical target provider identifier (claude, codex, antigravity).",
1747
+ ),
1748
+ ],
1749
+ new_session: Annotated[
1750
+ bool, typer.Option("--new-session", help="Force a fresh native conversation.")
1751
+ ] = False,
1752
+ resume_session: Annotated[
1753
+ str | None, typer.Option("--resume-session", help="Prior target CortexShift Session ID.")
1754
+ ] = None,
1755
+ from_session: Annotated[
1756
+ str | None,
1757
+ typer.Option(
1758
+ "--from-session",
1759
+ help="Explicit source session ID to hand off from (defaults to the latest).",
1760
+ ),
1761
+ ] = None,
1762
+ note: Annotated[
1763
+ str | None,
1764
+ typer.Option(
1765
+ "--note",
1766
+ help="Optional operator note added as advisory context to the handoff.",
1767
+ ),
1768
+ ] = None,
1769
+ dry_run: Annotated[
1770
+ bool,
1771
+ typer.Option(
1772
+ "--dry-run",
1773
+ help="Describe the switch without persisting, launching, or using model quota.",
1774
+ ),
1775
+ ] = False,
1776
+ json_output: Annotated[
1777
+ bool,
1778
+ typer.Option("--json", help="Output machine-readable JSON (only valid with --dry-run)."),
1779
+ ] = False,
1780
+ ) -> None:
1781
+ """Hand the active task to another coding agent and launch it with full context.
1782
+
1783
+ CortexShift builds the handoff deterministically from durable local state, so the
1784
+ outgoing agent does not need to be available, running, or even installed.
1785
+
1786
+ Claude receives a fresh context prompt. Codex and Antigravity ingest context in
1787
+ one read-only bootstrap model turn, then resume the same native conversation.
1788
+ Known target conversations are reused by default. --dry-run runs no model turn.
1789
+ """
1790
+ if json_output and not dry_run:
1791
+ err_console.print("[red]Error:[/red] --json is only supported with --dry-run.")
1792
+ raise typer.Exit(code=1)
1793
+
1794
+ service = SwitchService()
1795
+
1796
+ if dry_run:
1797
+ try:
1798
+ preview = service.dry_run(
1799
+ target_provider_name=provider,
1800
+ from_session_id=from_session,
1801
+ note=note,
1802
+ new_session=new_session,
1803
+ resume_session_id=resume_session,
1804
+ )
1805
+ except Exception as err:
1806
+ _handle_error(err)
1807
+ return
1808
+
1809
+ if json_output:
1810
+ sys.stdout.write(json.dumps(preview.to_dict(), indent=2) + "\n")
1811
+ return
1812
+
1813
+ console.print("\n[bold]CortexShift Switch (Dry Run)[/bold]\n")
1814
+ grid = Table.grid(padding=(0, 2))
1815
+ grid.add_column(style="bold cyan", justify="left")
1816
+ grid.add_column(style="default", justify="left")
1817
+ grid.add_row("Protocol", f"CortexShift Handoff Protocol v{preview.protocol_version}")
1818
+ grid.add_row("Project", f"{preview.project_name} ({preview.project_id})")
1819
+ grid.add_row("Task", f"{escape(preview.task_title)} ({preview.task_id})")
1820
+ grid.add_row(
1821
+ "From",
1822
+ f"{_provider_label(preview.source_provider_id)} "
1823
+ f"({preview.source_session_id}, {preview.source_session_status})",
1824
+ )
1825
+ grid.add_row("To", f"{preview.target_provider_name} ({preview.target_executable})")
1826
+ grid.add_row("Target native mode", preview.target_native_mode)
1827
+ grid.add_row("Prior target Session", preview.selected_prior_target_session_id or "—")
1828
+ grid.add_row("Native session known", "yes" if preview.native_session_known else "no")
1829
+ grid.add_row("Delivery", preview.delivery_strategy)
1830
+ grid.add_row(
1831
+ "Bootstrap model turn",
1832
+ "yes — one read-only planning turn would run"
1833
+ if preview.bootstrap_model_turn_required
1834
+ else "no",
1835
+ )
1836
+ grid.add_row(
1837
+ "Git",
1838
+ f"{preview.git_status}"
1839
+ + (
1840
+ f" · {preview.git_branch or '(detached)'}"
1841
+ f" · {'dirty' if preview.git_dirty else 'clean'}"
1842
+ if preview.git_status == RepositoryInspectionStatus.READY.value
1843
+ else ""
1844
+ ),
1845
+ )
1846
+ grid.add_row(
1847
+ "Context size",
1848
+ f"{preview.context_characters} / {preview.context_max_characters} characters",
1849
+ )
1850
+ grid.add_row("Truncated", "yes" if preview.context_truncated else "no")
1851
+ console.print(grid)
1852
+ console.print("\n[dim]Dry run: nothing was persisted and nothing was launched.[/dim]\n")
1853
+ return
1854
+
1855
+ def _on_prepared(handoff: HandoffRecord, rendered: RenderedHandoffContext) -> None:
1856
+ console.print("\n[bold]CortexShift Handoff[/bold]")
1857
+ grid = Table.grid(padding=(0, 2))
1858
+ grid.add_column(style="bold cyan", justify="left")
1859
+ grid.add_column(style="default", justify="left")
1860
+ grid.add_row("Handoff", handoff.id)
1861
+ grid.add_row("Task", escape(handoff.payload.task_title))
1862
+ grid.add_row("From", _provider_label(str(handoff.source_provider_id)))
1863
+ grid.add_row("To", _provider_label(str(handoff.target_provider_id)))
1864
+ grid.add_row("Git Snapshot", handoff.git_snapshot_id or "— (Git state unavailable)")
1865
+ if rendered.truncated:
1866
+ omitted = sum(o.omitted_items for o in rendered.omissions)
1867
+ grid.add_row("Context", f"{rendered.character_count} chars ({omitted} items omitted)")
1868
+ else:
1869
+ grid.add_row("Context", f"{rendered.character_count} chars")
1870
+ console.print(grid)
1871
+
1872
+ adapter = service._registry.get(provider)
1873
+ if adapter is not None and adapter.bootstrap_model_turn_required:
1874
+ console.print(
1875
+ "\n[dim]Delivering the handoff in a read-only bootstrap model turn...[/dim]"
1876
+ )
1877
+
1878
+ def _on_launch(_spec: LaunchSpecification, session: Session) -> None:
1879
+ adapter = service._registry.get(provider)
1880
+ if adapter is not None and adapter.bootstrap_model_turn_required:
1881
+ console.print(
1882
+ "\nHandoff delivered in a read-only bootstrap turn.\n"
1883
+ "Opening the same conversation in the native TUI.\n"
1884
+ "Review the prepared continuation plan and continue from there."
1885
+ )
1886
+ console.print(f"\n[dim]Session {session.id} — launching native provider...[/dim]\n")
1887
+
1888
+ try:
1889
+ result = service.switch(
1890
+ target_provider_name=provider,
1891
+ from_session_id=from_session,
1892
+ note=note,
1893
+ new_session=new_session,
1894
+ resume_session_id=resume_session,
1895
+ on_prepared=_on_prepared,
1896
+ on_launch=_on_launch,
1897
+ )
1898
+ except Exception as err:
1899
+ _handle_error(err)
1900
+ return
1901
+
1902
+ session = result.target_session
1903
+ if session.status == SessionStatus.COMPLETED:
1904
+ console.print("\nSession completed.")
1905
+ elif session.status == SessionStatus.INTERRUPTED:
1906
+ console.print("\nSession interrupted.")
1907
+ else:
1908
+ console.print("\nSession failed.")
1909
+ console.print(f"[dim]Handoff {result.handoff.id} was delivered.[/dim]")
1910
+ if session.exit_code:
1911
+ raise typer.Exit(code=session.exit_code)
1912
+ raise typer.Exit(code=1)
1913
+
1914
+
1915
+ # --- Checkpoint Commands ---
1916
+
1917
+
1918
+ def _render_checkpoint_details(checkpoint: CheckpointRecord) -> None:
1919
+ p = checkpoint.payload
1920
+ task = p.task
1921
+ git = p.git_state
1922
+
1923
+ console.print("\n[bold]CortexShift Checkpoint[/bold]")
1924
+ grid = Table.grid(padding=(0, 2))
1925
+ grid.add_column(style="bold cyan", justify="left")
1926
+ grid.add_column(style="default", justify="left")
1927
+ grid.add_row("ID", checkpoint.id)
1928
+ grid.add_row("Kind", checkpoint.kind.value)
1929
+ grid.add_row("Task", f"{escape(task.task_title)} ({task.task_id}, {task.task_status})")
1930
+ if checkpoint.session_id:
1931
+ grid.add_row("Session", checkpoint.session_id)
1932
+ if checkpoint.git_snapshot_id:
1933
+ grid.add_row("Git Snapshot", checkpoint.git_snapshot_id)
1934
+ grid.add_row("Created", checkpoint.created_at.strftime("%Y-%m-%d %H:%M:%S UTC"))
1935
+ console.print(grid)
1936
+
1937
+ console.print("\n[bold]Progress[/bold]")
1938
+ pgrid = Table.grid(padding=(0, 2))
1939
+ pgrid.add_column(style="dim", justify="left")
1940
+ pgrid.add_column(style="default", justify="left")
1941
+ pgrid.add_row(
1942
+ "Completed",
1943
+ f"{len(task.completed)} items" if task.completed else "(none recorded)",
1944
+ )
1945
+ pgrid.add_row("Current", escape(task.current_work or "(no in-flight work recorded)"))
1946
+ pgrid.add_row(
1947
+ "Remaining",
1948
+ f"{len(task.remaining)} items" if task.remaining else "(none recorded)",
1949
+ )
1950
+ console.print(pgrid)
1951
+
1952
+ console.print("\n[bold]Decisions[/bold]")
1953
+ if p.decisions:
1954
+ for d in p.decisions[:10]:
1955
+ console.print(f" - {escape(d)}")
1956
+ if len(p.decisions) > 10:
1957
+ console.print(
1958
+ f" [dim]... {len(p.decisions) - 10} more decisions omitted from summary[/dim]"
1959
+ )
1960
+ else:
1961
+ console.print(" [dim](none recorded)[/dim]")
1962
+
1963
+ if task.known_issues:
1964
+ console.print("\n[bold]Issues[/bold]")
1965
+ for issue in task.known_issues[:10]:
1966
+ console.print(f" - {escape(issue)}")
1967
+ if len(task.known_issues) > 10:
1968
+ console.print(f" [dim]... {len(task.known_issues) - 10} more issues omitted[/dim]")
1969
+
1970
+ console.print("\n[bold]Tests[/bold]")
1971
+ if p.test_status.known:
1972
+ console.print(f" Status: {p.test_status.provenance.value}")
1973
+ console.print(f" Summary: {escape(p.test_status.summary)}")
1974
+ else:
1975
+ console.print(" [dim]Unknown (not verified)[/dim]")
1976
+
1977
+ if p.operator_note:
1978
+ console.print(f"\n[bold]Operator Note[/bold]\n {escape(p.operator_note)}")
1979
+
1980
+ console.print("\n[bold]Repository[/bold]")
1981
+ rgrid = Table.grid(padding=(0, 2))
1982
+ rgrid.add_column(style="dim", justify="left")
1983
+ rgrid.add_column(style="default", justify="left")
1984
+ rgrid.add_row("Status", git.status.value)
1985
+ if git.available:
1986
+ rgrid.add_row("Branch", git.branch or "(detached)")
1987
+ rgrid.add_row("HEAD", git.head_sha[:12] if git.head_sha else "(unborn)")
1988
+ rgrid.add_row("Working tree", "dirty" if git.dirty else "clean")
1989
+ rgrid.add_row("Files touched", f"{len(p.files_touched)} files")
1990
+ console.print(rgrid)
1991
+
1992
+ if p.files_touched:
1993
+ console.print("\n[bold]Files Touched[/bold]")
1994
+ for f in p.files_touched[:15]:
1995
+ console.print(f" {escape(f)}")
1996
+ if len(p.files_touched) > 15:
1997
+ console.print(
1998
+ f" [dim]... {len(p.files_touched) - 15} more files omitted from summary[/dim]"
1999
+ )
2000
+
2001
+
2002
+ @checkpoint_app.command(name="create")
2003
+ def checkpoint_create_cmd(
2004
+ decision: Annotated[
2005
+ list[str] | None,
2006
+ typer.Option("--decision", "-d", help="Structured engineering decision (repeatable)."),
2007
+ ] = None,
2008
+ test_summary: Annotated[
2009
+ str | None,
2010
+ typer.Option("--test-summary", "-t", help="Reported test execution summary."),
2011
+ ] = None,
2012
+ note: Annotated[
2013
+ str | None,
2014
+ typer.Option("--note", "-n", help="Optional operator note."),
2015
+ ] = None,
2016
+ session: Annotated[
2017
+ str | None,
2018
+ typer.Option("--session", "-s", help="Explicit session ID to associate with."),
2019
+ ] = None,
2020
+ json_output: Annotated[
2021
+ bool,
2022
+ typer.Option("--json", help="Output machine-readable JSON."),
2023
+ ] = False,
2024
+ ) -> None:
2025
+ """Create an immutable checkpoint of current Task and repository state.
2026
+
2027
+ Can be safely called while a provider session is actively executing;
2028
+ does not acquire the workspace lease.
2029
+ """
2030
+ service = CheckpointService()
2031
+ try:
2032
+ cp = service.create_checkpoint(
2033
+ kind=CheckpointKind.MANUAL,
2034
+ session_id=session,
2035
+ decisions=decision,
2036
+ test_summary=test_summary,
2037
+ note=note,
2038
+ )
2039
+ except Exception as err:
2040
+ _handle_error(err)
2041
+ return
2042
+
2043
+ if json_output:
2044
+ sys.stdout.write(cp.model_dump_json(indent=2) + "\n")
2045
+ return
2046
+
2047
+ console.print(f"\nCreated checkpoint [bold]{cp.id}[/bold] ({cp.kind.value})")
2048
+ grid = Table.grid(padding=(0, 2))
2049
+ grid.add_column(style="bold cyan", justify="left")
2050
+ grid.add_column(style="default", justify="left")
2051
+ grid.add_row("Task", escape(cp.payload.task.task_title))
2052
+ if cp.session_id:
2053
+ grid.add_row("Session", cp.session_id)
2054
+ if cp.git_snapshot_id:
2055
+ grid.add_row("Git Snapshot", cp.git_snapshot_id)
2056
+ grid.add_row("Files Touched", str(len(cp.payload.files_touched)))
2057
+ console.print(grid)
2058
+
2059
+
2060
+ @checkpoint_app.command(name="list")
2061
+ def checkpoint_list_cmd(
2062
+ limit: Annotated[
2063
+ int,
2064
+ typer.Option("--limit", "-l", help="Maximum number of checkpoints to display."),
2065
+ ] = 20,
2066
+ json_output: Annotated[
2067
+ bool,
2068
+ typer.Option("--json", help="Output machine-readable JSON."),
2069
+ ] = False,
2070
+ ) -> None:
2071
+ """List historical checkpoints, newest first."""
2072
+ service = CheckpointService()
2073
+ try:
2074
+ checkpoints = service.list_checkpoints(limit=limit)
2075
+ except Exception as err:
2076
+ _handle_error(err)
2077
+ return
2078
+
2079
+ if json_output:
2080
+ sys.stdout.write(
2081
+ json.dumps([cp.model_dump(mode="json") for cp in checkpoints], indent=2) + "\n"
2082
+ )
2083
+ return
2084
+
2085
+ if not checkpoints:
2086
+ console.print("\nNo checkpoints found.")
2087
+ return
2088
+
2089
+ console.print(f"\n[bold]Checkpoints ({len(checkpoints)})[/bold]")
2090
+ table = Table(box=box.SIMPLE, show_header=True)
2091
+ table.add_column("Checkpoint", style="bold cyan", no_wrap=True)
2092
+ table.add_column("Kind", style="default")
2093
+ table.add_column("Session", style="dim")
2094
+ table.add_column("Created (UTC)", style="default")
2095
+
2096
+ for cp in checkpoints:
2097
+ created_str = cp.created_at.strftime("%Y-%m-%d %H:%M:%S")
2098
+ sess_str = f"{cp.session_id[:10]}…" if cp.session_id else "-"
2099
+ table.add_row(
2100
+ cp.id,
2101
+ cp.kind.value,
2102
+ sess_str,
2103
+ created_str,
2104
+ )
2105
+ console.print(table)
2106
+
2107
+
2108
+ @checkpoint_app.command(name="show")
2109
+ def checkpoint_show_cmd(
2110
+ checkpoint_id: Annotated[str, typer.Argument(help="Checkpoint ID to inspect.")],
2111
+ json_output: Annotated[
2112
+ bool,
2113
+ typer.Option("--json", help="Output machine-readable JSON."),
2114
+ ] = False,
2115
+ ) -> None:
2116
+ """Display detailed structured state for a specific checkpoint."""
2117
+ service = CheckpointService()
2118
+ try:
2119
+ cp = service.get_checkpoint(checkpoint_id)
2120
+ if cp is None:
2121
+ raise CheckpointNotFoundError(checkpoint_id)
2122
+ except Exception as err:
2123
+ _handle_error(err)
2124
+ return
2125
+
2126
+ if json_output:
2127
+ sys.stdout.write(cp.model_dump_json(indent=2) + "\n")
2128
+ return
2129
+
2130
+ _render_checkpoint_details(cp)
2131
+
2132
+
2133
+ @checkpoint_app.command(name="latest")
2134
+ def checkpoint_latest_cmd(
2135
+ json_output: Annotated[
2136
+ bool,
2137
+ typer.Option("--json", help="Output machine-readable JSON."),
2138
+ ] = False,
2139
+ ) -> None:
2140
+ """Display the newest checkpoint for the active task."""
2141
+ service = CheckpointService()
2142
+ try:
2143
+ cp = service.get_latest_checkpoint()
2144
+ except Exception as err:
2145
+ _handle_error(err)
2146
+ return
2147
+
2148
+ if cp is None:
2149
+ if json_output:
2150
+ sys.stdout.write("{}\n")
2151
+ else:
2152
+ console.print("\nNo checkpoints found for the active task.")
2153
+ return
2154
+
2155
+ if json_output:
2156
+ sys.stdout.write(cp.model_dump_json(indent=2) + "\n")
2157
+ return
2158
+
2159
+ _render_checkpoint_details(cp)
2160
+
2161
+
2162
+ # --- Recovery Command ---
2163
+
2164
+
2165
+ def _render_recovery_report(report: RecoveryReport) -> None:
2166
+ console.print("\n[bold]CortexShift Recovery[/bold]\n")
2167
+ if report.dry_run:
2168
+ console.print(
2169
+ "[yellow bold]Dry Run Preview — no changes were made to state.[/yellow bold]\n"
2170
+ )
2171
+
2172
+ console.print("[bold]Task[/bold]")
2173
+ console.print(f" {escape(report.task_title)}\n")
2174
+
2175
+ console.print("[bold]Recovered Sessions[/bold]")
2176
+ if report.reconciled_session_ids:
2177
+ for s_id in report.reconciled_session_ids:
2178
+ console.print(f" {s_id} unexpected termination")
2179
+ else:
2180
+ console.print(" [dim]No stale running or initializing sessions found.[/dim]")
2181
+ console.print()
2182
+
2183
+ if report.checkpoint_id:
2184
+ console.print("[bold]Checkpoint[/bold]")
2185
+ console.print(f" {report.checkpoint_id}\n")
2186
+
2187
+ console.print("[bold]Repository[/bold]")
2188
+ console.print(f" {'Dirty' if report.dirty else 'Clean'}")
2189
+ console.print(f" {len(report.files_touched)} changed files\n")
2190
+
2191
+ if not report.dry_run and report.reconciled_session_ids:
2192
+ console.print("[bold]Next[/bold]")
2193
+ if report.checkpoint_id:
2194
+ console.print(
2195
+ f" Review checkpoint:\n cortexshift checkpoint show {report.checkpoint_id}\n"
2196
+ )
2197
+ console.print(
2198
+ " Then continue with:\n"
2199
+ " cortexshift resume <provider>\n"
2200
+ " or\n"
2201
+ " cortexshift switch <provider>\n"
2202
+ )
2203
+
2204
+
2205
+ @app.command(name="recover")
2206
+ def recover_cmd(
2207
+ dry_run: Annotated[
2208
+ bool,
2209
+ typer.Option("--dry-run", help="Preview recovery actions without modifying state."),
2210
+ ] = False,
2211
+ json_output: Annotated[
2212
+ bool,
2213
+ typer.Option("--json", help="Output machine-readable JSON."),
2214
+ ] = False,
2215
+ ) -> None:
2216
+ """Reconcile crashed or interrupted sessions and capture recovery state."""
2217
+ service = RecoveryService()
2218
+ try:
2219
+ report = service.recover(dry_run=dry_run)
2220
+ except Exception as err:
2221
+ _handle_error(err)
2222
+ return
2223
+
2224
+ if json_output:
2225
+ sys.stdout.write(report.model_dump_json(indent=2) + "\n")
2226
+ return
2227
+
2228
+ _render_recovery_report(report)
2229
+
2230
+
2231
+ # --- MCP Commands ---
2232
+
2233
+
2234
+ @mcp_app.command("serve")
2235
+ def mcp_serve_command() -> None:
2236
+ """Run the CortexShift stdio MCP server for coding agents.
2237
+
2238
+ Standard output is reserved strictly for the MCP wire protocol.
2239
+ Diagnostics and logs route exclusively to standard error.
2240
+ """
2241
+ try:
2242
+ context, store = resolve_mcp_context()
2243
+ run_mcp_server(context=context, store=store)
2244
+ except Exception as err:
2245
+ sys.stderr.write(f"CortexShift MCP server failed to start: {err}\n")
2246
+ raise typer.Exit(code=1) from err
2247
+
2248
+
2249
+ @mcp_app.command("status")
2250
+ def mcp_status_command(
2251
+ json_output: Annotated[
2252
+ bool,
2253
+ typer.Option(
2254
+ "--json",
2255
+ help="Output machine-readable JSON format.",
2256
+ ),
2257
+ ] = False,
2258
+ ) -> None:
2259
+ """Show MCP SDK status, capabilities, and provider integration modes."""
2260
+ project_root = ProjectLocator.find_project_root()
2261
+ project_info: dict[str, Any] | None = None
2262
+ managed_session_info: dict[str, Any] | None = None
2263
+
2264
+ if project_root is not None:
2265
+ db_path = ProjectLocator.get_database_path(project_root)
2266
+ try:
2267
+ with SQLiteStateStore(db_path, auto_migrate=False) as store:
2268
+ proj = store.get_default_project()
2269
+ if proj:
2270
+ project_info = {
2271
+ "id": proj.id,
2272
+ "name": proj.name,
2273
+ "root": str(project_root),
2274
+ "active_task_id": store.get_active_task_id(proj.id),
2275
+ }
2276
+ sess_id = os.environ.get(ENV_SESSION_ID)
2277
+ if sess_id:
2278
+ sess = store.get_session(sess_id)
2279
+ if sess:
2280
+ managed_session_info = {
2281
+ "id": sess.id,
2282
+ "provider_id": str(sess.provider_id),
2283
+ "task_id": sess.task_id,
2284
+ "status": sess.status.value,
2285
+ }
2286
+ except Exception:
2287
+ pass
2288
+
2289
+ antigravity_configured = is_antigravity_mcp_configured(project_root) if project_root else False
2290
+
2291
+ status_data: dict[str, Any] = {
2292
+ "mcp_sdk": {
2293
+ "available": True,
2294
+ "version": getattr(mcp, "__version__", "unknown"),
2295
+ "transport": "stdio",
2296
+ },
2297
+ "server": {
2298
+ "name": "cortexshift",
2299
+ "capabilities": ["tools", "resources"],
2300
+ "read_tools": [
2301
+ "get_project_context",
2302
+ "get_current_task",
2303
+ "get_latest_checkpoint",
2304
+ "get_repository_status",
2305
+ ],
2306
+ "write_tools": [
2307
+ "set_current_work",
2308
+ "mark_completed",
2309
+ "add_remaining",
2310
+ "record_issue",
2311
+ "record_decision",
2312
+ "create_checkpoint",
2313
+ ],
2314
+ "resources": [
2315
+ "cortexshift://project",
2316
+ "cortexshift://task",
2317
+ "cortexshift://checkpoint/latest",
2318
+ "cortexshift://repository",
2319
+ ],
2320
+ },
2321
+ "project": project_info,
2322
+ "session_binding": {
2323
+ "managed": managed_session_info is not None,
2324
+ "session": managed_session_info,
2325
+ "read_only": (
2326
+ os.environ.get(ENV_MCP_READ_ONLY, "0").strip().lower() in ("1", "true", "yes")
2327
+ ),
2328
+ },
2329
+ "providers": {
2330
+ "claude": {
2331
+ "integration": "automatic per CortexShift launch",
2332
+ "mechanism": "--mcp-config",
2333
+ },
2334
+ "codex": {
2335
+ "integration": "automatic per CortexShift launch",
2336
+ "mechanism": "-c overrides",
2337
+ },
2338
+ "antigravity": {
2339
+ "integration": "workspace config",
2340
+ "config_path": (
2341
+ str(project_root / ANTIGRAVITY_MCP_CONFIG_REL_PATH) if project_root else None
2342
+ ),
2343
+ "configured": antigravity_configured,
2344
+ },
2345
+ },
2346
+ }
2347
+
2348
+ if json_output:
2349
+ sys.stdout.write(json.dumps(status_data, indent=2) + "\n")
2350
+ return
2351
+
2352
+ console.print("\n[bold]CortexShift MCP Status[/bold]\n")
2353
+
2354
+ grid = Table.grid(padding=(0, 2))
2355
+ grid.add_column(style="bold cyan", justify="left")
2356
+ grid.add_column(style="default", justify="left")
2357
+ grid.add_row("MCP SDK Version", str(status_data["mcp_sdk"]["version"]))
2358
+ grid.add_row("Transport", "stdio (local only)")
2359
+ if project_info:
2360
+ grid.add_row("Project", f"{project_info['name']} ({project_info['id']})")
2361
+ grid.add_row("Project Root", str(project_info["root"]))
2362
+ else:
2363
+ grid.add_row("Project", "Not inside an initialized CortexShift project")
2364
+
2365
+ if managed_session_info:
2366
+ sess_label = f"{managed_session_info['id']} ({managed_session_info['provider_id']})"
2367
+ grid.add_row("Managed Session", sess_label)
2368
+ grid.add_row("Bound Task", str(managed_session_info["task_id"]))
2369
+ else:
2370
+ grid.add_row("Execution Mode", "Unmanaged (Read-Only)")
2371
+
2372
+ console.print(grid)
2373
+
2374
+ console.print("\n[bold]Provider Integrations[/bold]\n")
2375
+ p_table = Table(box=box.ROUNDED)
2376
+ p_table.add_column("Provider", style="bold")
2377
+ p_table.add_column("Integration Mode")
2378
+ p_table.add_column("Status / Mechanism")
2379
+
2380
+ p_table.add_row("Claude Code", "Automatic per launch", "--mcp-config (inline JSON)")
2381
+ p_table.add_row("OpenAI Codex", "Automatic per launch", "-c mcp_servers.cortexshift (inline)")
2382
+ ag_status = (
2383
+ "[green]Configured[/green]"
2384
+ if antigravity_configured
2385
+ else "[yellow]Not configured (run: cortexshift mcp setup antigravity)[/yellow]"
2386
+ )
2387
+ p_table.add_row("Google Antigravity", "Workspace config (.agents/mcp_config.json)", ag_status)
2388
+ console.print(p_table)
2389
+ console.print()
2390
+
2391
+
2392
+ @mcp_setup_app.command("antigravity")
2393
+ def mcp_setup_antigravity_command(
2394
+ dry_run: Annotated[
2395
+ bool,
2396
+ typer.Option(
2397
+ "--dry-run",
2398
+ help="Preview configuration changes without writing to disk.",
2399
+ ),
2400
+ ] = False,
2401
+ force: Annotated[
2402
+ bool,
2403
+ typer.Option(
2404
+ "--force",
2405
+ help="Overwrite existing conflicting cortexshift entry.",
2406
+ ),
2407
+ ] = False,
2408
+ ) -> None:
2409
+ """Safely configure project-local .agents/mcp_config.json for Antigravity."""
2410
+ project_root = ProjectLocator.find_project_root()
2411
+ if project_root is None:
2412
+ _handle_error(ProjectNotInitializedError())
2413
+ return
2414
+
2415
+ try:
2416
+ result = setup_antigravity_mcp(project_root, dry_run=dry_run, force=force)
2417
+ except Exception as err:
2418
+ _handle_error(err)
2419
+ return
2420
+
2421
+ console.print("\n[bold]Antigravity Workspace MCP Configuration[/bold]\n")
2422
+ grid = Table.grid(padding=(0, 2))
2423
+ grid.add_column(style="bold cyan", justify="left")
2424
+ grid.add_column(style="default", justify="left")
2425
+ grid.add_row("Config Path", str(result["path"]))
2426
+ grid.add_row("Action", str(result["action"]))
2427
+ grid.add_row("Dry Run", str(result["dry_run"]))
2428
+ grid.add_row("Configured Servers", ", ".join(result["servers"]))
2429
+ console.print(grid)
2430
+
2431
+ console.print(
2432
+ "\n[dim]Notice: .agents/mcp_config.json is a workspace configuration file "
2433
+ "and may appear in Git.[/dim]"
2434
+ )
2435
+ console.print(
2436
+ "[dim]CortexShift will not automatically modify .gitignore or Git configuration.[/dim]\n"
2437
+ )
2438
+
2439
+
2440
+ # --- Interactive Terminal Control Center ---
2441
+
2442
+
2443
+ @app.command(name="tui")
2444
+ def tui_command() -> None:
2445
+ """Open the interactive CortexShift dashboard for this project.
2446
+
2447
+ The dashboard is a control center over the same application services the CLI uses.
2448
+ It never embeds a provider's terminal UI: choosing run, resume, or switch closes the
2449
+ dashboard, restores the terminal, and only then launches the native provider.
2450
+ """
2451
+
2452
+ def _announce(message: str) -> None:
2453
+ console.print(f"\n[dim]{message}[/dim]\n")
2454
+
2455
+ coordinator = TuiCoordinator(announce=_announce)
2456
+
2457
+ try:
2458
+ result = coordinator.start()
2459
+ except Exception as err:
2460
+ _handle_error(err)
2461
+ return
2462
+
2463
+ session = result.session
2464
+ if session is None:
2465
+ return
2466
+
2467
+ if session.status == SessionStatus.COMPLETED:
2468
+ console.print("\nSession completed.")
2469
+ elif session.status == SessionStatus.INTERRUPTED:
2470
+ console.print("\nSession interrupted.")
2471
+ else:
2472
+ console.print("\nSession failed.")
2473
+ raise typer.Exit(code=result.exit_code)
2474
+
2475
+
2476
+ if __name__ == "__main__":
2477
+ app()