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.
Files changed (80) hide show
  1. agentpack/__init__.py +3 -0
  2. agentpack/adapters/__init__.py +0 -0
  3. agentpack/adapters/base.py +22 -0
  4. agentpack/adapters/claude.py +32 -0
  5. agentpack/adapters/codex.py +26 -0
  6. agentpack/adapters/cursor.py +29 -0
  7. agentpack/adapters/generic.py +18 -0
  8. agentpack/adapters/windsurf.py +26 -0
  9. agentpack/analysis/__init__.py +0 -0
  10. agentpack/analysis/dependency_graph.py +80 -0
  11. agentpack/analysis/go_imports.py +32 -0
  12. agentpack/analysis/java_imports.py +19 -0
  13. agentpack/analysis/js_ts_imports.py +53 -0
  14. agentpack/analysis/python_imports.py +45 -0
  15. agentpack/analysis/ranking.py +400 -0
  16. agentpack/analysis/rust_imports.py +32 -0
  17. agentpack/analysis/symbols.py +154 -0
  18. agentpack/analysis/tests.py +30 -0
  19. agentpack/application/__init__.py +0 -0
  20. agentpack/application/pack_service.py +352 -0
  21. agentpack/cli.py +33 -0
  22. agentpack/commands/__init__.py +0 -0
  23. agentpack/commands/_shared.py +13 -0
  24. agentpack/commands/benchmark.py +302 -0
  25. agentpack/commands/claude_cmd.py +55 -0
  26. agentpack/commands/diff.py +46 -0
  27. agentpack/commands/doctor.py +185 -0
  28. agentpack/commands/explain.py +238 -0
  29. agentpack/commands/init.py +79 -0
  30. agentpack/commands/install.py +252 -0
  31. agentpack/commands/monitor.py +105 -0
  32. agentpack/commands/pack.py +188 -0
  33. agentpack/commands/scan.py +51 -0
  34. agentpack/commands/session.py +204 -0
  35. agentpack/commands/stats.py +138 -0
  36. agentpack/commands/status.py +37 -0
  37. agentpack/commands/summarize.py +64 -0
  38. agentpack/commands/watch.py +185 -0
  39. agentpack/core/__init__.py +0 -0
  40. agentpack/core/bootstrap.py +46 -0
  41. agentpack/core/cache.py +41 -0
  42. agentpack/core/config.py +101 -0
  43. agentpack/core/context_pack.py +222 -0
  44. agentpack/core/diff.py +40 -0
  45. agentpack/core/git.py +145 -0
  46. agentpack/core/git_hooks.py +8 -0
  47. agentpack/core/global_install.py +14 -0
  48. agentpack/core/ignore.py +66 -0
  49. agentpack/core/merkle.py +8 -0
  50. agentpack/core/models.py +115 -0
  51. agentpack/core/redactor.py +99 -0
  52. agentpack/core/scanner.py +150 -0
  53. agentpack/core/snapshot.py +60 -0
  54. agentpack/core/token_estimator.py +26 -0
  55. agentpack/core/vscode_tasks.py +5 -0
  56. agentpack/data/agentpack.md +160 -0
  57. agentpack/installers/__init__.py +0 -0
  58. agentpack/installers/claude.py +160 -0
  59. agentpack/installers/codex.py +54 -0
  60. agentpack/installers/cursor.py +76 -0
  61. agentpack/installers/windsurf.py +50 -0
  62. agentpack/integrations/__init__.py +0 -0
  63. agentpack/integrations/git_hooks.py +109 -0
  64. agentpack/integrations/global_install.py +221 -0
  65. agentpack/integrations/vscode_tasks.py +85 -0
  66. agentpack/renderers/__init__.py +3 -0
  67. agentpack/renderers/compact.py +75 -0
  68. agentpack/renderers/markdown.py +144 -0
  69. agentpack/renderers/receipts.py +10 -0
  70. agentpack/session/__init__.py +33 -0
  71. agentpack/session/state.py +105 -0
  72. agentpack/summaries/__init__.py +0 -0
  73. agentpack/summaries/base.py +42 -0
  74. agentpack/summaries/llm.py +100 -0
  75. agentpack/summaries/offline.py +97 -0
  76. agentpack_cli-0.1.0.dist-info/METADATA +1391 -0
  77. agentpack_cli-0.1.0.dist-info/RECORD +80 -0
  78. agentpack_cli-0.1.0.dist-info/WHEEL +4 -0
  79. agentpack_cli-0.1.0.dist-info/entry_points.txt +2 -0
  80. 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