up-cli 0.2.0__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.
- up/__init__.py +1 -1
- up/ai_cli.py +229 -0
- up/cli.py +54 -9
- up/commands/agent.py +521 -0
- up/commands/bisect.py +343 -0
- up/commands/branch.py +350 -0
- up/commands/init.py +195 -6
- up/commands/learn.py +1392 -32
- up/commands/memory.py +545 -0
- up/commands/provenance.py +267 -0
- up/commands/review.py +239 -0
- up/commands/start.py +752 -42
- up/commands/status.py +173 -18
- up/commands/sync.py +317 -0
- up/commands/vibe.py +304 -0
- up/context.py +64 -10
- up/core/__init__.py +69 -0
- up/core/checkpoint.py +479 -0
- up/core/provenance.py +364 -0
- up/core/state.py +678 -0
- up/events.py +512 -0
- up/git/__init__.py +37 -0
- up/git/utils.py +270 -0
- up/git/worktree.py +331 -0
- up/learn/__init__.py +155 -0
- up/learn/analyzer.py +227 -0
- up/learn/plan.py +374 -0
- up/learn/research.py +511 -0
- up/learn/utils.py +117 -0
- up/memory.py +1096 -0
- up/parallel.py +551 -0
- up/templates/config/__init__.py +1 -1
- up/templates/docs/SKILL.md +28 -0
- up/templates/docs/__init__.py +341 -0
- up/templates/docs/standards/HEADERS.md +24 -0
- up/templates/docs/standards/STRUCTURE.md +18 -0
- up/templates/docs/standards/TEMPLATES.md +19 -0
- up/templates/loop/__init__.py +92 -32
- up/ui/__init__.py +14 -0
- up/ui/loop_display.py +650 -0
- up/ui/theme.py +137 -0
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/METADATA +160 -15
- up_cli-0.5.0.dist-info/RECORD +55 -0
- up_cli-0.2.0.dist-info/RECORD +0 -23
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/entry_points.txt +0 -0
up/commands/status.py
CHANGED
|
@@ -44,21 +44,128 @@ def collect_status(workspace: Path) -> dict:
|
|
|
44
44
|
status = {
|
|
45
45
|
"workspace": str(workspace),
|
|
46
46
|
"initialized": False,
|
|
47
|
+
"state_version": None,
|
|
47
48
|
"context_budget": None,
|
|
48
49
|
"loop_state": None,
|
|
49
50
|
"circuit_breaker": None,
|
|
51
|
+
"checkpoints": None,
|
|
52
|
+
"agents": None,
|
|
53
|
+
"doom_loop": None,
|
|
50
54
|
"skills": [],
|
|
55
|
+
"hooks": None,
|
|
56
|
+
"memory": None,
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
# Check if initialized
|
|
54
60
|
claude_dir = workspace / ".claude"
|
|
55
61
|
cursor_dir = workspace / ".cursor"
|
|
56
|
-
|
|
62
|
+
up_dir = workspace / ".up"
|
|
63
|
+
status["initialized"] = claude_dir.exists() or cursor_dir.exists() or up_dir.exists()
|
|
57
64
|
|
|
58
65
|
if not status["initialized"]:
|
|
59
66
|
return status
|
|
60
67
|
|
|
61
|
-
#
|
|
68
|
+
# Try to load unified state first
|
|
69
|
+
try:
|
|
70
|
+
from up.core.state import get_state_manager
|
|
71
|
+
manager = get_state_manager(workspace)
|
|
72
|
+
state = manager.state
|
|
73
|
+
status["state_version"] = state.version
|
|
74
|
+
|
|
75
|
+
# Context budget from unified state
|
|
76
|
+
status["context_budget"] = {
|
|
77
|
+
"budget": state.context.budget,
|
|
78
|
+
"total_tokens": state.context.total_tokens,
|
|
79
|
+
"remaining_tokens": state.context.remaining_tokens,
|
|
80
|
+
"usage_percent": state.context.usage_percent,
|
|
81
|
+
"status": state.context.status,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Loop state from unified state
|
|
85
|
+
status["loop_state"] = {
|
|
86
|
+
"iteration": state.loop.iteration,
|
|
87
|
+
"phase": state.loop.phase,
|
|
88
|
+
"current_task": state.loop.current_task,
|
|
89
|
+
"tasks_completed": len(state.loop.tasks_completed),
|
|
90
|
+
"tasks_failed": len(state.loop.tasks_failed),
|
|
91
|
+
"success_rate": state.metrics.success_rate,
|
|
92
|
+
"last_checkpoint": state.loop.last_checkpoint,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Circuit breakers
|
|
96
|
+
status["circuit_breaker"] = {
|
|
97
|
+
name: {"state": cb.state, "failures": cb.failures}
|
|
98
|
+
for name, cb in state.circuit_breakers.items()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Checkpoints
|
|
102
|
+
status["checkpoints"] = {
|
|
103
|
+
"total": len(state.checkpoints),
|
|
104
|
+
"last": state.loop.last_checkpoint,
|
|
105
|
+
"recent": state.checkpoints[-5:] if state.checkpoints else [],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Agents
|
|
109
|
+
if state.agents:
|
|
110
|
+
status["agents"] = {
|
|
111
|
+
task_id: {
|
|
112
|
+
"status": agent.status,
|
|
113
|
+
"phase": agent.phase,
|
|
114
|
+
"worktree": agent.worktree_path,
|
|
115
|
+
}
|
|
116
|
+
for task_id, agent in state.agents.items()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Doom loop detection
|
|
120
|
+
is_doom, doom_msg = manager.check_doom_loop()
|
|
121
|
+
if is_doom or state.loop.consecutive_failures > 0:
|
|
122
|
+
status["doom_loop"] = {
|
|
123
|
+
"triggered": is_doom,
|
|
124
|
+
"consecutive_failures": state.loop.consecutive_failures,
|
|
125
|
+
"threshold": state.loop.doom_loop_threshold,
|
|
126
|
+
"message": doom_msg if is_doom else None,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
except ImportError:
|
|
130
|
+
# Fallback to old state files
|
|
131
|
+
_collect_legacy_status(workspace, status)
|
|
132
|
+
|
|
133
|
+
# Git hooks status
|
|
134
|
+
from up.commands.sync import check_hooks_installed
|
|
135
|
+
status["hooks"] = check_hooks_installed(workspace)
|
|
136
|
+
|
|
137
|
+
# Memory status
|
|
138
|
+
memory_dir = workspace / ".up" / "memory"
|
|
139
|
+
if memory_dir.exists():
|
|
140
|
+
try:
|
|
141
|
+
from up.memory import MemoryManager
|
|
142
|
+
manager = MemoryManager(workspace, use_vectors=False)
|
|
143
|
+
stats = manager.get_stats()
|
|
144
|
+
status["memory"] = {
|
|
145
|
+
"total": stats.get("total", 0),
|
|
146
|
+
"branch": stats.get("current_branch", "unknown"),
|
|
147
|
+
"commit": stats.get("current_commit", "unknown"),
|
|
148
|
+
}
|
|
149
|
+
except Exception:
|
|
150
|
+
status["memory"] = {"total": 0}
|
|
151
|
+
|
|
152
|
+
# Skills
|
|
153
|
+
skills_dirs = [
|
|
154
|
+
workspace / ".claude/skills",
|
|
155
|
+
workspace / ".cursor/skills",
|
|
156
|
+
]
|
|
157
|
+
for skills_dir in skills_dirs:
|
|
158
|
+
if skills_dir.exists():
|
|
159
|
+
for skill_dir in skills_dir.iterdir():
|
|
160
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
161
|
+
status["skills"].append(skill_dir.name)
|
|
162
|
+
|
|
163
|
+
return status
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _collect_legacy_status(workspace: Path, status: dict) -> None:
|
|
167
|
+
"""Collect status from legacy state files."""
|
|
168
|
+
# Context budget (old location)
|
|
62
169
|
context_file = workspace / ".claude/context_budget.json"
|
|
63
170
|
if context_file.exists():
|
|
64
171
|
try:
|
|
@@ -66,7 +173,7 @@ def collect_status(workspace: Path) -> dict:
|
|
|
66
173
|
except json.JSONDecodeError:
|
|
67
174
|
status["context_budget"] = {"error": "Invalid JSON"}
|
|
68
175
|
|
|
69
|
-
# Loop state
|
|
176
|
+
# Loop state (old location)
|
|
70
177
|
loop_file = workspace / ".loop_state.json"
|
|
71
178
|
if loop_file.exists():
|
|
72
179
|
try:
|
|
@@ -82,19 +189,6 @@ def collect_status(workspace: Path) -> dict:
|
|
|
82
189
|
status["circuit_breaker"] = data.get("circuit_breaker", {})
|
|
83
190
|
except json.JSONDecodeError:
|
|
84
191
|
status["loop_state"] = {"error": "Invalid JSON"}
|
|
85
|
-
|
|
86
|
-
# Skills
|
|
87
|
-
skills_dirs = [
|
|
88
|
-
workspace / ".claude/skills",
|
|
89
|
-
workspace / ".cursor/skills",
|
|
90
|
-
]
|
|
91
|
-
for skills_dir in skills_dirs:
|
|
92
|
-
if skills_dir.exists():
|
|
93
|
-
for skill_dir in skills_dir.iterdir():
|
|
94
|
-
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
95
|
-
status["skills"].append(skill_dir.name)
|
|
96
|
-
|
|
97
|
-
return status
|
|
98
192
|
|
|
99
193
|
|
|
100
194
|
def display_status(status: dict) -> None:
|
|
@@ -162,7 +256,7 @@ def display_status(status: dict) -> None:
|
|
|
162
256
|
else:
|
|
163
257
|
console.print(" [dim]Not active[/]")
|
|
164
258
|
|
|
165
|
-
# Loop State
|
|
259
|
+
# Product Loop State
|
|
166
260
|
console.print("\n[bold]Product Loop[/]")
|
|
167
261
|
if status["loop_state"]:
|
|
168
262
|
loop = status["loop_state"]
|
|
@@ -177,8 +271,9 @@ def display_status(status: dict) -> None:
|
|
|
177
271
|
console.print(f" Current Task: [cyan]{current}[/]")
|
|
178
272
|
|
|
179
273
|
completed = loop.get("tasks_completed", 0)
|
|
274
|
+
failed = loop.get("tasks_failed", 0)
|
|
180
275
|
remaining = loop.get("tasks_remaining", 0)
|
|
181
|
-
total = completed + remaining
|
|
276
|
+
total = completed + failed + remaining if remaining else completed + failed
|
|
182
277
|
|
|
183
278
|
if total > 0:
|
|
184
279
|
progress = completed / total * 100
|
|
@@ -189,9 +284,69 @@ def display_status(status: dict) -> None:
|
|
|
189
284
|
|
|
190
285
|
success_rate = loop.get("success_rate", 1.0)
|
|
191
286
|
console.print(f" Success Rate: {success_rate * 100:.0f}%")
|
|
287
|
+
|
|
288
|
+
last_cp = loop.get("last_checkpoint")
|
|
289
|
+
if last_cp:
|
|
290
|
+
console.print(f" Last Checkpoint: [cyan]{last_cp}[/]")
|
|
192
291
|
else:
|
|
193
292
|
console.print(" [dim]Not active[/]")
|
|
194
293
|
|
|
294
|
+
# Doom Loop Detection
|
|
295
|
+
if status.get("doom_loop"):
|
|
296
|
+
doom = status["doom_loop"]
|
|
297
|
+
console.print("\n[bold]Doom Loop Detection[/]")
|
|
298
|
+
if doom["triggered"]:
|
|
299
|
+
console.print(f" [red]⚠ TRIGGERED[/] - {doom['consecutive_failures']}/{doom['threshold']} failures")
|
|
300
|
+
console.print(f" [dim]{doom.get('message', '')}[/]")
|
|
301
|
+
else:
|
|
302
|
+
console.print(f" Consecutive Failures: {doom['consecutive_failures']}/{doom['threshold']}")
|
|
303
|
+
|
|
304
|
+
# Checkpoints
|
|
305
|
+
if status.get("checkpoints"):
|
|
306
|
+
cp = status["checkpoints"]
|
|
307
|
+
console.print("\n[bold]Checkpoints[/]")
|
|
308
|
+
console.print(f" Total: {cp['total']}")
|
|
309
|
+
if cp.get("recent"):
|
|
310
|
+
console.print(f" Recent: {', '.join(cp['recent'][-3:])}")
|
|
311
|
+
|
|
312
|
+
# Active Agents
|
|
313
|
+
if status.get("agents"):
|
|
314
|
+
console.print("\n[bold]Active Agents[/]")
|
|
315
|
+
for task_id, agent in status["agents"].items():
|
|
316
|
+
status_icon = "🟢" if agent["status"] == "passed" else "🟡" if agent["status"] in ["executing", "verifying"] else "🔴"
|
|
317
|
+
console.print(f" {status_icon} {task_id}: {agent['status']} ({agent['phase']})")
|
|
318
|
+
|
|
319
|
+
# Memory
|
|
320
|
+
console.print("\n[bold]Memory[/]")
|
|
321
|
+
if status["memory"]:
|
|
322
|
+
mem = status["memory"]
|
|
323
|
+
total = mem.get("total", 0)
|
|
324
|
+
branch = mem.get("branch", "unknown")
|
|
325
|
+
commit = mem.get("commit", "unknown")
|
|
326
|
+
console.print(f" 📚 {total} entries | Branch: [cyan]{branch}[/] @ {commit}")
|
|
327
|
+
else:
|
|
328
|
+
console.print(" [dim]Not initialized - run [cyan]up memory sync[/][/]")
|
|
329
|
+
|
|
330
|
+
# Git Hooks
|
|
331
|
+
console.print("\n[bold]Auto-Sync (Git Hooks)[/]")
|
|
332
|
+
hooks = status.get("hooks", {})
|
|
333
|
+
if hooks.get("git"):
|
|
334
|
+
post_commit = hooks.get("post_commit", False)
|
|
335
|
+
post_checkout = hooks.get("post_checkout", False)
|
|
336
|
+
|
|
337
|
+
if post_commit and post_checkout:
|
|
338
|
+
console.print(" [green]✓ Enabled[/] - commits auto-indexed to memory")
|
|
339
|
+
else:
|
|
340
|
+
console.print(" [yellow]⚠ Partially installed[/]")
|
|
341
|
+
if not post_commit:
|
|
342
|
+
console.print(" • Missing: post-commit hook")
|
|
343
|
+
if not post_checkout:
|
|
344
|
+
console.print(" • Missing: post-checkout hook")
|
|
345
|
+
console.print("\n [dim]Run [cyan]up hooks[/] to install missing hooks[/]")
|
|
346
|
+
else:
|
|
347
|
+
console.print(" [yellow]✗ Not installed[/]")
|
|
348
|
+
console.print(" [dim]Run [cyan]up hooks[/] to enable auto-sync on commits[/]")
|
|
349
|
+
|
|
195
350
|
# Skills
|
|
196
351
|
console.print("\n[bold]Skills[/]")
|
|
197
352
|
if status["skills"]:
|
up/commands/sync.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""up sync - Sync all systems."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.option("--memory/--no-memory", default=True, help="Sync memory system")
|
|
14
|
+
@click.option("--docs/--no-docs", default=True, help="Update docs")
|
|
15
|
+
@click.option("--full", is_flag=True, help="Full sync including file re-indexing")
|
|
16
|
+
def sync_cmd(memory: bool, docs: bool, full: bool):
|
|
17
|
+
"""Sync all up systems.
|
|
18
|
+
|
|
19
|
+
Updates memory index, refreshes docs, and ensures all systems
|
|
20
|
+
are in sync with current project state.
|
|
21
|
+
|
|
22
|
+
\b
|
|
23
|
+
Examples:
|
|
24
|
+
up sync # Standard sync
|
|
25
|
+
up sync --full # Full sync with file re-indexing
|
|
26
|
+
up sync --no-docs # Memory only
|
|
27
|
+
"""
|
|
28
|
+
cwd = Path.cwd()
|
|
29
|
+
|
|
30
|
+
console.print(Panel.fit(
|
|
31
|
+
"[bold blue]Syncing Systems[/]",
|
|
32
|
+
border_style="blue"
|
|
33
|
+
))
|
|
34
|
+
|
|
35
|
+
results = {}
|
|
36
|
+
|
|
37
|
+
# Initialize event system
|
|
38
|
+
from up.events import initialize_event_system
|
|
39
|
+
initialize_event_system(cwd)
|
|
40
|
+
|
|
41
|
+
steps = []
|
|
42
|
+
if memory:
|
|
43
|
+
steps.append(("Memory", _sync_memory))
|
|
44
|
+
if docs:
|
|
45
|
+
steps.append(("Docs", _sync_docs))
|
|
46
|
+
if full:
|
|
47
|
+
steps.append(("Files", _sync_files))
|
|
48
|
+
|
|
49
|
+
for name, func in steps:
|
|
50
|
+
console.print(f"[dim]Syncing {name}...[/]", end=" ")
|
|
51
|
+
try:
|
|
52
|
+
result = func(cwd, full)
|
|
53
|
+
results[name.lower()] = result
|
|
54
|
+
details = ", ".join(f"{k}={v}" for k, v in result.items() if v)
|
|
55
|
+
console.print(f"[green]✓[/] {details or 'done'}")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
results[name.lower()] = {"error": str(e)}
|
|
58
|
+
console.print(f"[red]✗[/] {e}")
|
|
59
|
+
|
|
60
|
+
# Summary
|
|
61
|
+
console.print()
|
|
62
|
+
errors = [k for k, v in results.items() if "error" in v]
|
|
63
|
+
if errors:
|
|
64
|
+
console.print(f"[yellow]Completed with {len(errors)} error(s)[/]")
|
|
65
|
+
else:
|
|
66
|
+
console.print("[green]✓[/] All systems synced")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _sync_memory(workspace: Path, full: bool) -> dict:
|
|
70
|
+
"""Sync memory system."""
|
|
71
|
+
import os
|
|
72
|
+
import sys
|
|
73
|
+
import warnings
|
|
74
|
+
import logging
|
|
75
|
+
from up.memory import MemoryManager, _check_chromadb
|
|
76
|
+
|
|
77
|
+
# Suppress noisy warnings and logs
|
|
78
|
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
|
79
|
+
warnings.filterwarnings("ignore")
|
|
80
|
+
logging.getLogger("chromadb").setLevel(logging.ERROR)
|
|
81
|
+
|
|
82
|
+
# Redirect stderr temporarily to suppress ChromaDB noise
|
|
83
|
+
old_stderr = sys.stderr
|
|
84
|
+
sys.stderr = open(os.devnull, 'w')
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
# Use JSON backend if ChromaDB not available (faster, no model download)
|
|
88
|
+
use_vectors = _check_chromadb()
|
|
89
|
+
|
|
90
|
+
manager = MemoryManager(workspace, use_vectors=use_vectors)
|
|
91
|
+
results = manager.sync()
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"commits": results.get("commits_indexed", 0),
|
|
95
|
+
"files": results.get("files_indexed", 0),
|
|
96
|
+
"backend": manager._backend,
|
|
97
|
+
}
|
|
98
|
+
finally:
|
|
99
|
+
sys.stderr.close()
|
|
100
|
+
sys.stderr = old_stderr
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _sync_docs(workspace: Path, full: bool) -> dict:
|
|
104
|
+
"""Sync documentation."""
|
|
105
|
+
from datetime import date
|
|
106
|
+
import re
|
|
107
|
+
|
|
108
|
+
updated = 0
|
|
109
|
+
|
|
110
|
+
# Update CONTEXT.md date
|
|
111
|
+
context_file = workspace / "docs" / "CONTEXT.md"
|
|
112
|
+
if context_file.exists():
|
|
113
|
+
content = context_file.read_text()
|
|
114
|
+
today = date.today().isoformat()
|
|
115
|
+
new_content = re.sub(
|
|
116
|
+
r'\*\*Updated\*\*:\s*[\d-]+',
|
|
117
|
+
f'**Updated**: {today}',
|
|
118
|
+
content
|
|
119
|
+
)
|
|
120
|
+
if new_content != content:
|
|
121
|
+
context_file.write_text(new_content)
|
|
122
|
+
updated += 1
|
|
123
|
+
|
|
124
|
+
# Check INDEX.md exists
|
|
125
|
+
index_file = workspace / "docs" / "INDEX.md"
|
|
126
|
+
if index_file.exists():
|
|
127
|
+
updated += 0 # Just checking
|
|
128
|
+
|
|
129
|
+
return {"updated": updated}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _sync_files(workspace: Path, full: bool) -> dict:
|
|
133
|
+
"""Sync file index."""
|
|
134
|
+
from up.memory import MemoryManager
|
|
135
|
+
|
|
136
|
+
manager = MemoryManager(workspace)
|
|
137
|
+
indexed = manager.index_file_changes()
|
|
138
|
+
|
|
139
|
+
return {"indexed": indexed}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_hooks_installed(workspace: Path) -> dict:
|
|
143
|
+
"""Check if up-cli hooks are installed.
|
|
144
|
+
|
|
145
|
+
Returns dict with status of each hook.
|
|
146
|
+
"""
|
|
147
|
+
git_dir = workspace / ".git"
|
|
148
|
+
if not git_dir.exists():
|
|
149
|
+
return {"git": False, "post_commit": False, "post_checkout": False}
|
|
150
|
+
|
|
151
|
+
hooks_dir = git_dir / "hooks"
|
|
152
|
+
|
|
153
|
+
result = {"git": True, "post_commit": False, "post_checkout": False}
|
|
154
|
+
|
|
155
|
+
post_commit = hooks_dir / "post-commit"
|
|
156
|
+
if post_commit.exists() and "up-cli" in post_commit.read_text():
|
|
157
|
+
result["post_commit"] = True
|
|
158
|
+
|
|
159
|
+
post_checkout = hooks_dir / "post-checkout"
|
|
160
|
+
if post_checkout.exists() and "up-cli" in post_checkout.read_text():
|
|
161
|
+
result["post_checkout"] = True
|
|
162
|
+
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@click.command()
|
|
167
|
+
@click.option("--uninstall", is_flag=True, help="Remove hooks instead of installing")
|
|
168
|
+
@click.option("--check", is_flag=True, help="Check if hooks are installed")
|
|
169
|
+
def hooks_cmd(uninstall: bool, check: bool):
|
|
170
|
+
"""Install or uninstall git hooks for automatic syncing.
|
|
171
|
+
|
|
172
|
+
Installs:
|
|
173
|
+
- post-commit: Auto-index commits to memory
|
|
174
|
+
- post-checkout: Update context on branch switch
|
|
175
|
+
|
|
176
|
+
\b
|
|
177
|
+
Examples:
|
|
178
|
+
up hooks # Install hooks
|
|
179
|
+
up hooks --check # Check status
|
|
180
|
+
up hooks --uninstall # Remove hooks
|
|
181
|
+
"""
|
|
182
|
+
cwd = Path.cwd()
|
|
183
|
+
git_dir = cwd / ".git"
|
|
184
|
+
|
|
185
|
+
if not git_dir.exists():
|
|
186
|
+
console.print("[red]Error:[/] Not a git repository")
|
|
187
|
+
raise SystemExit(1)
|
|
188
|
+
|
|
189
|
+
hooks_dir = git_dir / "hooks"
|
|
190
|
+
hooks_dir.mkdir(exist_ok=True)
|
|
191
|
+
|
|
192
|
+
if check:
|
|
193
|
+
status = check_hooks_installed(cwd)
|
|
194
|
+
console.print("\n[bold]Git Hooks Status:[/]")
|
|
195
|
+
console.print(f" post-commit: {'[green]✓ installed[/]' if status['post_commit'] else '[yellow]✗ not installed[/]'}")
|
|
196
|
+
console.print(f" post-checkout: {'[green]✓ installed[/]' if status['post_checkout'] else '[yellow]✗ not installed[/]'}")
|
|
197
|
+
|
|
198
|
+
if not status['post_commit'] or not status['post_checkout']:
|
|
199
|
+
console.print("\n[dim]Run 'up hooks' to install missing hooks[/]")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
if uninstall:
|
|
203
|
+
_uninstall_hooks(hooks_dir)
|
|
204
|
+
else:
|
|
205
|
+
_install_hooks(hooks_dir)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _install_hooks(hooks_dir: Path):
|
|
209
|
+
"""Install git hooks."""
|
|
210
|
+
|
|
211
|
+
# Post-commit hook
|
|
212
|
+
post_commit = hooks_dir / "post-commit"
|
|
213
|
+
post_commit_content = '''#!/bin/bash
|
|
214
|
+
# up-cli auto-sync hook
|
|
215
|
+
# Indexes commits to memory automatically
|
|
216
|
+
|
|
217
|
+
# Run in background to not slow down commits
|
|
218
|
+
(
|
|
219
|
+
# Wait a moment for git to finish
|
|
220
|
+
sleep 1
|
|
221
|
+
|
|
222
|
+
# Sync memory with latest commit
|
|
223
|
+
if command -v up &> /dev/null; then
|
|
224
|
+
up memory sync 2>/dev/null
|
|
225
|
+
elif command -v python3 &> /dev/null; then
|
|
226
|
+
python3 -m up.memory sync 2>/dev/null
|
|
227
|
+
fi
|
|
228
|
+
) &
|
|
229
|
+
|
|
230
|
+
exit 0
|
|
231
|
+
'''
|
|
232
|
+
|
|
233
|
+
_write_hook(post_commit, post_commit_content)
|
|
234
|
+
console.print("[green]✓[/] Installed post-commit hook")
|
|
235
|
+
|
|
236
|
+
# Post-checkout hook (for branch switches)
|
|
237
|
+
post_checkout = hooks_dir / "post-checkout"
|
|
238
|
+
post_checkout_content = '''#!/bin/bash
|
|
239
|
+
# up-cli context update hook
|
|
240
|
+
# Updates context when switching branches
|
|
241
|
+
|
|
242
|
+
PREV_HEAD=$1
|
|
243
|
+
NEW_HEAD=$2
|
|
244
|
+
BRANCH_CHECKOUT=$3
|
|
245
|
+
|
|
246
|
+
# Only run on branch checkout, not file checkout
|
|
247
|
+
if [ "$BRANCH_CHECKOUT" = "1" ]; then
|
|
248
|
+
(
|
|
249
|
+
sleep 1
|
|
250
|
+
if command -v up &> /dev/null; then
|
|
251
|
+
up sync --no-memory 2>/dev/null
|
|
252
|
+
fi
|
|
253
|
+
) &
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
exit 0
|
|
257
|
+
'''
|
|
258
|
+
|
|
259
|
+
_write_hook(post_checkout, post_checkout_content)
|
|
260
|
+
console.print("[green]✓[/] Installed post-checkout hook")
|
|
261
|
+
|
|
262
|
+
console.print("\n[bold]Hooks installed![/]")
|
|
263
|
+
console.print("Memory will auto-sync on commits.")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _write_hook(path: Path, content: str):
|
|
267
|
+
"""Write hook file with executable permissions."""
|
|
268
|
+
import stat
|
|
269
|
+
|
|
270
|
+
# Check for existing hook
|
|
271
|
+
if path.exists():
|
|
272
|
+
existing = path.read_text()
|
|
273
|
+
if "up-cli" in existing:
|
|
274
|
+
# Already our hook, overwrite
|
|
275
|
+
pass
|
|
276
|
+
else:
|
|
277
|
+
# User has custom hook, append
|
|
278
|
+
content = existing + "\n\n" + content
|
|
279
|
+
|
|
280
|
+
path.write_text(content)
|
|
281
|
+
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _uninstall_hooks(hooks_dir: Path):
|
|
285
|
+
"""Remove up-cli hooks."""
|
|
286
|
+
|
|
287
|
+
for hook_name in ["post-commit", "post-checkout"]:
|
|
288
|
+
hook_path = hooks_dir / hook_name
|
|
289
|
+
if hook_path.exists():
|
|
290
|
+
content = hook_path.read_text()
|
|
291
|
+
if "up-cli" in content:
|
|
292
|
+
# Remove our section
|
|
293
|
+
lines = content.split("\n")
|
|
294
|
+
new_lines = []
|
|
295
|
+
skip = False
|
|
296
|
+
for line in lines:
|
|
297
|
+
if "up-cli" in line:
|
|
298
|
+
skip = True
|
|
299
|
+
elif skip and line.startswith("exit"):
|
|
300
|
+
skip = False
|
|
301
|
+
continue
|
|
302
|
+
elif not skip:
|
|
303
|
+
new_lines.append(line)
|
|
304
|
+
|
|
305
|
+
new_content = "\n".join(new_lines).strip()
|
|
306
|
+
if new_content:
|
|
307
|
+
hook_path.write_text(new_content)
|
|
308
|
+
else:
|
|
309
|
+
hook_path.unlink()
|
|
310
|
+
|
|
311
|
+
console.print(f"[green]✓[/] Removed {hook_name} hook")
|
|
312
|
+
|
|
313
|
+
console.print("\n[bold]Hooks uninstalled![/]")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
sync_cmd()
|