up-cli 0.1.1__py3-none-any.whl → 0.5.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 (55) hide show
  1. up/__init__.py +1 -1
  2. up/ai_cli.py +229 -0
  3. up/cli.py +75 -4
  4. up/commands/agent.py +521 -0
  5. up/commands/bisect.py +343 -0
  6. up/commands/branch.py +350 -0
  7. up/commands/dashboard.py +248 -0
  8. up/commands/init.py +195 -6
  9. up/commands/learn.py +1741 -0
  10. up/commands/memory.py +545 -0
  11. up/commands/new.py +108 -10
  12. up/commands/provenance.py +267 -0
  13. up/commands/review.py +239 -0
  14. up/commands/start.py +1124 -0
  15. up/commands/status.py +360 -0
  16. up/commands/summarize.py +122 -0
  17. up/commands/sync.py +317 -0
  18. up/commands/vibe.py +304 -0
  19. up/context.py +421 -0
  20. up/core/__init__.py +69 -0
  21. up/core/checkpoint.py +479 -0
  22. up/core/provenance.py +364 -0
  23. up/core/state.py +678 -0
  24. up/events.py +512 -0
  25. up/git/__init__.py +37 -0
  26. up/git/utils.py +270 -0
  27. up/git/worktree.py +331 -0
  28. up/learn/__init__.py +155 -0
  29. up/learn/analyzer.py +227 -0
  30. up/learn/plan.py +374 -0
  31. up/learn/research.py +511 -0
  32. up/learn/utils.py +117 -0
  33. up/memory.py +1096 -0
  34. up/parallel.py +551 -0
  35. up/summarizer.py +407 -0
  36. up/templates/__init__.py +70 -2
  37. up/templates/config/__init__.py +502 -20
  38. up/templates/docs/SKILL.md +28 -0
  39. up/templates/docs/__init__.py +341 -0
  40. up/templates/docs/standards/HEADERS.md +24 -0
  41. up/templates/docs/standards/STRUCTURE.md +18 -0
  42. up/templates/docs/standards/TEMPLATES.md +19 -0
  43. up/templates/learn/__init__.py +567 -14
  44. up/templates/loop/__init__.py +546 -27
  45. up/templates/mcp/__init__.py +474 -0
  46. up/templates/projects/__init__.py +786 -0
  47. up/ui/__init__.py +14 -0
  48. up/ui/loop_display.py +650 -0
  49. up/ui/theme.py +137 -0
  50. up_cli-0.5.0.dist-info/METADATA +519 -0
  51. up_cli-0.5.0.dist-info/RECORD +55 -0
  52. up_cli-0.1.1.dist-info/METADATA +0 -186
  53. up_cli-0.1.1.dist-info/RECORD +0 -14
  54. {up_cli-0.1.1.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
  55. {up_cli-0.1.1.dist-info → up_cli-0.5.0.dist-info}/entry_points.txt +0 -0
up/core/checkpoint.py ADDED
@@ -0,0 +1,479 @@
1
+ """Unified checkpoint management for up-cli.
2
+
3
+ This module provides a single implementation for Git checkpoints,
4
+ used by all commands that need to save/restore state:
5
+ - up save
6
+ - up reset
7
+ - up start (before AI operations)
8
+ - up agent (before merge)
9
+
10
+ Checkpoints are lightweight Git tags + metadata stored in .up/checkpoints/
11
+ """
12
+
13
+ import json
14
+ import subprocess
15
+ from dataclasses import dataclass, asdict
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Optional, List
19
+
20
+ from up.core.state import get_state_manager, AgentState
21
+
22
+
23
+ @dataclass
24
+ class CheckpointMetadata:
25
+ """Metadata for a checkpoint."""
26
+ id: str
27
+ commit_sha: str
28
+ tag_name: str
29
+ message: str
30
+ created_at: str
31
+ branch: str
32
+ files_changed: int = 0
33
+ task_id: Optional[str] = None
34
+ agent_id: Optional[str] = None
35
+
36
+ def to_dict(self) -> dict:
37
+ return asdict(self)
38
+
39
+ @classmethod
40
+ def from_dict(cls, data: dict) -> "CheckpointMetadata":
41
+ return cls(**{
42
+ k: v for k, v in data.items()
43
+ if k in cls.__dataclass_fields__
44
+ })
45
+
46
+
47
+ class CheckpointManager:
48
+ """Manages Git checkpoints for up-cli.
49
+
50
+ Provides:
51
+ - Create checkpoints (commit + tag)
52
+ - Restore to checkpoints
53
+ - List available checkpoints
54
+ - Cleanup old checkpoints
55
+ """
56
+
57
+ CHECKPOINT_DIR = ".up/checkpoints"
58
+ TAG_PREFIX = "up-checkpoint"
59
+
60
+ def __init__(self, workspace: Optional[Path] = None):
61
+ """Initialize checkpoint manager.
62
+
63
+ Args:
64
+ workspace: Project root directory (defaults to cwd)
65
+ """
66
+ self.workspace = workspace or Path.cwd()
67
+ self.checkpoint_dir = self.workspace / self.CHECKPOINT_DIR
68
+ self.state_manager = get_state_manager(workspace)
69
+
70
+ def _run_git(self, *args, check: bool = True, timeout: int = 60) -> subprocess.CompletedProcess:
71
+ """Run a git command with error handling.
72
+
73
+ Args:
74
+ *args: Git command arguments
75
+ check: Raise GitError on failure
76
+ timeout: Command timeout in seconds
77
+
78
+ Returns:
79
+ CompletedProcess result
80
+
81
+ Raises:
82
+ GitError: If command fails and check=True
83
+ GitNotInstalledError: If git is not installed
84
+ """
85
+ try:
86
+ result = subprocess.run(
87
+ ["git"] + list(args),
88
+ cwd=self.workspace,
89
+ capture_output=True,
90
+ text=True,
91
+ timeout=timeout
92
+ )
93
+ if check and result.returncode != 0:
94
+ raise GitError(f"Git command failed: git {' '.join(args)}\n{result.stderr}")
95
+ return result
96
+ except FileNotFoundError:
97
+ raise GitNotInstalledError(
98
+ "Git is not installed or not in PATH. "
99
+ "Please install git: https://git-scm.com/downloads"
100
+ )
101
+ except subprocess.TimeoutExpired:
102
+ raise GitError(f"Git command timed out after {timeout}s: git {' '.join(args)}")
103
+
104
+ def _is_git_repo(self) -> bool:
105
+ """Check if workspace is a git repository."""
106
+ result = self._run_git("rev-parse", "--git-dir", check=False)
107
+ return result.returncode == 0
108
+
109
+ def _get_current_branch(self) -> str:
110
+ """Get current branch name."""
111
+ result = self._run_git("rev-parse", "--abbrev-ref", "HEAD")
112
+ return result.stdout.strip()
113
+
114
+ def _get_head_sha(self) -> str:
115
+ """Get current HEAD commit SHA."""
116
+ result = self._run_git("rev-parse", "HEAD")
117
+ return result.stdout.strip()
118
+
119
+ def _has_changes(self) -> bool:
120
+ """Check if there are uncommitted changes."""
121
+ result = self._run_git("status", "--porcelain")
122
+ return bool(result.stdout.strip())
123
+
124
+ def _count_changed_files(self) -> int:
125
+ """Count number of changed files."""
126
+ result = self._run_git("status", "--porcelain")
127
+ return len(result.stdout.strip().split("\n")) if result.stdout.strip() else 0
128
+
129
+ def save(
130
+ self,
131
+ message: str = None,
132
+ task_id: str = None,
133
+ agent_id: str = None,
134
+ auto_commit: bool = True
135
+ ) -> CheckpointMetadata:
136
+ """Create a checkpoint.
137
+
138
+ Args:
139
+ message: Optional checkpoint message
140
+ task_id: Associated task ID
141
+ agent_id: Associated agent ID (for worktrees)
142
+ auto_commit: Whether to commit dirty files
143
+
144
+ Returns:
145
+ CheckpointMetadata for the created checkpoint
146
+
147
+ Raises:
148
+ GitError: If git operations fail
149
+ NotAGitRepoError: If not in a git repository
150
+ """
151
+ if not self._is_git_repo():
152
+ raise NotAGitRepoError("Not a git repository")
153
+
154
+ # Generate checkpoint ID
155
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
156
+ checkpoint_id = f"cp-{timestamp}"
157
+ if task_id:
158
+ checkpoint_id = f"cp-{task_id}-{timestamp}"
159
+
160
+ # Commit dirty files if requested
161
+ files_changed = 0
162
+ if auto_commit and self._has_changes():
163
+ files_changed = self._count_changed_files()
164
+ self._run_git("add", "-A")
165
+ commit_message = message or f"checkpoint: {checkpoint_id}"
166
+ self._run_git("commit", "-m", commit_message)
167
+
168
+ # Get commit info
169
+ commit_sha = self._get_head_sha()
170
+ branch = self._get_current_branch()
171
+
172
+ # Create lightweight tag
173
+ tag_name = f"{self.TAG_PREFIX}/{checkpoint_id}"
174
+ self._run_git("tag", tag_name, check=False) # May already exist
175
+
176
+ # Create metadata
177
+ metadata = CheckpointMetadata(
178
+ id=checkpoint_id,
179
+ commit_sha=commit_sha,
180
+ tag_name=tag_name,
181
+ message=message or f"Checkpoint before {task_id or 'AI operation'}",
182
+ created_at=datetime.now().isoformat(),
183
+ branch=branch,
184
+ files_changed=files_changed,
185
+ task_id=task_id,
186
+ agent_id=agent_id,
187
+ )
188
+
189
+ # Save metadata
190
+ self._save_metadata(metadata)
191
+
192
+ # Update state
193
+ self.state_manager.add_checkpoint(checkpoint_id)
194
+
195
+ return metadata
196
+
197
+ def restore(
198
+ self,
199
+ checkpoint_id: str = None,
200
+ hard: bool = True
201
+ ) -> CheckpointMetadata:
202
+ """Restore to a checkpoint.
203
+
204
+ Args:
205
+ checkpoint_id: Checkpoint to restore (defaults to last)
206
+ hard: Whether to use hard reset (discard changes)
207
+
208
+ Returns:
209
+ CheckpointMetadata of restored checkpoint
210
+
211
+ Raises:
212
+ CheckpointNotFoundError: If checkpoint doesn't exist
213
+ """
214
+ if not self._is_git_repo():
215
+ raise NotAGitRepoError("Not a git repository")
216
+
217
+ # Get checkpoint
218
+ if checkpoint_id:
219
+ metadata = self._load_metadata(checkpoint_id)
220
+ else:
221
+ # Get last checkpoint
222
+ last_id = self.state_manager.state.loop.last_checkpoint
223
+ if not last_id:
224
+ raise CheckpointNotFoundError("No checkpoints available")
225
+ metadata = self._load_metadata(last_id)
226
+
227
+ if metadata is None:
228
+ # Try to restore from tag directly
229
+ tag_name = f"{self.TAG_PREFIX}/{checkpoint_id}"
230
+ result = self._run_git("rev-parse", tag_name, check=False)
231
+ if result.returncode != 0:
232
+ raise CheckpointNotFoundError(f"Checkpoint not found: {checkpoint_id}")
233
+
234
+ commit_sha = result.stdout.strip()
235
+ metadata = CheckpointMetadata(
236
+ id=checkpoint_id,
237
+ commit_sha=commit_sha,
238
+ tag_name=tag_name,
239
+ message="Restored from tag",
240
+ created_at=datetime.now().isoformat(),
241
+ branch=self._get_current_branch(),
242
+ )
243
+
244
+ # Perform reset
245
+ reset_type = "--hard" if hard else "--soft"
246
+ self._run_git("reset", reset_type, metadata.commit_sha)
247
+
248
+ # Record rollback
249
+ self.state_manager.record_rollback()
250
+
251
+ return metadata
252
+
253
+ def list_checkpoints(self, limit: int = 20) -> List[CheckpointMetadata]:
254
+ """List available checkpoints.
255
+
256
+ Args:
257
+ limit: Maximum number to return
258
+
259
+ Returns:
260
+ List of checkpoint metadata, newest first
261
+ """
262
+ checkpoints = []
263
+
264
+ # Get from state
265
+ checkpoint_ids = self.state_manager.state.checkpoints[-limit:]
266
+ checkpoint_ids.reverse() # Newest first
267
+
268
+ for cp_id in checkpoint_ids:
269
+ metadata = self._load_metadata(cp_id)
270
+ if metadata:
271
+ checkpoints.append(metadata)
272
+
273
+ return checkpoints
274
+
275
+ def get_last_checkpoint(self) -> Optional[CheckpointMetadata]:
276
+ """Get the last checkpoint."""
277
+ last_id = self.state_manager.state.loop.last_checkpoint
278
+ if last_id:
279
+ return self._load_metadata(last_id)
280
+ return None
281
+
282
+ def cleanup(self, keep: int = 20) -> int:
283
+ """Remove old checkpoints.
284
+
285
+ Args:
286
+ keep: Number of recent checkpoints to keep
287
+
288
+ Returns:
289
+ Number of checkpoints removed
290
+ """
291
+ all_checkpoints = self.state_manager.state.checkpoints
292
+ if len(all_checkpoints) <= keep:
293
+ return 0
294
+
295
+ to_remove = all_checkpoints[:-keep]
296
+ removed = 0
297
+
298
+ for cp_id in to_remove:
299
+ # Remove tag
300
+ tag_name = f"{self.TAG_PREFIX}/{cp_id}"
301
+ self._run_git("tag", "-d", tag_name, check=False)
302
+
303
+ # Remove metadata file
304
+ metadata_file = self.checkpoint_dir / f"{cp_id}.json"
305
+ if metadata_file.exists():
306
+ metadata_file.unlink()
307
+
308
+ removed += 1
309
+
310
+ # Update state
311
+ self.state_manager.state.checkpoints = all_checkpoints[-keep:]
312
+ self.state_manager.save()
313
+
314
+ return removed
315
+
316
+ def diff_from_checkpoint(self, checkpoint_id: str = None) -> str:
317
+ """Get diff from checkpoint to current state.
318
+
319
+ Args:
320
+ checkpoint_id: Checkpoint to diff from (defaults to last)
321
+
322
+ Returns:
323
+ Diff output as string
324
+ """
325
+ if not checkpoint_id:
326
+ checkpoint_id = self.state_manager.state.loop.last_checkpoint
327
+
328
+ if not checkpoint_id:
329
+ # Diff from HEAD
330
+ result = self._run_git("diff", "HEAD")
331
+ return result.stdout
332
+
333
+ tag_name = f"{self.TAG_PREFIX}/{checkpoint_id}"
334
+ result = self._run_git("diff", tag_name, "HEAD", check=False)
335
+
336
+ if result.returncode != 0:
337
+ # Tag might not exist, try commit SHA from metadata
338
+ metadata = self._load_metadata(checkpoint_id)
339
+ if metadata:
340
+ result = self._run_git("diff", metadata.commit_sha, "HEAD")
341
+ return result.stdout
342
+ return ""
343
+
344
+ return result.stdout
345
+
346
+ def diff_stats(self, checkpoint_id: str = None) -> dict:
347
+ """Get diff statistics from checkpoint.
348
+
349
+ Returns:
350
+ Dict with files, insertions, deletions
351
+ """
352
+ if not checkpoint_id:
353
+ checkpoint_id = self.state_manager.state.loop.last_checkpoint
354
+
355
+ if not checkpoint_id:
356
+ result = self._run_git("diff", "--stat", "HEAD")
357
+ else:
358
+ tag_name = f"{self.TAG_PREFIX}/{checkpoint_id}"
359
+ result = self._run_git("diff", "--stat", tag_name, "HEAD", check=False)
360
+ if result.returncode != 0:
361
+ return {"files": 0, "insertions": 0, "deletions": 0}
362
+
363
+ # Parse stat output
364
+ lines = result.stdout.strip().split("\n")
365
+ if not lines or not lines[-1]:
366
+ return {"files": 0, "insertions": 0, "deletions": 0}
367
+
368
+ # Last line has summary: "X files changed, Y insertions(+), Z deletions(-)"
369
+ import re
370
+ summary = lines[-1] if lines else ""
371
+
372
+ files = 0
373
+ insertions = 0
374
+ deletions = 0
375
+
376
+ files_match = re.search(r"(\d+) files? changed", summary)
377
+ if files_match:
378
+ files = int(files_match.group(1))
379
+
380
+ ins_match = re.search(r"(\d+) insertions?\(\+\)", summary)
381
+ if ins_match:
382
+ insertions = int(ins_match.group(1))
383
+
384
+ del_match = re.search(r"(\d+) deletions?\(-\)", summary)
385
+ if del_match:
386
+ deletions = int(del_match.group(1))
387
+
388
+ return {
389
+ "files": files,
390
+ "insertions": insertions,
391
+ "deletions": deletions,
392
+ }
393
+
394
+ def _save_metadata(self, metadata: CheckpointMetadata) -> None:
395
+ """Save checkpoint metadata to file."""
396
+ self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
397
+ metadata_file = self.checkpoint_dir / f"{metadata.id}.json"
398
+ metadata_file.write_text(json.dumps(metadata.to_dict(), indent=2))
399
+
400
+ def _load_metadata(self, checkpoint_id: str) -> Optional[CheckpointMetadata]:
401
+ """Load checkpoint metadata from file."""
402
+ metadata_file = self.checkpoint_dir / f"{checkpoint_id}.json"
403
+ if not metadata_file.exists():
404
+ return None
405
+
406
+ try:
407
+ data = json.loads(metadata_file.read_text())
408
+ return CheckpointMetadata.from_dict(data)
409
+ except (json.JSONDecodeError, KeyError):
410
+ return None
411
+
412
+
413
+ # =============================================================================
414
+ # Exceptions
415
+ # =============================================================================
416
+
417
+ class CheckpointError(Exception):
418
+ """Base exception for checkpoint operations."""
419
+ pass
420
+
421
+
422
+ class GitError(CheckpointError):
423
+ """Git operation failed."""
424
+ pass
425
+
426
+
427
+ class GitNotInstalledError(GitError):
428
+ """Git is not installed or not in PATH."""
429
+ pass
430
+
431
+
432
+ class NotAGitRepoError(CheckpointError):
433
+ """Not in a git repository."""
434
+ pass
435
+
436
+
437
+ class CheckpointNotFoundError(CheckpointError):
438
+ """Checkpoint not found."""
439
+ pass
440
+
441
+
442
+ # =============================================================================
443
+ # Module-level convenience functions
444
+ # =============================================================================
445
+
446
+ _default_manager: Optional[CheckpointManager] = None
447
+
448
+
449
+ def get_checkpoint_manager(workspace: Optional[Path] = None) -> CheckpointManager:
450
+ """Get or create the default checkpoint manager."""
451
+ global _default_manager
452
+ if _default_manager is None or (workspace and _default_manager.workspace != workspace):
453
+ _default_manager = CheckpointManager(workspace)
454
+ return _default_manager
455
+
456
+
457
+ def save_checkpoint(
458
+ message: str = None,
459
+ task_id: str = None,
460
+ workspace: Optional[Path] = None
461
+ ) -> CheckpointMetadata:
462
+ """Create a checkpoint (convenience function)."""
463
+ return get_checkpoint_manager(workspace).save(message=message, task_id=task_id)
464
+
465
+
466
+ def restore_checkpoint(
467
+ checkpoint_id: str = None,
468
+ workspace: Optional[Path] = None
469
+ ) -> CheckpointMetadata:
470
+ """Restore to a checkpoint (convenience function)."""
471
+ return get_checkpoint_manager(workspace).restore(checkpoint_id=checkpoint_id)
472
+
473
+
474
+ def get_diff(
475
+ checkpoint_id: str = None,
476
+ workspace: Optional[Path] = None
477
+ ) -> str:
478
+ """Get diff from checkpoint (convenience function)."""
479
+ return get_checkpoint_manager(workspace).diff_from_checkpoint(checkpoint_id)