agentpack-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.
- agentpack/__init__.py +3 -0
- agentpack/adapters/__init__.py +0 -0
- agentpack/adapters/base.py +22 -0
- agentpack/adapters/claude.py +32 -0
- agentpack/adapters/codex.py +26 -0
- agentpack/adapters/cursor.py +29 -0
- agentpack/adapters/generic.py +18 -0
- agentpack/adapters/windsurf.py +26 -0
- agentpack/analysis/__init__.py +0 -0
- agentpack/analysis/dependency_graph.py +80 -0
- agentpack/analysis/go_imports.py +32 -0
- agentpack/analysis/java_imports.py +19 -0
- agentpack/analysis/js_ts_imports.py +53 -0
- agentpack/analysis/python_imports.py +45 -0
- agentpack/analysis/ranking.py +400 -0
- agentpack/analysis/rust_imports.py +32 -0
- agentpack/analysis/symbols.py +154 -0
- agentpack/analysis/tests.py +30 -0
- agentpack/application/__init__.py +0 -0
- agentpack/application/pack_service.py +352 -0
- agentpack/cli.py +33 -0
- agentpack/commands/__init__.py +0 -0
- agentpack/commands/_shared.py +13 -0
- agentpack/commands/benchmark.py +302 -0
- agentpack/commands/claude_cmd.py +55 -0
- agentpack/commands/diff.py +46 -0
- agentpack/commands/doctor.py +185 -0
- agentpack/commands/explain.py +238 -0
- agentpack/commands/init.py +79 -0
- agentpack/commands/install.py +252 -0
- agentpack/commands/monitor.py +105 -0
- agentpack/commands/pack.py +188 -0
- agentpack/commands/scan.py +51 -0
- agentpack/commands/session.py +204 -0
- agentpack/commands/stats.py +138 -0
- agentpack/commands/status.py +37 -0
- agentpack/commands/summarize.py +64 -0
- agentpack/commands/watch.py +185 -0
- agentpack/core/__init__.py +0 -0
- agentpack/core/bootstrap.py +46 -0
- agentpack/core/cache.py +41 -0
- agentpack/core/config.py +101 -0
- agentpack/core/context_pack.py +222 -0
- agentpack/core/diff.py +40 -0
- agentpack/core/git.py +145 -0
- agentpack/core/git_hooks.py +8 -0
- agentpack/core/global_install.py +14 -0
- agentpack/core/ignore.py +66 -0
- agentpack/core/merkle.py +8 -0
- agentpack/core/models.py +115 -0
- agentpack/core/redactor.py +99 -0
- agentpack/core/scanner.py +150 -0
- agentpack/core/snapshot.py +60 -0
- agentpack/core/token_estimator.py +26 -0
- agentpack/core/vscode_tasks.py +5 -0
- agentpack/data/agentpack.md +160 -0
- agentpack/installers/__init__.py +0 -0
- agentpack/installers/claude.py +160 -0
- agentpack/installers/codex.py +54 -0
- agentpack/installers/cursor.py +76 -0
- agentpack/installers/windsurf.py +50 -0
- agentpack/integrations/__init__.py +0 -0
- agentpack/integrations/git_hooks.py +109 -0
- agentpack/integrations/global_install.py +221 -0
- agentpack/integrations/vscode_tasks.py +85 -0
- agentpack/renderers/__init__.py +3 -0
- agentpack/renderers/compact.py +75 -0
- agentpack/renderers/markdown.py +144 -0
- agentpack/renderers/receipts.py +10 -0
- agentpack/session/__init__.py +33 -0
- agentpack/session/state.py +105 -0
- agentpack/summaries/__init__.py +0 -0
- agentpack/summaries/base.py +42 -0
- agentpack/summaries/llm.py +100 -0
- agentpack/summaries/offline.py +97 -0
- agentpack_cli-0.1.0.dist-info/METADATA +1391 -0
- agentpack_cli-0.1.0.dist-info/RECORD +80 -0
- agentpack_cli-0.1.0.dist-info/WHEEL +4 -0
- agentpack_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentpack_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich import box
|
|
10
|
+
|
|
11
|
+
from agentpack.commands._shared import console, _root
|
|
12
|
+
from agentpack.session.state import (
|
|
13
|
+
CONTEXT_FILE, COMPACT_FILE, TASK_FILE, SESSION_FILE,
|
|
14
|
+
create_session, load_session, save_session, stop_session, log_activity,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(app: typer.Typer) -> None:
|
|
19
|
+
session_app = typer.Typer(help="Manage AgentPack sessions.")
|
|
20
|
+
app.add_typer(session_app, name="session")
|
|
21
|
+
|
|
22
|
+
@session_app.command("start")
|
|
23
|
+
def start(
|
|
24
|
+
agent: str = typer.Option("generic", "--agent", help="Target agent (claude|cursor|codex|generic)."),
|
|
25
|
+
mode: str = typer.Option("balanced", "--mode", help="Pack mode (minimal|balanced|deep)."),
|
|
26
|
+
task: str = typer.Option("", "--task", help="Initial task description."),
|
|
27
|
+
budget: int = typer.Option(0, "--budget", help="Token budget (0 = config default)."),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Start a session: create state files and generate initial context."""
|
|
30
|
+
root = _root()
|
|
31
|
+
state = create_session(root, agent=agent, mode=mode)
|
|
32
|
+
|
|
33
|
+
if task:
|
|
34
|
+
(root / TASK_FILE).write_text(f"# Current Task\n\n{task}\n", encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
console.print()
|
|
37
|
+
console.print("[bold green]AgentPack session started.[/]")
|
|
38
|
+
console.print()
|
|
39
|
+
|
|
40
|
+
created: list[tuple[str, str]] = [
|
|
41
|
+
(SESSION_FILE, "session state"),
|
|
42
|
+
(TASK_FILE, "edit to set your task"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
result = _run_refresh(root, state.agent, state.mode, budget)
|
|
46
|
+
if result:
|
|
47
|
+
created += [
|
|
48
|
+
(CONTEXT_FILE, f"{result['files']} files, {result['tokens']:,} tokens"),
|
|
49
|
+
(COMPACT_FILE, "compact protocol format"),
|
|
50
|
+
]
|
|
51
|
+
log_activity(root, f"session started — {result['files']} files, {result['tokens']:,} tokens")
|
|
52
|
+
else:
|
|
53
|
+
created += [
|
|
54
|
+
(CONTEXT_FILE, "will generate on first refresh"),
|
|
55
|
+
(COMPACT_FILE, "will generate on first refresh"),
|
|
56
|
+
]
|
|
57
|
+
log_activity(root, "session started (context generation deferred)")
|
|
58
|
+
|
|
59
|
+
console.print("[bold]Created:[/]")
|
|
60
|
+
for path, note in created:
|
|
61
|
+
console.print(f" [green]✓[/] {path} [dim]{note}[/]")
|
|
62
|
+
|
|
63
|
+
console.print()
|
|
64
|
+
console.print("[bold]Next:[/]")
|
|
65
|
+
console.print(" - Run [bold]agentpack watch[/] in another terminal to auto-refresh context.")
|
|
66
|
+
console.print(" - Open Claude / Cursor / Codex and ask your task normally.")
|
|
67
|
+
console.print(" - To change the task: [bold]agentpack session refresh --task \"new task\"[/]")
|
|
68
|
+
console.print()
|
|
69
|
+
|
|
70
|
+
@session_app.command("stop")
|
|
71
|
+
def stop() -> None:
|
|
72
|
+
"""Stop the current session."""
|
|
73
|
+
root = _root()
|
|
74
|
+
state = load_session(root)
|
|
75
|
+
if state is None or not state.active:
|
|
76
|
+
console.print("[yellow]No active session.[/]")
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
stop_session(root)
|
|
79
|
+
log_activity(root, "session stopped")
|
|
80
|
+
console.print("[dim]Session stopped.[/]")
|
|
81
|
+
|
|
82
|
+
@session_app.command("status")
|
|
83
|
+
def status() -> None:
|
|
84
|
+
"""Show current session status."""
|
|
85
|
+
root = _root()
|
|
86
|
+
state = load_session(root)
|
|
87
|
+
if state is None:
|
|
88
|
+
console.print("[yellow]No session found. Run: agentpack session start[/]")
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
|
|
91
|
+
tbl = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
|
|
92
|
+
tbl.add_column(style="dim")
|
|
93
|
+
tbl.add_column(style="bold")
|
|
94
|
+
tbl.add_row("active", "[green]yes[/]" if state.active else "[red]no[/]")
|
|
95
|
+
tbl.add_row("agent", state.agent)
|
|
96
|
+
tbl.add_row("mode", state.mode)
|
|
97
|
+
tbl.add_row("started", state.started_at or "—")
|
|
98
|
+
tbl.add_row("last refresh", state.last_refresh_at or "—")
|
|
99
|
+
tbl.add_row("refresh count", str(state.refresh_count))
|
|
100
|
+
tbl.add_row("context", str(root / CONTEXT_FILE))
|
|
101
|
+
console.print(tbl)
|
|
102
|
+
|
|
103
|
+
context_path = root / CONTEXT_FILE
|
|
104
|
+
if context_path.exists():
|
|
105
|
+
from agentpack.core.token_estimator import estimate_tokens
|
|
106
|
+
tokens = estimate_tokens(context_path.read_text(encoding="utf-8"))
|
|
107
|
+
console.print(f"[dim]context size: ~{tokens:,} tokens[/]")
|
|
108
|
+
|
|
109
|
+
@session_app.command("refresh")
|
|
110
|
+
def refresh(
|
|
111
|
+
task: str = typer.Option("", "--task", help="Override task for this refresh."),
|
|
112
|
+
budget: int = typer.Option(0, "--budget", help="Token budget override."),
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Refresh context pack for the current session."""
|
|
115
|
+
root = _root()
|
|
116
|
+
state = load_session(root)
|
|
117
|
+
if state is None:
|
|
118
|
+
console.print("[yellow]No session. Run: agentpack session start[/]")
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
|
|
121
|
+
if task:
|
|
122
|
+
(root / TASK_FILE).write_text(f"# Current Task\n\n{task}\n", encoding="utf-8")
|
|
123
|
+
|
|
124
|
+
result = _run_refresh(root, state.agent, state.mode, budget)
|
|
125
|
+
if result:
|
|
126
|
+
state.last_refresh_at = _now_iso()
|
|
127
|
+
state.refresh_count += 1
|
|
128
|
+
state.last_task_hash = _file_hash(root / TASK_FILE)
|
|
129
|
+
save_session(root, state)
|
|
130
|
+
log_activity(root, f"refreshed — {result['files']} files, {result['tokens']:,} tokens")
|
|
131
|
+
console.print(f"[green]✓[/] refreshed: {result['files']} files, {result['tokens']:,} tokens, {result['saving']:.1f}% saving")
|
|
132
|
+
else:
|
|
133
|
+
console.print("[red]Refresh failed.[/]")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _run_refresh(
|
|
138
|
+
root: Path,
|
|
139
|
+
agent: str,
|
|
140
|
+
mode: str,
|
|
141
|
+
budget: int,
|
|
142
|
+
) -> Optional[dict]:
|
|
143
|
+
"""Run PackService and write context + compact files. Returns stats dict or None on error."""
|
|
144
|
+
try:
|
|
145
|
+
from agentpack.application.pack_service import PackService, PackRequest
|
|
146
|
+
from agentpack.core import git
|
|
147
|
+
from agentpack.renderers.compact import render_compact
|
|
148
|
+
|
|
149
|
+
task_path = root / TASK_FILE
|
|
150
|
+
if task_path.exists():
|
|
151
|
+
raw = task_path.read_text(encoding="utf-8").strip()
|
|
152
|
+
lines = [l for l in raw.splitlines() if l.strip() and not l.startswith("#")]
|
|
153
|
+
task = lines[0].strip() if lines else ""
|
|
154
|
+
else:
|
|
155
|
+
task = ""
|
|
156
|
+
|
|
157
|
+
if not task:
|
|
158
|
+
if git.is_git_repo(root):
|
|
159
|
+
task = git.infer_task_from_git(root)
|
|
160
|
+
else:
|
|
161
|
+
task = "Current branch changes and likely related files"
|
|
162
|
+
|
|
163
|
+
result = PackService().run(PackRequest(
|
|
164
|
+
root=root,
|
|
165
|
+
agent=agent,
|
|
166
|
+
task=task,
|
|
167
|
+
mode=mode,
|
|
168
|
+
budget=budget,
|
|
169
|
+
since=None,
|
|
170
|
+
refresh=False,
|
|
171
|
+
summary_provider="offline",
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
# Write readable context
|
|
175
|
+
from agentpack.renderers.markdown import render_generic
|
|
176
|
+
context_text = render_generic(result.pack)
|
|
177
|
+
context_path = root / CONTEXT_FILE
|
|
178
|
+
context_path.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
context_path.write_text(context_text, encoding="utf-8")
|
|
180
|
+
|
|
181
|
+
# Write compact context
|
|
182
|
+
compact_text = render_compact(result.pack)
|
|
183
|
+
compact_path = root / COMPACT_FILE
|
|
184
|
+
compact_path.write_text(compact_text, encoding="utf-8")
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"files": len(result.pack.selected_files),
|
|
188
|
+
"tokens": result.packed_tokens,
|
|
189
|
+
"saving": result.saving_pct,
|
|
190
|
+
}
|
|
191
|
+
except Exception as e:
|
|
192
|
+
console.print(f"[red]Error during refresh: {e}[/]")
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _now_iso() -> str:
|
|
197
|
+
from datetime import datetime, timezone
|
|
198
|
+
return datetime.now(timezone.utc).isoformat()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _file_hash(path: Path) -> str:
|
|
202
|
+
if not path.exists():
|
|
203
|
+
return ""
|
|
204
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich import box
|
|
9
|
+
|
|
10
|
+
from agentpack.core.config import load_config
|
|
11
|
+
from agentpack.core.ignore import load_spec
|
|
12
|
+
from agentpack.core.scanner import scan
|
|
13
|
+
from agentpack.core.context_pack import load_pack_metadata
|
|
14
|
+
from agentpack.commands._shared import console, _root
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(app: typer.Typer) -> None:
|
|
18
|
+
@app.command()
|
|
19
|
+
def stats() -> None:
|
|
20
|
+
"""Show token-saving statistics and session info."""
|
|
21
|
+
root = _root()
|
|
22
|
+
cfg = load_config(root)
|
|
23
|
+
ignore_spec = load_spec(root / cfg.project.ignore_file)
|
|
24
|
+
|
|
25
|
+
scan_result = scan(root, ignore_spec, cfg.context.max_file_tokens)
|
|
26
|
+
meta = load_pack_metadata(root)
|
|
27
|
+
|
|
28
|
+
raw = sum(f.estimated_tokens for f in scan_result.all_files)
|
|
29
|
+
after_ignore = sum(f.estimated_tokens for f in scan_result.packable)
|
|
30
|
+
packed = meta.get("token_estimate", 0) if meta else 0
|
|
31
|
+
saving = (1 - packed / raw) * 100 if raw > 0 else 0
|
|
32
|
+
|
|
33
|
+
ignored_count = len(scan_result.ignored) + len(scan_result.binary)
|
|
34
|
+
included_count = 0
|
|
35
|
+
summarized_count = 0
|
|
36
|
+
top_files: list[tuple[str, str]] = []
|
|
37
|
+
|
|
38
|
+
if meta:
|
|
39
|
+
context_path = root / meta.get("context_path", "")
|
|
40
|
+
if context_path.exists():
|
|
41
|
+
content = context_path.read_text()
|
|
42
|
+
included_count = content.count("Included as: **full**")
|
|
43
|
+
summarized_count = (
|
|
44
|
+
content.count("Included as: **summary**")
|
|
45
|
+
+ content.count("Included as: **symbols**")
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
full_files = [f for f in scan_result.packable
|
|
49
|
+
if f.estimated_tokens <= cfg.context.max_file_tokens]
|
|
50
|
+
manual_estimate = min(after_ignore, sum(f.estimated_tokens for f in full_files[:20]))
|
|
51
|
+
vs_manual = (1 - packed / manual_estimate) * 100 if manual_estimate > 0 else 0
|
|
52
|
+
|
|
53
|
+
# --- Session info ---
|
|
54
|
+
from agentpack.session.state import load_session, CONTEXT_FILE
|
|
55
|
+
session = load_session(root)
|
|
56
|
+
|
|
57
|
+
if session:
|
|
58
|
+
sess_tbl = Table(title="Session", box=box.SIMPLE, show_header=False, padding=(0, 2))
|
|
59
|
+
sess_tbl.add_column(style="dim")
|
|
60
|
+
sess_tbl.add_column(style="bold")
|
|
61
|
+
sess_tbl.add_row("active", "[green]yes[/]" if session.active else "[red]no[/]")
|
|
62
|
+
sess_tbl.add_row("agent", session.agent)
|
|
63
|
+
sess_tbl.add_row("mode", session.mode)
|
|
64
|
+
if session.started_at:
|
|
65
|
+
sess_tbl.add_row("started", session.started_at[:19].replace("T", " "))
|
|
66
|
+
if session.last_refresh_at:
|
|
67
|
+
sess_tbl.add_row("last refresh", session.last_refresh_at[:19].replace("T", " "))
|
|
68
|
+
sess_tbl.add_row("refreshes", str(session.refresh_count))
|
|
69
|
+
console.print(sess_tbl)
|
|
70
|
+
console.print()
|
|
71
|
+
|
|
72
|
+
# --- Last context top files ---
|
|
73
|
+
metrics_path = root / ".agentpack" / "metrics.jsonl"
|
|
74
|
+
last_selected: list[dict] = []
|
|
75
|
+
if metrics_path.exists():
|
|
76
|
+
lines = [l.strip() for l in metrics_path.read_text().splitlines() if l.strip()]
|
|
77
|
+
if lines:
|
|
78
|
+
try:
|
|
79
|
+
last_record = json.loads(lines[-1])
|
|
80
|
+
# metrics don't store per-file data — use context file for top files
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
context_path_obj = root / CONTEXT_FILE
|
|
85
|
+
if context_path_obj.exists():
|
|
86
|
+
top_files = _parse_top_files(context_path_obj)
|
|
87
|
+
|
|
88
|
+
# --- Token table ---
|
|
89
|
+
token_tbl = Table(title="Last Context", box=box.SIMPLE, show_header=False, padding=(0, 2))
|
|
90
|
+
token_tbl.add_column(style="dim")
|
|
91
|
+
token_tbl.add_column(justify="right", style="bold")
|
|
92
|
+
token_tbl.add_row("raw repo tokens", f"{raw:,}")
|
|
93
|
+
token_tbl.add_row("after ignore", f"{after_ignore:,}")
|
|
94
|
+
token_tbl.add_row("packed tokens", f"{packed:,}")
|
|
95
|
+
token_tbl.add_row("vs raw repo", f"[green]{saving:.1f}% smaller[/]")
|
|
96
|
+
token_tbl.add_row("vs manual (~20 files)", f"[green]{vs_manual:.1f}% smaller[/]")
|
|
97
|
+
token_tbl.add_row("files ignored", f"{ignored_count:,}")
|
|
98
|
+
token_tbl.add_row("files full", f"{included_count:,}")
|
|
99
|
+
token_tbl.add_row("files summarized", f"{summarized_count:,}")
|
|
100
|
+
console.print(token_tbl)
|
|
101
|
+
|
|
102
|
+
if top_files:
|
|
103
|
+
console.print()
|
|
104
|
+
top_tbl = Table(title="Top Included", box=box.SIMPLE, show_header=True, padding=(0, 1))
|
|
105
|
+
top_tbl.add_column("#", width=3, style="dim")
|
|
106
|
+
top_tbl.add_column("file", style="cyan", max_width=55)
|
|
107
|
+
top_tbl.add_column("mode", width=8)
|
|
108
|
+
top_tbl.add_column("why", style="dim", max_width=35)
|
|
109
|
+
for i, (path, mode, why) in enumerate(top_files[:10], 1):
|
|
110
|
+
top_tbl.add_row(str(i), path, mode, why)
|
|
111
|
+
console.print(top_tbl)
|
|
112
|
+
|
|
113
|
+
console.print("[dim]'manual' = hand-picking 20 most relevant full files[/]")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_top_files(context_path: Path) -> list[tuple[str, str, str]]:
|
|
117
|
+
"""Parse top selected files from context.md. Returns list of (path, mode, why)."""
|
|
118
|
+
results: list[tuple[str, str, str]] = []
|
|
119
|
+
try:
|
|
120
|
+
content = context_path.read_text(encoding="utf-8")
|
|
121
|
+
# Parse the Selected Files table: | `path` | mode | score | why |
|
|
122
|
+
in_table = False
|
|
123
|
+
for line in content.splitlines():
|
|
124
|
+
if line.startswith("| File") or line.startswith("|---|"):
|
|
125
|
+
in_table = True
|
|
126
|
+
continue
|
|
127
|
+
if in_table:
|
|
128
|
+
if not line.startswith("|"):
|
|
129
|
+
break
|
|
130
|
+
parts = [p.strip() for p in line.split("|") if p.strip()]
|
|
131
|
+
if len(parts) >= 3:
|
|
132
|
+
path = parts[0].strip("`")
|
|
133
|
+
mode = parts[1]
|
|
134
|
+
why = parts[3] if len(parts) > 3 else ""
|
|
135
|
+
results.append((path, mode, why))
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
return results
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from agentpack.core.config import load_config
|
|
6
|
+
from agentpack.core.ignore import load_spec
|
|
7
|
+
from agentpack.core.scanner import scan
|
|
8
|
+
from agentpack.core.snapshot import build_snapshot
|
|
9
|
+
from agentpack.core.context_pack import load_pack_metadata
|
|
10
|
+
from agentpack.commands._shared import console, _root
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(app: typer.Typer) -> None:
|
|
14
|
+
@app.command()
|
|
15
|
+
def status() -> None:
|
|
16
|
+
"""Check if the latest context pack is stale."""
|
|
17
|
+
root = _root()
|
|
18
|
+
cfg = load_config(root)
|
|
19
|
+
ignore_spec = load_spec(root / cfg.project.ignore_file)
|
|
20
|
+
|
|
21
|
+
meta = load_pack_metadata(root)
|
|
22
|
+
if not meta:
|
|
23
|
+
console.print("[yellow]No context pack found. Run agentpack pack to generate one.[/]")
|
|
24
|
+
raise typer.Exit(1)
|
|
25
|
+
|
|
26
|
+
scan_result = scan(root, ignore_spec, cfg.context.max_file_tokens)
|
|
27
|
+
current = build_snapshot(scan_result.packable)
|
|
28
|
+
|
|
29
|
+
if current["root_hash"] == meta.get("snapshot_root_hash"):
|
|
30
|
+
console.print("[green]Context pack is up to date.[/]")
|
|
31
|
+
console.print(f" Task: {meta.get('task')}")
|
|
32
|
+
console.print(f" Generated: {meta.get('generated_at')}")
|
|
33
|
+
else:
|
|
34
|
+
console.print("[yellow]Context pack is STALE.[/] Files changed since last pack.")
|
|
35
|
+
console.print(f" Last generated: {meta.get('generated_at')}")
|
|
36
|
+
console.print(" Run [bold]agentpack pack[/] to refresh.")
|
|
37
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from agentpack.core.config import load_config
|
|
8
|
+
from agentpack.core.ignore import load_spec
|
|
9
|
+
from agentpack.core.scanner import scan
|
|
10
|
+
from agentpack.summaries.base import get_or_build_summary
|
|
11
|
+
from agentpack.commands._shared import console, _root
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(app: typer.Typer) -> None:
|
|
15
|
+
@app.command()
|
|
16
|
+
def summarize(
|
|
17
|
+
provider: str = typer.Option("offline", "--provider", help="Summary provider (offline|claude)."),
|
|
18
|
+
refresh: bool = typer.Option(False, "--refresh", help="Force rebuild all summaries."),
|
|
19
|
+
model: Optional[str] = typer.Option(None, "--model", help="LLM model override (for claude provider)."),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Build or refresh summary cache. Default: offline (no API calls)."""
|
|
22
|
+
root = _root()
|
|
23
|
+
cfg = load_config(root)
|
|
24
|
+
ignore_spec = load_spec(root / cfg.project.ignore_file)
|
|
25
|
+
|
|
26
|
+
if provider not in ("offline", "claude"):
|
|
27
|
+
console.print("[red]Supported providers: offline, claude[/]")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
if provider == "claude":
|
|
31
|
+
console.print("[bold]Building LLM summaries via Claude (requires ANTHROPIC_API_KEY)...[/]")
|
|
32
|
+
else:
|
|
33
|
+
console.print("[bold]Building offline summaries...[/]")
|
|
34
|
+
|
|
35
|
+
scan_result = scan(root, ignore_spec, cfg.context.max_file_tokens)
|
|
36
|
+
active = scan_result.packable
|
|
37
|
+
|
|
38
|
+
built = 0
|
|
39
|
+
errors = 0
|
|
40
|
+
for fi in active:
|
|
41
|
+
try:
|
|
42
|
+
if provider == "claude" and model:
|
|
43
|
+
# pass model through via a thin wrapper
|
|
44
|
+
from agentpack.summaries import llm as llm_mod
|
|
45
|
+
from agentpack.core import cache as summary_cache
|
|
46
|
+
if fi.hash:
|
|
47
|
+
cached = summary_cache.load_summary(root, fi.path, fi.hash, provider)
|
|
48
|
+
if cached and not refresh:
|
|
49
|
+
built += 1
|
|
50
|
+
continue
|
|
51
|
+
summary = llm_mod.summarize(fi.path, fi.abs_path, fi.language, fi.hash or "", provider=provider, model=model)
|
|
52
|
+
summary_cache.save_summary(root, summary)
|
|
53
|
+
else:
|
|
54
|
+
get_or_build_summary(fi, root, provider)
|
|
55
|
+
built += 1
|
|
56
|
+
except Exception as e:
|
|
57
|
+
console.print(f"[yellow]Warning:[/] {fi.path}: {e}")
|
|
58
|
+
errors += 1
|
|
59
|
+
|
|
60
|
+
console.print(f"[green]Done.[/] Built/refreshed {built} summaries.", end="")
|
|
61
|
+
if errors:
|
|
62
|
+
console.print(f" [yellow]{errors} errors.[/]")
|
|
63
|
+
else:
|
|
64
|
+
console.print()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from agentpack.commands._shared import console, _root
|
|
10
|
+
from agentpack.session.state import TASK_FILE, load_session, save_session, log_activity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_IGNORE_DIRS = {".git", "node_modules", ".venv", "venv", "dist", "build", ".next", "__pycache__"}
|
|
14
|
+
_IGNORE_NAMES = {"context.md", "context.compact.md"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(app: typer.Typer) -> None:
|
|
18
|
+
@app.command()
|
|
19
|
+
def watch(
|
|
20
|
+
agent: str = typer.Option("", "--agent", help="Agent override (uses session agent if not set)."),
|
|
21
|
+
mode: str = typer.Option("", "--mode", help="Mode override (uses session mode if not set)."),
|
|
22
|
+
budget: int = typer.Option(0, "--budget", help="Token budget override."),
|
|
23
|
+
debounce: float = typer.Option(2.0, "--debounce", help="Seconds to wait after last change before refresh."),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Watch for file changes and refresh context automatically."""
|
|
26
|
+
root = _root()
|
|
27
|
+
state = load_session(root)
|
|
28
|
+
|
|
29
|
+
effective_agent = agent or (state.agent if state else "generic")
|
|
30
|
+
effective_mode = mode or (state.mode if state else "balanced")
|
|
31
|
+
|
|
32
|
+
if state is None:
|
|
33
|
+
console.print("[yellow]No session found — watching in stateless mode.[/]")
|
|
34
|
+
console.print("[dim]Run 'agentpack session start' for full session support.[/]")
|
|
35
|
+
|
|
36
|
+
console.print()
|
|
37
|
+
console.print("[bold]AgentPack watch active.[/]")
|
|
38
|
+
console.print("Press Ctrl+C to stop.")
|
|
39
|
+
console.print(f"[dim]agent={effective_agent} mode={effective_mode}[/]")
|
|
40
|
+
console.print()
|
|
41
|
+
|
|
42
|
+
# Try watchdog first, fall back to polling
|
|
43
|
+
try:
|
|
44
|
+
from watchdog.observers import Observer
|
|
45
|
+
_watch_with_watchdog(root, effective_agent, effective_mode, budget, debounce, state)
|
|
46
|
+
except ImportError:
|
|
47
|
+
console.print("[dim]watchdog not installed — using polling (install watchdog for better performance)[/]")
|
|
48
|
+
_watch_polling(root, effective_agent, effective_mode, budget, debounce, state)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ts() -> str:
|
|
52
|
+
return datetime.now().strftime("%H:%M:%S")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _should_ignore(path: str) -> bool:
|
|
56
|
+
parts = Path(path).parts
|
|
57
|
+
for part in parts:
|
|
58
|
+
if part in _IGNORE_DIRS:
|
|
59
|
+
return True
|
|
60
|
+
name = Path(path).name
|
|
61
|
+
return name in _IGNORE_NAMES
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run_refresh(root: Path, agent: str, mode: str, budget: int) -> None:
|
|
65
|
+
from agentpack.commands.session import _run_refresh as do_refresh, _file_hash, _now_iso
|
|
66
|
+
result = do_refresh(root, agent, mode, budget)
|
|
67
|
+
if result:
|
|
68
|
+
ts = _ts()
|
|
69
|
+
console.print(
|
|
70
|
+
f"[dim][{ts}][/] [green]refreshed:[/] {result['files']} files, "
|
|
71
|
+
f"{result['tokens']:,} tokens, mode={mode}"
|
|
72
|
+
)
|
|
73
|
+
state = load_session(root)
|
|
74
|
+
if state:
|
|
75
|
+
state.last_refresh_at = _now_iso()
|
|
76
|
+
state.refresh_count += 1
|
|
77
|
+
state.last_task_hash = _file_hash(root / TASK_FILE)
|
|
78
|
+
save_session(root, state)
|
|
79
|
+
log_activity(root, f"watch refresh — {result['files']} files, {result['tokens']:,} tokens")
|
|
80
|
+
else:
|
|
81
|
+
console.print(f"[dim][{_ts()}][/] [red]refresh failed[/]")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _watch_with_watchdog(
|
|
85
|
+
root: Path,
|
|
86
|
+
agent: str,
|
|
87
|
+
mode: str,
|
|
88
|
+
budget: int,
|
|
89
|
+
debounce: float,
|
|
90
|
+
state,
|
|
91
|
+
) -> None:
|
|
92
|
+
from watchdog.observers import Observer
|
|
93
|
+
from watchdog.events import FileSystemEventHandler
|
|
94
|
+
|
|
95
|
+
_last_refresh = [time.monotonic() - debounce - 1]
|
|
96
|
+
_pending = [False]
|
|
97
|
+
|
|
98
|
+
# Run initial refresh
|
|
99
|
+
_run_refresh(root, agent, mode, budget)
|
|
100
|
+
|
|
101
|
+
class Handler(FileSystemEventHandler):
|
|
102
|
+
def on_any_event(self, event): # type: ignore[override]
|
|
103
|
+
if event.is_directory:
|
|
104
|
+
return
|
|
105
|
+
path = str(event.src_path)
|
|
106
|
+
if _should_ignore(path):
|
|
107
|
+
return
|
|
108
|
+
# Task file change → show message
|
|
109
|
+
if path.endswith(TASK_FILE):
|
|
110
|
+
console.print(f"[dim][{_ts()}][/] task changed")
|
|
111
|
+
_pending[0] = True
|
|
112
|
+
|
|
113
|
+
observer = Observer()
|
|
114
|
+
observer.schedule(Handler(), str(root), recursive=True)
|
|
115
|
+
observer.start()
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
while True:
|
|
119
|
+
time.sleep(0.5)
|
|
120
|
+
if _pending[0]:
|
|
121
|
+
now = time.monotonic()
|
|
122
|
+
if now - _last_refresh[0] >= debounce:
|
|
123
|
+
_pending[0] = False
|
|
124
|
+
_last_refresh[0] = now
|
|
125
|
+
try:
|
|
126
|
+
_run_refresh(root, agent, mode, budget)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
console.print(f"[red]refresh error: {e}[/]")
|
|
129
|
+
except KeyboardInterrupt:
|
|
130
|
+
observer.stop()
|
|
131
|
+
console.print("\n[dim]Watch stopped.[/]")
|
|
132
|
+
observer.join()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _watch_polling(
|
|
136
|
+
root: Path,
|
|
137
|
+
agent: str,
|
|
138
|
+
mode: str,
|
|
139
|
+
budget: int,
|
|
140
|
+
debounce: float,
|
|
141
|
+
state,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Polling fallback: walk repo files and compare mtimes."""
|
|
144
|
+
_POLL_INTERVAL = 1.5
|
|
145
|
+
|
|
146
|
+
def _collect_mtimes() -> dict[str, float]:
|
|
147
|
+
mtimes: dict[str, float] = {}
|
|
148
|
+
for p in root.rglob("*"):
|
|
149
|
+
if not p.is_file():
|
|
150
|
+
continue
|
|
151
|
+
rel = str(p.relative_to(root))
|
|
152
|
+
if _should_ignore(rel):
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
mtimes[rel] = p.stat().st_mtime
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
return mtimes
|
|
159
|
+
|
|
160
|
+
prev = _collect_mtimes()
|
|
161
|
+
_run_refresh(root, agent, mode, budget)
|
|
162
|
+
_last_refresh = time.monotonic()
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
while True:
|
|
166
|
+
time.sleep(_POLL_INTERVAL)
|
|
167
|
+
curr = _collect_mtimes()
|
|
168
|
+
changed = {p for p, m in curr.items() if prev.get(p) != m}
|
|
169
|
+
changed |= set(prev) - set(curr)
|
|
170
|
+
if changed:
|
|
171
|
+
task_changed = any(p.endswith(TASK_FILE) for p in changed)
|
|
172
|
+
if task_changed:
|
|
173
|
+
console.print(f"[dim][{_ts()}][/] task changed")
|
|
174
|
+
now = time.monotonic()
|
|
175
|
+
if now - _last_refresh >= debounce:
|
|
176
|
+
_last_refresh = now
|
|
177
|
+
prev = curr
|
|
178
|
+
try:
|
|
179
|
+
_run_refresh(root, agent, mode, budget)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
console.print(f"[red]refresh error: {e}[/]")
|
|
182
|
+
else:
|
|
183
|
+
prev = curr
|
|
184
|
+
except KeyboardInterrupt:
|
|
185
|
+
console.print("\n[dim]Watch stopped.[/]")
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_initialized(root: Path) -> bool:
|
|
9
|
+
return (root / ".agentpack" / "config.toml").exists()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def bootstrap_if_needed(root: Path, agent: str = "claude", silent: bool = True) -> bool:
|
|
13
|
+
"""Run init + pack if not already configured. Returns True if bootstrapped.
|
|
14
|
+
|
|
15
|
+
Safe to call from git hooks or shell hooks — catches all exceptions so it
|
|
16
|
+
never breaks the calling tool.
|
|
17
|
+
"""
|
|
18
|
+
if is_initialized(root):
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
# Don't bootstrap non-git directories or directories that look like home/system dirs
|
|
22
|
+
if not (root / ".git").exists():
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
# Skip very large directories (>5000 files) — likely not a real project root
|
|
26
|
+
try:
|
|
27
|
+
file_count = sum(1 for _ in root.rglob("*") if _.is_file())
|
|
28
|
+
if file_count > 5000:
|
|
29
|
+
return False
|
|
30
|
+
except OSError:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
kwargs: dict = {"capture_output": silent, "text": True}
|
|
35
|
+
subprocess.run(
|
|
36
|
+
[sys.executable, "-m", "agentpack", "init", "--yes"],
|
|
37
|
+
cwd=str(root), **kwargs
|
|
38
|
+
)
|
|
39
|
+
subprocess.run(
|
|
40
|
+
[sys.executable, "-m", "agentpack", "pack",
|
|
41
|
+
"--agent", agent, "--task", "auto", "--mode", "balanced"],
|
|
42
|
+
cwd=str(root), **kwargs
|
|
43
|
+
)
|
|
44
|
+
return True
|
|
45
|
+
except Exception:
|
|
46
|
+
return False
|