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.
- cortexshift/__init__.py +10 -0
- cortexshift/__main__.py +6 -0
- cortexshift/adapters/__init__.py +22 -0
- cortexshift/adapters/command_runner.py +116 -0
- cortexshift/adapters/discovery.py +55 -0
- cortexshift/adapters/git/__init__.py +10 -0
- cortexshift/adapters/git/inspector.py +321 -0
- cortexshift/adapters/git/parser.py +140 -0
- cortexshift/adapters/headless_runner.py +92 -0
- cortexshift/adapters/process_runner.py +56 -0
- cortexshift/adapters/providers/__init__.py +4 -0
- cortexshift/adapters/providers/antigravity.py +530 -0
- cortexshift/adapters/providers/claude.py +375 -0
- cortexshift/adapters/providers/codex.py +434 -0
- cortexshift/adapters/sqlite/__init__.py +10 -0
- cortexshift/adapters/sqlite/migrations.py +268 -0
- cortexshift/adapters/sqlite/store.py +914 -0
- cortexshift/adapters/workspace_lease.py +123 -0
- cortexshift/application/__init__.py +42 -0
- cortexshift/application/checkpoint_builder.py +218 -0
- cortexshift/application/checkpoint_service.py +273 -0
- cortexshift/application/doctor.py +80 -0
- cortexshift/application/handoff_builder.py +281 -0
- cortexshift/application/handoff_renderer.py +430 -0
- cortexshift/application/handoff_service.py +66 -0
- cortexshift/application/init_service.py +86 -0
- cortexshift/application/locator.py +48 -0
- cortexshift/application/native_session.py +65 -0
- cortexshift/application/recovery_service.py +235 -0
- cortexshift/application/repository_service.py +146 -0
- cortexshift/application/resume_service.py +124 -0
- cortexshift/application/run_service.py +270 -0
- cortexshift/application/session_launcher.py +183 -0
- cortexshift/application/session_service.py +63 -0
- cortexshift/application/source_session.py +62 -0
- cortexshift/application/status_service.py +73 -0
- cortexshift/application/switch_service.py +671 -0
- cortexshift/application/task_service.py +201 -0
- cortexshift/application/task_workspace.py +152 -0
- cortexshift/cli/__init__.py +5 -0
- cortexshift/cli/app.py +2477 -0
- cortexshift/domain/__init__.py +153 -0
- cortexshift/domain/checkpoint.py +174 -0
- cortexshift/domain/doctor.py +68 -0
- cortexshift/domain/errors.py +277 -0
- cortexshift/domain/git.py +102 -0
- cortexshift/domain/handoff.py +241 -0
- cortexshift/domain/identifiers.py +27 -0
- cortexshift/domain/launch.py +58 -0
- cortexshift/domain/mcp_binding.py +81 -0
- cortexshift/domain/native_session.py +19 -0
- cortexshift/domain/project.py +37 -0
- cortexshift/domain/provider.py +67 -0
- cortexshift/domain/session.py +92 -0
- cortexshift/domain/status.py +40 -0
- cortexshift/domain/task.py +191 -0
- cortexshift/mcp/__init__.py +38 -0
- cortexshift/mcp/context.py +165 -0
- cortexshift/mcp/facade.py +513 -0
- cortexshift/mcp/models.py +178 -0
- cortexshift/mcp/resources.py +45 -0
- cortexshift/mcp/server.py +52 -0
- cortexshift/mcp/tools.py +176 -0
- cortexshift/ports/__init__.py +39 -0
- cortexshift/ports/checkpoint_store.py +45 -0
- cortexshift/ports/command_runner.py +56 -0
- cortexshift/ports/discovery.py +41 -0
- cortexshift/ports/handoff_delivery.py +91 -0
- cortexshift/ports/handoff_store.py +43 -0
- cortexshift/ports/headless_runner.py +58 -0
- cortexshift/ports/native_session.py +20 -0
- cortexshift/ports/process_runner.py +31 -0
- cortexshift/ports/provider.py +152 -0
- cortexshift/ports/repository.py +44 -0
- cortexshift/ports/session_store.py +27 -0
- cortexshift/ports/state_store.py +55 -0
- cortexshift/ports/workspace_lease.py +39 -0
- cortexshift/tui/__init__.py +24 -0
- cortexshift/tui/actions.py +58 -0
- cortexshift/tui/app.py +1051 -0
- cortexshift/tui/coordinator.py +173 -0
- cortexshift/tui/cortexshift.tcss +258 -0
- cortexshift/tui/facade.py +614 -0
- cortexshift/tui/modals.py +594 -0
- cortexshift/tui/models.py +503 -0
- cortexshift/tui/screens/__init__.py +81 -0
- cortexshift/tui/screens/checkpoints.py +188 -0
- cortexshift/tui/screens/handoffs.py +180 -0
- cortexshift/tui/screens/help.py +117 -0
- cortexshift/tui/screens/overview.py +200 -0
- cortexshift/tui/screens/providers.py +169 -0
- cortexshift/tui/screens/repository.py +143 -0
- cortexshift/tui/screens/sessions.py +146 -0
- cortexshift/tui/screens/task.py +174 -0
- cortexshift/tui/widgets.py +209 -0
- cortexshift-0.1.0.dist-info/METADATA +202 -0
- cortexshift-0.1.0.dist-info/RECORD +100 -0
- cortexshift-0.1.0.dist-info/WHEEL +4 -0
- cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|