terminal-agent-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. terminal_agent/__init__.py +7 -0
  2. terminal_agent/__main__.py +7 -0
  3. terminal_agent/agent/__init__.py +9 -0
  4. terminal_agent/agent/loop.py +338 -0
  5. terminal_agent/checkpoints/__init__.py +9 -0
  6. terminal_agent/checkpoints/manager.py +151 -0
  7. terminal_agent/cli/commands/__init__.py +26 -0
  8. terminal_agent/cli/commands/checkpoint.py +76 -0
  9. terminal_agent/cli/commands/clean.py +170 -0
  10. terminal_agent/cli/commands/config_cmd.py +32 -0
  11. terminal_agent/cli/commands/diff.py +45 -0
  12. terminal_agent/cli/commands/doctor.py +136 -0
  13. terminal_agent/cli/commands/resume.py +109 -0
  14. terminal_agent/cli/commands/run.py +184 -0
  15. terminal_agent/cli/commands/setup.py +229 -0
  16. terminal_agent/cli/commands/status.py +62 -0
  17. terminal_agent/cli/commands/test.py +38 -0
  18. terminal_agent/cli/commands/trace.py +60 -0
  19. terminal_agent/cli/main.py +94 -0
  20. terminal_agent/cli/theme.py +55 -0
  21. terminal_agent/cli/ui.py +147 -0
  22. terminal_agent/config/__init__.py +30 -0
  23. terminal_agent/config/schema.py +100 -0
  24. terminal_agent/config/settings.py +85 -0
  25. terminal_agent/context/__init__.py +11 -0
  26. terminal_agent/context/engine.py +173 -0
  27. terminal_agent/context/ranker.py +87 -0
  28. terminal_agent/git/__init__.py +10 -0
  29. terminal_agent/git/adapter.py +219 -0
  30. terminal_agent/planner/__init__.py +10 -0
  31. terminal_agent/planner/contract.py +58 -0
  32. terminal_agent/planner/planner.py +30 -0
  33. terminal_agent/providers/__init__.py +30 -0
  34. terminal_agent/providers/anthropic_provider.py +141 -0
  35. terminal_agent/providers/base.py +57 -0
  36. terminal_agent/providers/detector.py +397 -0
  37. terminal_agent/providers/factory.py +29 -0
  38. terminal_agent/providers/gemini_provider.py +130 -0
  39. terminal_agent/providers/mock_provider.py +80 -0
  40. terminal_agent/providers/ollama_provider.py +124 -0
  41. terminal_agent/providers/openai_provider.py +124 -0
  42. terminal_agent/recovery/__init__.py +10 -0
  43. terminal_agent/recovery/classifier.py +64 -0
  44. terminal_agent/recovery/strategies.py +62 -0
  45. terminal_agent/sandbox/__init__.py +13 -0
  46. terminal_agent/sandbox/base.py +41 -0
  47. terminal_agent/sandbox/docker.py +129 -0
  48. terminal_agent/sandbox/local.py +129 -0
  49. terminal_agent/security/__init__.py +13 -0
  50. terminal_agent/security/classifier.py +151 -0
  51. terminal_agent/security/policy.py +51 -0
  52. terminal_agent/security/secrets.py +105 -0
  53. terminal_agent/session/__init__.py +33 -0
  54. terminal_agent/session/manager.py +195 -0
  55. terminal_agent/session/models.py +160 -0
  56. terminal_agent/telemetry/__init__.py +12 -0
  57. terminal_agent/telemetry/events.py +87 -0
  58. terminal_agent/telemetry/metrics.py +38 -0
  59. terminal_agent/tools/__init__.py +33 -0
  60. terminal_agent/tools/base.py +59 -0
  61. terminal_agent/tools/checkpoint_tools.py +72 -0
  62. terminal_agent/tools/command_tools.py +125 -0
  63. terminal_agent/tools/file_tools.py +340 -0
  64. terminal_agent/tools/git_tools.py +124 -0
  65. terminal_agent/tools/registry.py +109 -0
  66. terminal_agent/verifier/__init__.py +13 -0
  67. terminal_agent/verifier/assertions.py +115 -0
  68. terminal_agent/verifier/engine.py +125 -0
  69. terminal_agent/verifier/runners.py +80 -0
  70. terminal_agent_cli-0.1.0.dist-info/METADATA +438 -0
  71. terminal_agent_cli-0.1.0.dist-info/RECORD +74 -0
  72. terminal_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  73. terminal_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  74. terminal_agent_cli-0.1.0.dist-info/licenses/LICENSE +23 -0
@@ -0,0 +1,7 @@
1
+ """
2
+ Terminal Agent - Build. Verify. Ship.
3
+ Autonomous terminal-based coding agent designed around verifiable software changes.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
@@ -0,0 +1,7 @@
1
+ """Package main entrypoint."""
2
+
3
+ from terminal_agent.cli.main import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
7
+
@@ -0,0 +1,9 @@
1
+ """Agent package for Terminal Agent."""
2
+
3
+ from terminal_agent.agent.loop import AgentLoop, SYSTEM_PROMPT
4
+
5
+ __all__ = [
6
+ "AgentLoop",
7
+ "SYSTEM_PROMPT",
8
+ ]
9
+
@@ -0,0 +1,338 @@
1
+ """Core bounded autonomous agent loop: Observe -> Plan -> Act -> Verify -> Repair -> Verify."""
2
+
3
+ import time
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any, Callable, Dict, List, Optional
7
+
8
+ from terminal_agent.checkpoints.manager import CheckpointManager
9
+ from terminal_agent.config.schema import TerminalAgentConfig
10
+ from terminal_agent.context.engine import RepositoryContextEngine
11
+ from terminal_agent.git.adapter import GitAdapter
12
+ from terminal_agent.planner.contract import TaskContractGenerator
13
+ from terminal_agent.planner.planner import ExecutionPlanner
14
+ from terminal_agent.providers.base import LLMMessage, LLMResponse, LLMToolCall, ModelProvider
15
+ from terminal_agent.recovery.classifier import FailureClassifier
16
+ from terminal_agent.recovery.strategies import RecoveryEngine
17
+ from terminal_agent.session.manager import SessionManager
18
+ from terminal_agent.session.models import (
19
+ FailureCategory,
20
+ PlanItem,
21
+ SessionState,
22
+ StepAction,
23
+ TaskContract,
24
+ VerificationResult,
25
+ VerificationStatus,
26
+ )
27
+ from terminal_agent.telemetry.events import TelemetryLogger
28
+ from terminal_agent.telemetry.metrics import MetricsCollector
29
+ from terminal_agent.tools.registry import ToolRegistry
30
+ from terminal_agent.verifier.engine import IndependentVerifier
31
+
32
+
33
+ SYSTEM_PROMPT = """You are TERMINAL AGENT, an autonomous verify-first terminal coding agent.
34
+ Tagline: Build. Verify. Ship.
35
+
36
+ CORE PRINCIPLE:
37
+ You do NOT simply modify code and claim completion.
38
+ A task is only complete when independently verified by running tests, validating contract assertions, and inspecting git diffs.
39
+
40
+ RULES:
41
+ 1. Every tool call MUST include a clear, concise 'reason' explaining what you are doing and why.
42
+ 2. Read and analyze files before making changes.
43
+ 3. Keep changes minimal and focused directly on the user's task contract.
44
+ 4. When you believe your changes are in place, run verification tests.
45
+ 5. If tests fail, analyze the error trace, determine root cause, and apply targeted repairs.
46
+ 6. Never modify test files unless the user explicitly requested test changes.
47
+ """
48
+
49
+
50
+ class AgentLoop:
51
+ """Orchestrates the autonomous verify-first coding cycle."""
52
+
53
+ def __init__(
54
+ self,
55
+ session_state: SessionState,
56
+ config: TerminalAgentConfig,
57
+ provider: ModelProvider,
58
+ tool_registry: ToolRegistry,
59
+ verifier: IndependentVerifier,
60
+ context_engine: RepositoryContextEngine,
61
+ checkpoint_manager: CheckpointManager,
62
+ session_manager: SessionManager,
63
+ telemetry_logger: TelemetryLogger,
64
+ step_callback: Optional[Callable[[str, Any], None]] = None
65
+ ):
66
+ self.state = session_state
67
+ self.config = config
68
+ self.provider = provider
69
+ self.tools = tool_registry
70
+ self.verifier = verifier
71
+ self.context = context_engine
72
+ self.checkpoints = checkpoint_manager
73
+ self.session_mgr = session_manager
74
+ self.telemetry = telemetry_logger
75
+ self.step_callback = step_callback or (lambda event, data: None)
76
+ self.start_time = time.time()
77
+
78
+ def _notify(self, event_type: str, data: Any = None) -> None:
79
+ """Send UI update notification."""
80
+ try:
81
+ self.step_callback(event_type, data)
82
+ except Exception:
83
+ pass
84
+
85
+ def run(self) -> SessionState:
86
+ """Execute the bounded autonomous loop."""
87
+ self._notify("start", self.state)
88
+
89
+ # 1. OBSERVE & INITIALIZE CONTRACT
90
+ if not self.state.contract:
91
+ self._notify("observe_start", "Inspecting repository...")
92
+ contract = TaskContractGenerator.generate_from_description(
93
+ task_description=self.state.task_description,
94
+ verification_commands=self.config.verification.tests
95
+ )
96
+ self.state.contract = contract
97
+ self.state.plan = ExecutionPlanner.create_initial_plan(contract)
98
+ self.session_mgr.save_session(self.state)
99
+ self._notify("contract_ready", contract)
100
+
101
+ # 2. CREATE BASELINE CHECKPOINT
102
+ if not self.state.checkpoints:
103
+ self._notify("checkpoint_creating", "Creating baseline checkpoint...")
104
+ baseline_chk = self.checkpoints.create_checkpoint(name="baseline_before_changes")
105
+ self.state.checkpoints.append(baseline_chk)
106
+ self.session_mgr.save_session(self.state)
107
+ self._notify("checkpoint_created", baseline_chk)
108
+
109
+ # 3. BUILD INITIAL CONTEXT & MESSAGES
110
+ repo_context = self.context.build_initial_context(
111
+ task_description=self.state.task_description,
112
+ contract=self.state.contract
113
+ )
114
+
115
+ messages: List[LLMMessage] = [
116
+ LLMMessage(role="system", content=SYSTEM_PROMPT),
117
+ LLMMessage(
118
+ role="user",
119
+ content=(
120
+ f"TASK CONTRACT:\n"
121
+ f"Goal: {self.state.contract.goal}\n"
122
+ f"Constraints:\n" + "\n".join(f"- {c}" for c in self.state.contract.constraints) + "\n\n"
123
+ f"Success Criteria:\n" + "\n".join(f"- {sc}" for sc in self.state.contract.success_criteria) + "\n\n"
124
+ f"REPOSITORY CONTEXT:\n{repo_context}\n\n"
125
+ f"Please begin by inspecting the relevant code and formulating your changes."
126
+ )
127
+ )
128
+ ]
129
+
130
+ # Replay completed steps if resuming
131
+ for past_step in self.state.completed_steps:
132
+ if past_step.tool_name:
133
+ messages.append(
134
+ LLMMessage(
135
+ role="assistant",
136
+ tool_calls=[
137
+ LLMToolCall(
138
+ id=f"call_{past_step.step_number}",
139
+ name=past_step.tool_name,
140
+ arguments=past_step.tool_args or {}
141
+ )
142
+ ]
143
+ )
144
+ )
145
+ messages.append(
146
+ LLMMessage(
147
+ role="tool",
148
+ name=past_step.tool_name,
149
+ tool_call_id=f"call_{past_step.step_number}",
150
+ content=past_step.output or ""
151
+ )
152
+ )
153
+
154
+ step_count = len(self.state.completed_steps)
155
+ retry_count = len(self.state.failures)
156
+ max_steps = self.config.agent.max_steps
157
+ max_retries = self.config.agent.max_retries
158
+ timeout_sec = self.config.agent.timeout_seconds
159
+
160
+ # 4. BOUNDED AGENT LOOP
161
+ while step_count < max_steps:
162
+ # Check overall timeout
163
+ elapsed = time.time() - self.start_time
164
+ if elapsed > timeout_sec:
165
+ self.state.current_status = VerificationStatus.FAILED
166
+ self.session_mgr.record_step(
167
+ self.state,
168
+ action_type="timeout",
169
+ reason=f"Task timed out after {elapsed:.1f} seconds.",
170
+ status="error"
171
+ )
172
+ self._notify("timeout", f"Execution exceeded {timeout_sec}s limit.")
173
+ break
174
+
175
+ step_count += 1
176
+ self.state.next_step = f"Executing step {step_count}..."
177
+ self._notify("step_start", {"step": step_count, "retries": retry_count})
178
+
179
+ # Query Model Provider
180
+ tool_schemas = self.tools.get_schemas()
181
+ try:
182
+ response: LLMResponse = self.provider.generate(
183
+ messages=messages,
184
+ tools=tool_schemas,
185
+ temperature=self.config.provider.temperature
186
+ )
187
+ except Exception as e:
188
+ self.session_mgr.record_step(
189
+ self.state,
190
+ action_type="provider_error",
191
+ reason=f"Model provider error: {e}",
192
+ status="error"
193
+ )
194
+ self._notify("error", f"Model provider failed: {e}")
195
+ break
196
+
197
+ # Handle model message and tool calls
198
+ if response.content:
199
+ self._notify("agent_thought", response.content)
200
+
201
+ if not response.tool_calls:
202
+ # Model stopped or claims completion without tool calls -> Trigger Independent Verification
203
+ self._notify("verification_triggered", "Model requested verification / completed actions.")
204
+ v_result = self.verifier.verify(contract=self.state.contract)
205
+ self.session_mgr.record_verification(self.state, v_result)
206
+ self._notify("verification_result", v_result)
207
+
208
+ if v_result.status == VerificationStatus.VERIFIED:
209
+ self._finalize_success(v_result)
210
+ break
211
+ else:
212
+ # Verification failed -> enter repair logic
213
+ retry_count += 1
214
+ if retry_count > max_retries:
215
+ self.state.current_status = VerificationStatus.FAILED
216
+ self._notify("max_retries_exceeded", f"Failed verification after {max_retries} repair retries.")
217
+ break
218
+
219
+ # Classify failure & formulate repair
220
+ cat, cat_reason = FailureClassifier.classify(v_result.test_output)
221
+ hypothesis, repair_action = RecoveryEngine.analyze_and_plan_recovery(cat, v_result.test_output, self.state)
222
+ failure_record = self.session_mgr.record_failure(
223
+ self.state,
224
+ category=cat,
225
+ raw_output=v_result.test_output,
226
+ root_cause_hypothesis=hypothesis,
227
+ recovery_action=repair_action
228
+ )
229
+ self._notify("repair_plan", failure_record)
230
+
231
+ # Append failure diagnostics to conversation
232
+ messages.append(
233
+ LLMMessage(
234
+ role="user",
235
+ content=(
236
+ f"INDEPENDENT VERIFICATION FAILED (Status: {v_result.status.value})\n"
237
+ f"Test Failures: {v_result.tests_failed}/{v_result.tests_run}\n"
238
+ f"Primary Failure Category: {cat.value}\n"
239
+ f"Root Cause Hypothesis: {hypothesis}\n"
240
+ f"Recommended Repair: {repair_action}\n\n"
241
+ f"Test Output Trace:\n{v_result.test_output[-2000:]}\n\n"
242
+ f"Please repair the issue in the target files and re-test."
243
+ )
244
+ )
245
+ )
246
+ continue
247
+
248
+ # Execute tool calls
249
+ for tc in response.tool_calls:
250
+ tool_name = tc.name
251
+ tool_args = tc.arguments or {}
252
+ reason = str(tool_args.get("reason", response.content or "Executing tool action"))
253
+
254
+ self._notify("tool_executing", {"tool": tool_name, "args": tool_args, "reason": reason})
255
+
256
+ tool_result = self.tools.execute(tool_name, tool_args, reason=reason)
257
+
258
+ # Track modified files
259
+ if tool_name in ("write_file", "edit_file") and tool_result.success:
260
+ p = tool_args.get("path")
261
+ if p and p not in self.state.modified_files:
262
+ self.state.modified_files.append(p)
263
+
264
+ # Record step and telemetry
265
+ self.session_mgr.record_step(
266
+ self.state,
267
+ action_type="tool_call",
268
+ tool_name=tool_name,
269
+ tool_args=tool_args,
270
+ reason=reason,
271
+ output=tool_result.output if tool_result.success else tool_result.error,
272
+ status="success" if tool_result.success else "error",
273
+ duration_ms=tool_result.duration_ms
274
+ )
275
+
276
+ self.telemetry.log_event(
277
+ step=step_count,
278
+ event_type="tool_call",
279
+ tool=tool_name,
280
+ reason=reason,
281
+ duration_ms=tool_result.duration_ms,
282
+ status="success" if tool_result.success else "error",
283
+ data=tool_args
284
+ )
285
+
286
+ self._notify("tool_completed", tool_result)
287
+
288
+ # Append tool exchange to LLM history
289
+ messages.append(
290
+ LLMMessage(
291
+ role="assistant",
292
+ tool_calls=[tc]
293
+ )
294
+ )
295
+ messages.append(
296
+ LLMMessage(
297
+ role="tool",
298
+ name=tool_name,
299
+ tool_call_id=tc.id,
300
+ content=tool_result.output if tool_result.success else f"ERROR: {tool_result.error}"
301
+ )
302
+ )
303
+
304
+ # End of loop: update final metrics
305
+ MetricsCollector.calculate_metrics(self.state)
306
+ self.session_mgr.save_session(self.state)
307
+ self._notify("finish", self.state)
308
+ return self.state
309
+
310
+ def _finalize_success(self, v_result: VerificationResult) -> None:
311
+ """Mark session as VERIFIED and synthesize final Proof of Done."""
312
+ self.state.current_status = VerificationStatus.VERIFIED
313
+ diff_stats = self.verifier.git_adapter.get_diff_stats()
314
+
315
+ # Build structured Proof of Done
316
+ pod = {
317
+ "session_id": self.state.session_id,
318
+ "task": self.state.task_description,
319
+ "result": "VERIFIED ✓",
320
+ "tests_passed": f"{v_result.tests_passed}/{v_result.tests_run}",
321
+ "lint": "PASS" if v_result.lint_passed else "FAIL",
322
+ "files_changed_count": len(self.state.modified_files),
323
+ "files_changed": self.state.modified_files,
324
+ "unintended_files_count": v_result.unintended_files_count,
325
+ "retries_count": len(self.state.failures),
326
+ "git_diff_reviewed": True,
327
+ "success_criteria_passed": f"{sum(1 for a in v_result.assertions if a.passed)}/{len(v_result.assertions)}",
328
+ "summary": {
329
+ "what_changed": f"Modified files: {', '.join(self.state.modified_files) if self.state.modified_files else 'None'}",
330
+ "why_it_changed": self.state.contract.goal if self.state.contract else "Fulfilled task requirements",
331
+ "what_was_verified": f"Independent test suite ({v_result.tests_passed} tests passed)",
332
+ "remaining_limitations": "None detected under verified test suite."
333
+ }
334
+ }
335
+ self.state.proof_of_done = pod
336
+ self.session_mgr.save_session(self.state)
337
+ self._notify("proof_of_done", pod)
338
+
@@ -0,0 +1,9 @@
1
+ """Checkpoints package for Terminal Agent."""
2
+
3
+ from terminal_agent.checkpoints.manager import CheckpointManager, get_checkpoints_dir
4
+
5
+ __all__ = [
6
+ "CheckpointManager",
7
+ "get_checkpoints_dir",
8
+ ]
9
+
@@ -0,0 +1,151 @@
1
+ """Checkpoint manager for creating and restoring repository and session snapshots."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional
10
+
11
+ from terminal_agent.git.adapter import GitAdapter
12
+ from terminal_agent.session.models import CheckpointSnapshot
13
+
14
+
15
+ def get_checkpoints_dir(working_dir: Optional[Path] = None) -> Path:
16
+ """Return .terminal_agent/checkpoints directory."""
17
+ base = working_dir or Path.cwd()
18
+ c_dir = base / ".terminal_agent" / "checkpoints"
19
+ c_dir.mkdir(parents=True, exist_ok=True)
20
+ return c_dir
21
+
22
+
23
+ class CheckpointManager:
24
+ """Creates, lists, and restores filesystem snapshots."""
25
+
26
+ def __init__(self, working_dir: Optional[Path] = None):
27
+ self.working_dir = (working_dir or Path.cwd()).resolve()
28
+ self.checkpoints_dir = get_checkpoints_dir(self.working_dir)
29
+ self.git_adapter = GitAdapter(self.working_dir)
30
+
31
+ def create_checkpoint(
32
+ self,
33
+ name: str,
34
+ step_number: int = 0,
35
+ target_files: Optional[List[str]] = None
36
+ ) -> CheckpointSnapshot:
37
+ """Create a checkpoint snapshot of the repository."""
38
+ now = datetime.now(timezone.utc)
39
+ chk_id = f"chk_{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:4]}"
40
+ chk_dir = self.checkpoints_dir / chk_id
41
+ chk_dir.mkdir(parents=True, exist_ok=True)
42
+ files_store_dir = chk_dir / "files"
43
+ files_store_dir.mkdir(parents=True, exist_ok=True)
44
+ git_commit = self.git_adapter.get_head_commit()
45
+ files_to_save: List[Path] = []
46
+ if target_files:
47
+ for f in target_files:
48
+ p = (self.working_dir / f).resolve()
49
+ if p.exists() and p.is_file():
50
+ files_to_save.append(p)
51
+ else:
52
+ for root, dirs, files in os.walk(self.working_dir):
53
+ dirs[:] = [d for d in dirs if d not in (".git", ".terminal_agent", ".venv", "venv", "__pycache__", "node_modules", "dist", "build", ".pytest_cache")]
54
+ for f in files:
55
+ full_p = Path(root) / f
56
+ if not full_p.name.startswith(".env") and not full_p.name.endswith((".key", ".pem")):
57
+ files_to_save.append(full_p)
58
+ file_contents: Dict[str, str] = {}
59
+ modified_rel_paths: List[str] = []
60
+ for p in files_to_save:
61
+ try:
62
+ rel_p = str(p.relative_to(self.working_dir)).replace("\\", "/")
63
+ dest = files_store_dir / rel_p
64
+ dest.parent.mkdir(parents=True, exist_ok=True)
65
+ shutil.copy2(p, dest)
66
+ try:
67
+ content = p.read_text(encoding="utf-8", errors="ignore")
68
+ if len(content) < 500000:
69
+ file_contents[rel_p] = content
70
+ except Exception:
71
+ pass
72
+ modified_rel_paths.append(rel_p)
73
+ except Exception:
74
+ continue
75
+ snapshot = CheckpointSnapshot(
76
+ checkpoint_id=chk_id,
77
+ name=name,
78
+ created_at=now.isoformat(),
79
+ step_number=step_number,
80
+ git_commit=git_commit,
81
+ modified_files=modified_rel_paths,
82
+ file_contents=file_contents
83
+ )
84
+ metadata_file = chk_dir / "metadata.json"
85
+ metadata_file.write_text(snapshot.model_dump_json(indent=2), encoding="utf-8")
86
+ return snapshot
87
+
88
+ def list_checkpoints(self) -> List[CheckpointSnapshot]:
89
+ """List all available checkpoints ordered by most recent."""
90
+ checkpoints = []
91
+ if not self.checkpoints_dir.exists():
92
+ return checkpoints
93
+ for d in sorted(self.checkpoints_dir.iterdir(), key=os.path.getmtime, reverse=True):
94
+ if d.is_dir():
95
+ meta_file = d / "metadata.json"
96
+ if meta_file.exists():
97
+ try:
98
+ data = json.loads(meta_file.read_text(encoding="utf-8"))
99
+ checkpoints.append(CheckpointSnapshot.model_validate(data))
100
+ except Exception:
101
+ continue
102
+ return checkpoints
103
+
104
+ def get_checkpoint(self, checkpoint_id: str) -> Optional[CheckpointSnapshot]:
105
+ """Retrieve checkpoint by ID or name substring."""
106
+ for chk in self.list_checkpoints():
107
+ if chk.checkpoint_id == checkpoint_id or checkpoint_id in chk.checkpoint_id or chk.name == checkpoint_id:
108
+ return chk
109
+ return None
110
+
111
+ def rollback(self, checkpoint_id: str) -> bool:
112
+ """Restore repository state from a checkpoint."""
113
+ chk = self.get_checkpoint(checkpoint_id)
114
+ if not chk:
115
+ return False
116
+ chk_dir = self.checkpoints_dir / chk.checkpoint_id
117
+ files_store_dir = chk_dir / "files"
118
+ if not files_store_dir.exists():
119
+ return False
120
+ for root, _, files in os.walk(files_store_dir):
121
+ for f in files:
122
+ src_path = Path(root) / f
123
+ rel_path = src_path.relative_to(files_store_dir)
124
+ target_path = self.working_dir / rel_path
125
+ target_path.parent.mkdir(parents=True, exist_ok=True)
126
+ shutil.copy2(src_path, target_path)
127
+ return True
128
+
129
+ def prune_checkpoints(self, days: Optional[int] = None) -> List[Path]:
130
+ """Delete checkpoint directories older than ``days``.
131
+
132
+ If ``days`` is None, delete every checkpoint directory. The parent
133
+ checkpoints directory is left in place. Returns deleted directory paths.
134
+ """
135
+ deleted: List[Path] = []
136
+ if not self.checkpoints_dir.exists():
137
+ return deleted
138
+ cutoff = None
139
+ if days is not None:
140
+ cutoff = datetime.now(timezone.utc).timestamp() - (days * 86400)
141
+ for entry in list(self.checkpoints_dir.iterdir()):
142
+ if not entry.is_dir():
143
+ continue
144
+ if cutoff is not None and entry.stat().st_mtime >= cutoff:
145
+ continue
146
+ try:
147
+ shutil.rmtree(entry)
148
+ deleted.append(entry)
149
+ except OSError:
150
+ continue
151
+ return deleted
@@ -0,0 +1,26 @@
1
+ """CLI commands package for Terminal Agent."""
2
+
3
+ from terminal_agent.cli.commands.run import run_command
4
+ from terminal_agent.cli.commands.resume import resume_command
5
+ from terminal_agent.cli.commands.status import status_command
6
+ from terminal_agent.cli.commands.diff import diff_command
7
+ from terminal_agent.cli.commands.test import test_command
8
+ from terminal_agent.cli.commands.checkpoint import checkpoint_app, rollback_command
9
+ from terminal_agent.cli.commands.doctor import doctor_command
10
+ from terminal_agent.cli.commands.trace import trace_command
11
+ from terminal_agent.cli.commands.config_cmd import config_command
12
+ from terminal_agent.cli.commands.clean import clean_command
13
+
14
+ __all__ = [
15
+ "run_command",
16
+ "resume_command",
17
+ "status_command",
18
+ "diff_command",
19
+ "test_command",
20
+ "checkpoint_app",
21
+ "rollback_command",
22
+ "doctor_command",
23
+ "trace_command",
24
+ "config_command",
25
+ "clean_command",
26
+ ]
@@ -0,0 +1,76 @@
1
+ """CLI checkpoint and rollback commands."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import typer
6
+ from rich.table import Table
7
+
8
+ from terminal_agent.checkpoints.manager import CheckpointManager
9
+ from terminal_agent.cli.theme import SYM_CHECK, SYM_CROSS, console
10
+ from terminal_agent.cli.ui import render_header
11
+
12
+ checkpoint_app = typer.Typer(help="Manage repository checkpoints.")
13
+
14
+
15
+ @checkpoint_app.command("list")
16
+ def list_checkpoints_cmd(
17
+ working_dir: Optional[Path] = typer.Option(None, "--cwd", "-C", help="Target repository directory"),
18
+ ) -> None:
19
+ """List all available checkpoints for this repository."""
20
+ render_header()
21
+ target_dir = (working_dir or Path.cwd()).resolve()
22
+ mgr = CheckpointManager(target_dir)
23
+ checkpoints = mgr.list_checkpoints()
24
+
25
+ if not checkpoints:
26
+ console.print("[agent.muted]No checkpoints found for this repository.[/agent.muted]")
27
+ return
28
+
29
+ table = Table(title="[agent.accent]CHECKPOINTS[/agent.accent]", border_style="agent.border")
30
+ table.add_column("Checkpoint ID", style="agent.accent")
31
+ table.add_column("Name", style="agent.text")
32
+ table.add_column("Created At", style="agent.muted")
33
+ table.add_column("Files", style="agent.muted")
34
+ table.add_column("Git Commit", style="agent.muted")
35
+
36
+ for chk in checkpoints:
37
+ table.add_row(
38
+ chk.checkpoint_id,
39
+ chk.name,
40
+ chk.created_at[:19].replace("T", " "),
41
+ str(len(chk.modified_files)),
42
+ chk.git_commit[:8] if chk.git_commit else "N/A"
43
+ )
44
+
45
+ console.print(table)
46
+
47
+
48
+ @checkpoint_app.command("create")
49
+ def create_checkpoint_cmd(
50
+ name: str = typer.Argument("manual_checkpoint", help="Descriptive checkpoint name"),
51
+ working_dir: Optional[Path] = typer.Option(None, "--cwd", "-C", help="Target repository directory"),
52
+ ) -> None:
53
+ """Create a new manual checkpoint snapshot."""
54
+ render_header()
55
+ target_dir = (working_dir or Path.cwd()).resolve()
56
+ mgr = CheckpointManager(target_dir)
57
+ snapshot = mgr.create_checkpoint(name=name)
58
+ console.print(f"{SYM_CHECK} [agent.success]Created checkpoint '{snapshot.checkpoint_id}' ('{snapshot.name}') with {len(snapshot.modified_files)} files.[/agent.success]")
59
+
60
+
61
+ def rollback_command(
62
+ checkpoint_id: str = typer.Argument(..., help="Checkpoint ID or name to restore"),
63
+ working_dir: Optional[Path] = typer.Option(None, "--cwd", "-C", help="Target repository directory"),
64
+ ) -> None:
65
+ """Roll back workspace files to a previous checkpoint."""
66
+ render_header()
67
+ target_dir = (working_dir or Path.cwd()).resolve()
68
+ mgr = CheckpointManager(target_dir)
69
+ success = mgr.rollback(checkpoint_id)
70
+
71
+ if success:
72
+ console.print(f"{SYM_CHECK} [agent.success]Successfully rolled back to checkpoint '{checkpoint_id}'.[/agent.success]")
73
+ else:
74
+ console.print(f"{SYM_CROSS} [agent.error]Failed to rollback to checkpoint '{checkpoint_id}'. Checkpoint not found.[/agent.error]")
75
+ raise typer.Exit(1)
76
+