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,252 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from agentpack.installers.claude import ClaudeInstaller
|
|
9
|
+
from agentpack.installers.codex import CodexInstaller
|
|
10
|
+
from agentpack.installers.cursor import CursorInstaller
|
|
11
|
+
from agentpack.installers.windsurf import WindsurfInstaller
|
|
12
|
+
from agentpack.integrations.global_install import (
|
|
13
|
+
install_git_template_hooks,
|
|
14
|
+
configure_git_template_dir,
|
|
15
|
+
install_shell_hook,
|
|
16
|
+
remove_git_template_hooks,
|
|
17
|
+
remove_shell_hook,
|
|
18
|
+
)
|
|
19
|
+
from agentpack.commands._shared import console, _root
|
|
20
|
+
|
|
21
|
+
_SUPPORTED_AGENTS = ("claude", "cursor", "windsurf", "codex")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(app: typer.Typer) -> None:
|
|
25
|
+
@app.command()
|
|
26
|
+
def install(
|
|
27
|
+
agent: str = typer.Option("claude", "--agent", help=f"Target agent ({' | '.join(_SUPPORTED_AGENTS)})."),
|
|
28
|
+
slash_command: bool = typer.Option(True, "--slash-command/--no-slash-command", help="Install /agentpack slash command (Claude only)."),
|
|
29
|
+
global_install: bool = typer.Option(False, "--global/--local", help="Install globally or locally."),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Configure agentpack for your AI coding agent (Claude, Cursor, Windsurf, or Codex)."""
|
|
32
|
+
root = _root()
|
|
33
|
+
|
|
34
|
+
if agent == "claude":
|
|
35
|
+
installer = ClaudeInstaller()
|
|
36
|
+
action = installer.patch_claude_md(root)
|
|
37
|
+
console.print(f"[green]CLAUDE.md {action}.[/]")
|
|
38
|
+
|
|
39
|
+
hook_action = installer.patch_claude_settings(root, global_install)
|
|
40
|
+
scope = "~/.claude/settings.json" if global_install else ".claude/settings.json"
|
|
41
|
+
console.print(f"[green]{scope} {hook_action}.[/]")
|
|
42
|
+
|
|
43
|
+
if slash_command:
|
|
44
|
+
_install_slash_command(root, global_install)
|
|
45
|
+
|
|
46
|
+
elif agent == "cursor":
|
|
47
|
+
installer = CursorInstaller()
|
|
48
|
+
rules_action = installer.patch_cursor_rules(root)
|
|
49
|
+
console.print(f"[green].cursorrules {rules_action}.[/]")
|
|
50
|
+
mdc_action = installer.patch_cursor_mdc(root)
|
|
51
|
+
console.print(f"[green].cursor/rules/agentpack.mdc {mdc_action}.[/]")
|
|
52
|
+
_print_auto_repack_results(installer.install_auto_repack(root))
|
|
53
|
+
console.print(" Run [bold]agentpack pack --agent cursor --task \"<task>\"[/] to generate context.")
|
|
54
|
+
|
|
55
|
+
elif agent == "windsurf":
|
|
56
|
+
installer = WindsurfInstaller()
|
|
57
|
+
rules_action = installer.patch_windsurfrules(root)
|
|
58
|
+
console.print(f"[green].windsurfrules {rules_action}.[/]")
|
|
59
|
+
_print_auto_repack_results(installer.install_auto_repack(root))
|
|
60
|
+
console.print(" Run [bold]agentpack pack --agent windsurf --task \"<task>\"[/] to generate context.")
|
|
61
|
+
|
|
62
|
+
elif agent == "codex":
|
|
63
|
+
installer = CodexInstaller()
|
|
64
|
+
action = installer.patch_agents_md(root)
|
|
65
|
+
console.print(f"[green]AGENTS.md {action}.[/]")
|
|
66
|
+
_print_auto_repack_results(installer.install_auto_repack(root))
|
|
67
|
+
console.print(" Run [bold]agentpack pack --agent codex --task \"<task>\"[/] to generate context.")
|
|
68
|
+
|
|
69
|
+
else:
|
|
70
|
+
console.print(f"[yellow]Unknown agent: {agent}. Supported: {', '.join(_SUPPORTED_AGENTS)}[/]")
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
|
|
73
|
+
@app.command(name="global-install")
|
|
74
|
+
def global_install_cmd(
|
|
75
|
+
agent: str = typer.Option("claude", "--agent", help=f"Target agent ({' | '.join(_SUPPORTED_AGENTS)})."),
|
|
76
|
+
pipx: bool = typer.Option(True, "--pipx/--no-pipx", help="Install via pipx for global availability."),
|
|
77
|
+
shell_hook: bool = typer.Option(True, "--shell-hook/--no-shell-hook", help="Add cd hook to shell rc for auto-bootstrap."),
|
|
78
|
+
git_template: bool = typer.Option(True, "--git-template/--no-git-template", help="Install git template hooks for every new repo."),
|
|
79
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be changed without mutating anything."),
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Install agentpack once — works in every repo from that point on.
|
|
82
|
+
|
|
83
|
+
Sets up git template hooks (fired on every git init/clone) and a shell
|
|
84
|
+
cd hook so agentpack auto-bootstraps silently whenever you enter a git
|
|
85
|
+
repo for the first time. No per-project setup required after this.
|
|
86
|
+
"""
|
|
87
|
+
import subprocess as sp
|
|
88
|
+
|
|
89
|
+
if dry_run:
|
|
90
|
+
console.print("[bold yellow]Dry run — no files will be changed.[/]\n")
|
|
91
|
+
|
|
92
|
+
if pipx and not dry_run:
|
|
93
|
+
console.print("[bold]Installing agentpack globally via pipx...[/]")
|
|
94
|
+
result = sp.run(
|
|
95
|
+
["pipx", "install", "agentpack-cli", "--force"],
|
|
96
|
+
capture_output=True, text=True,
|
|
97
|
+
)
|
|
98
|
+
if result.returncode == 0:
|
|
99
|
+
console.print("[green]agentpack installed globally.[/] Available as `agentpack` in any shell.")
|
|
100
|
+
else:
|
|
101
|
+
console.print("[yellow]pipx install failed. Trying pip install --user...[/]")
|
|
102
|
+
result2 = sp.run(
|
|
103
|
+
[sys.executable, "-m", "pip", "install", "--user", "agentpack-cli"],
|
|
104
|
+
capture_output=True, text=True,
|
|
105
|
+
)
|
|
106
|
+
if result2.returncode != 0:
|
|
107
|
+
console.print(f"[red]Install failed:[/] {result2.stderr[:200]}")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
console.print("[green]Installed via pip --user.[/]")
|
|
110
|
+
elif pipx and dry_run:
|
|
111
|
+
console.print("[dim]Would run: pipx install agentpack-cli[/]")
|
|
112
|
+
|
|
113
|
+
# --- Git template hooks (fire on every future git init / clone) ---
|
|
114
|
+
if git_template:
|
|
115
|
+
console.print("\n[bold]Git template hooks:[/]" if dry_run else "\n[bold]Setting up git template hooks...[/]")
|
|
116
|
+
hook_results = install_git_template_hooks(dry_run=dry_run)
|
|
117
|
+
for name, action in hook_results.items():
|
|
118
|
+
if action != "unchanged":
|
|
119
|
+
prefix = "[dim]" if dry_run else "[green]"
|
|
120
|
+
suffix = "[/]"
|
|
121
|
+
console.print(f"{prefix}~/.git-templates/hooks/{name} {action}.{suffix}")
|
|
122
|
+
git_cfg_action = configure_git_template_dir(dry_run=dry_run)
|
|
123
|
+
console.print(f"[dim]git config --global init.templateDir {git_cfg_action}.[/]" if dry_run
|
|
124
|
+
else f"[green]git config --global init.templateDir {git_cfg_action}.[/]")
|
|
125
|
+
if not dry_run:
|
|
126
|
+
console.print(" Every future [bold]git init[/] or [bold]git clone[/] will auto-bootstrap agentpack.")
|
|
127
|
+
|
|
128
|
+
# --- Shell cd hook (fires when entering opted-in repos) ---
|
|
129
|
+
if shell_hook:
|
|
130
|
+
console.print("\n[bold]Shell cd hook:[/]" if dry_run else "\n[bold]Setting up shell cd hook...[/]")
|
|
131
|
+
action, rc_path = install_shell_hook(dry_run=dry_run)
|
|
132
|
+
if rc_path:
|
|
133
|
+
prefix = "[dim]" if dry_run else "[green]"
|
|
134
|
+
console.print(f"{prefix}{rc_path} {action}.[/]")
|
|
135
|
+
if not dry_run:
|
|
136
|
+
console.print(" When you [bold]cd[/] into a repo with [dim].agentpack/config.toml[/], agentpack")
|
|
137
|
+
console.print(" silently repacks if stale. [dim]Non-configured repos are never touched.[/]")
|
|
138
|
+
console.print(f" [dim]Reload with: source {rc_path}[/]")
|
|
139
|
+
else:
|
|
140
|
+
console.print(f"[yellow]Shell hook: {action}[/]")
|
|
141
|
+
|
|
142
|
+
root = _root()
|
|
143
|
+
|
|
144
|
+
# --- Agent-specific config ---
|
|
145
|
+
if agent == "claude":
|
|
146
|
+
if not dry_run:
|
|
147
|
+
hook_action = ClaudeInstaller().patch_claude_settings(root, global_install=True)
|
|
148
|
+
console.print(f"\n[green]~/.claude/settings.json {hook_action}.[/]")
|
|
149
|
+
_install_slash_command(root, global_install=True)
|
|
150
|
+
else:
|
|
151
|
+
console.print("\n[dim]Would patch: ~/.claude/settings.json (hooks)[/]")
|
|
152
|
+
console.print("[dim]Would install: ~/.claude/commands/agentpack.md (slash command)[/]")
|
|
153
|
+
|
|
154
|
+
elif agent == "cursor":
|
|
155
|
+
if not dry_run:
|
|
156
|
+
inst = CursorInstaller()
|
|
157
|
+
rules_action = inst.patch_cursor_rules(root)
|
|
158
|
+
console.print(f"\n[green].cursorrules {rules_action}.[/]")
|
|
159
|
+
mdc_action = inst.patch_cursor_mdc(root)
|
|
160
|
+
console.print(f"[green].cursor/rules/agentpack.mdc {mdc_action}.[/]")
|
|
161
|
+
else:
|
|
162
|
+
console.print("\n[dim]Would patch: .cursorrules, .cursor/rules/agentpack.mdc[/]")
|
|
163
|
+
|
|
164
|
+
elif agent == "windsurf":
|
|
165
|
+
if not dry_run:
|
|
166
|
+
rules_action = WindsurfInstaller().patch_windsurfrules(root)
|
|
167
|
+
console.print(f"\n[green].windsurfrules {rules_action}.[/]")
|
|
168
|
+
else:
|
|
169
|
+
console.print("\n[dim]Would patch: .windsurfrules[/]")
|
|
170
|
+
|
|
171
|
+
elif agent == "codex":
|
|
172
|
+
if not dry_run:
|
|
173
|
+
action = CodexInstaller().patch_agents_md(root)
|
|
174
|
+
console.print(f"\n[green]AGENTS.md {action}.[/]")
|
|
175
|
+
else:
|
|
176
|
+
console.print("\n[dim]Would patch: AGENTS.md[/]")
|
|
177
|
+
|
|
178
|
+
else:
|
|
179
|
+
console.print(f"[yellow]Unknown agent: {agent}. Supported: {', '.join(_SUPPORTED_AGENTS)}[/]")
|
|
180
|
+
raise typer.Exit(1)
|
|
181
|
+
|
|
182
|
+
if dry_run:
|
|
183
|
+
console.print("\n[bold yellow]Dry run complete. Re-run without --dry-run to apply.[/]")
|
|
184
|
+
else:
|
|
185
|
+
console.print("\n[bold green]Global install complete.[/]")
|
|
186
|
+
console.print(" Git hooks fire on commit/merge/checkout — [bold]only in opted-in repos[/].")
|
|
187
|
+
if shell_hook:
|
|
188
|
+
console.print(" Shell hook repacks on cd — [bold]only in repos with .agentpack/config.toml[/].")
|
|
189
|
+
console.print(" To opt a repo in: [bold]cd repo && agentpack init[/]")
|
|
190
|
+
|
|
191
|
+
@app.command(name="global-uninstall")
|
|
192
|
+
def global_uninstall_cmd(
|
|
193
|
+
shell_hook: bool = typer.Option(True, "--shell-hook/--no-shell-hook", help="Remove cd hook from shell rc."),
|
|
194
|
+
git_template: bool = typer.Option(True, "--git-template/--no-git-template", help="Remove git template hooks."),
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Remove agentpack global hooks (git templates + shell rc hook).
|
|
197
|
+
|
|
198
|
+
Per-project .agentpack/ directories and agent config files are not touched.
|
|
199
|
+
"""
|
|
200
|
+
if git_template:
|
|
201
|
+
console.print("[bold]Removing git template hooks...[/]")
|
|
202
|
+
results = remove_git_template_hooks()
|
|
203
|
+
if results:
|
|
204
|
+
for name, action in results.items():
|
|
205
|
+
if action != "unchanged":
|
|
206
|
+
console.print(f"[green]~/.git-templates/hooks/{name} {action}.[/]")
|
|
207
|
+
else:
|
|
208
|
+
console.print("[dim]No git template hooks found.[/]")
|
|
209
|
+
|
|
210
|
+
if shell_hook:
|
|
211
|
+
console.print("\n[bold]Removing shell cd hook...[/]")
|
|
212
|
+
action, rc_path = remove_shell_hook()
|
|
213
|
+
if rc_path:
|
|
214
|
+
console.print(f"[green]{rc_path} {action}.[/]")
|
|
215
|
+
else:
|
|
216
|
+
console.print("[dim]No shell hook found (unknown shell).[/]")
|
|
217
|
+
|
|
218
|
+
console.print("\n[bold green]Global uninstall complete.[/]")
|
|
219
|
+
console.print(" Per-project [dim].agentpack/[/] directories are untouched.")
|
|
220
|
+
console.print(" To remove from a specific repo: delete [dim].agentpack/[/] and remove agent config.")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _print_auto_repack_results(results: dict[str, str]) -> None:
|
|
224
|
+
for key, action in results.items():
|
|
225
|
+
if action == "unchanged":
|
|
226
|
+
continue
|
|
227
|
+
if key.startswith("git:"):
|
|
228
|
+
console.print(f"[green].git/hooks/{key[4:]} {action}.[/]")
|
|
229
|
+
elif key == "vscode:tasks":
|
|
230
|
+
console.print(f"[green].vscode/tasks.json {action}.[/]")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _install_slash_command(root: Path, global_install: bool) -> None:
|
|
234
|
+
import importlib.resources
|
|
235
|
+
|
|
236
|
+
commands_dir = (
|
|
237
|
+
Path.home() / ".claude" / "commands" if global_install
|
|
238
|
+
else root / ".claude" / "commands"
|
|
239
|
+
)
|
|
240
|
+
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
dest = commands_dir / "agentpack.md"
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
pkg_files = importlib.resources.files("agentpack") / "data" / "agentpack.md"
|
|
245
|
+
source_text = pkg_files.read_text(encoding="utf-8")
|
|
246
|
+
except Exception:
|
|
247
|
+
source_text = (Path(__file__).parent.parent / "data" / "agentpack.md").read_text()
|
|
248
|
+
|
|
249
|
+
dest.write_text(source_text)
|
|
250
|
+
scope = "global" if global_install else "local"
|
|
251
|
+
console.print(f"[green]Slash command installed ({scope}):[/] {dest}")
|
|
252
|
+
console.print(" Use [bold]/agentpack[/] in any Claude CLI session.")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich import box
|
|
8
|
+
|
|
9
|
+
from agentpack.commands._shared import console, _root
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(app: typer.Typer) -> None:
|
|
13
|
+
@app.command()
|
|
14
|
+
def monitor(
|
|
15
|
+
last: int = typer.Option(20, "--last", "-n", help="Show last N pack runs."),
|
|
16
|
+
clear: bool = typer.Option(False, "--clear", help="Delete metrics log."),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Show pack performance metrics across runs."""
|
|
19
|
+
root = _root()
|
|
20
|
+
metrics_path = root / ".agentpack" / "metrics.jsonl"
|
|
21
|
+
|
|
22
|
+
if clear:
|
|
23
|
+
if metrics_path.exists():
|
|
24
|
+
metrics_path.unlink()
|
|
25
|
+
console.print("[green]Metrics log cleared.[/]")
|
|
26
|
+
else:
|
|
27
|
+
console.print("[dim]No metrics log found.[/]")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
if not metrics_path.exists():
|
|
31
|
+
console.print("[yellow]No metrics recorded yet. Run agentpack pack first.[/]")
|
|
32
|
+
raise typer.Exit(1)
|
|
33
|
+
|
|
34
|
+
records = []
|
|
35
|
+
for line in metrics_path.read_text().splitlines():
|
|
36
|
+
line = line.strip()
|
|
37
|
+
if line:
|
|
38
|
+
try:
|
|
39
|
+
records.append(json.loads(line))
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
if not records:
|
|
44
|
+
console.print("[yellow]Metrics log is empty.[/]")
|
|
45
|
+
raise typer.Exit(1)
|
|
46
|
+
|
|
47
|
+
recent = records[-last:]
|
|
48
|
+
|
|
49
|
+
# Summary stats
|
|
50
|
+
savings = [r["saving_pct"] for r in recent]
|
|
51
|
+
totals = [r["total_s"] for r in recent]
|
|
52
|
+
avg_saving = sum(savings) / len(savings)
|
|
53
|
+
avg_total = sum(totals) / len(totals)
|
|
54
|
+
best_saving = max(savings)
|
|
55
|
+
|
|
56
|
+
summary_table = Table(title="Performance Summary", show_header=True, box=box.SIMPLE)
|
|
57
|
+
summary_table.add_column("Metric", style="cyan")
|
|
58
|
+
summary_table.add_column("Value", justify="right")
|
|
59
|
+
summary_table.add_row("Runs recorded", str(len(records)))
|
|
60
|
+
summary_table.add_row("Shown", str(len(recent)))
|
|
61
|
+
summary_table.add_row("Avg saving", f"[green]{avg_saving:.1f}%[/]")
|
|
62
|
+
summary_table.add_row("Best saving", f"[green]{best_saving:.1f}%[/]")
|
|
63
|
+
summary_table.add_row("Avg pack time", f"{avg_total:.2f}s")
|
|
64
|
+
console.print(summary_table)
|
|
65
|
+
|
|
66
|
+
# Per-run table
|
|
67
|
+
run_table = Table(title=f"Last {len(recent)} Runs", show_header=True, box=box.SIMPLE)
|
|
68
|
+
run_table.add_column("When", style="dim", max_width=20)
|
|
69
|
+
run_table.add_column("Task", max_width=35)
|
|
70
|
+
run_table.add_column("Mode", width=9)
|
|
71
|
+
run_table.add_column("Saving", justify="right")
|
|
72
|
+
run_table.add_column("Packed", justify="right")
|
|
73
|
+
run_table.add_column("Total", justify="right")
|
|
74
|
+
run_table.add_column("scan", justify="right", style="dim")
|
|
75
|
+
run_table.add_column("sum", justify="right", style="dim")
|
|
76
|
+
run_table.add_column("rank", justify="right", style="dim")
|
|
77
|
+
|
|
78
|
+
for r in recent:
|
|
79
|
+
ts = r.get("ts", "")[:16].replace("T", " ")
|
|
80
|
+
phases = r.get("phases", {})
|
|
81
|
+
run_table.add_row(
|
|
82
|
+
ts,
|
|
83
|
+
r.get("task", "")[:35],
|
|
84
|
+
r.get("mode", ""),
|
|
85
|
+
f"[green]{r['saving_pct']:.1f}%[/]",
|
|
86
|
+
f"{r['packed_tokens']:,}",
|
|
87
|
+
f"{r['total_s']:.2f}s",
|
|
88
|
+
f"{phases.get('scan', 0):.2f}s",
|
|
89
|
+
f"{phases.get('summarize', 0):.2f}s",
|
|
90
|
+
f"{phases.get('rank', 0):.2f}s",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
console.print(run_table)
|
|
94
|
+
|
|
95
|
+
# Phase breakdown averaged
|
|
96
|
+
phase_keys = ["scan", "summarize", "deps", "changes", "rank", "select", "render"]
|
|
97
|
+
phase_table = Table(title="Avg Phase Times", show_header=True, box=box.SIMPLE)
|
|
98
|
+
phase_table.add_column("Phase", style="cyan")
|
|
99
|
+
phase_table.add_column("Avg (s)", justify="right")
|
|
100
|
+
phase_table.add_column("Max (s)", justify="right")
|
|
101
|
+
for pk in phase_keys:
|
|
102
|
+
vals = [r.get("phases", {}).get(pk, 0) for r in recent]
|
|
103
|
+
if any(v > 0 for v in vals):
|
|
104
|
+
phase_table.add_row(pk, f"{sum(vals)/len(vals):.3f}", f"{max(vals):.3f}")
|
|
105
|
+
console.print(phase_table)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.columns import Columns
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich import box
|
|
12
|
+
|
|
13
|
+
from agentpack.core import git
|
|
14
|
+
from agentpack.application.pack_service import PackRequest, PackService, PackResult
|
|
15
|
+
from agentpack.commands._shared import console, _root
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(app: typer.Typer) -> None:
|
|
19
|
+
@app.command()
|
|
20
|
+
def pack(
|
|
21
|
+
agent: str = typer.Option("claude", "--agent", help="Target agent (claude|cursor|windsurf|codex|generic)."),
|
|
22
|
+
task: str = typer.Option("auto", "--task", help="Task description, or 'auto' to infer from git."),
|
|
23
|
+
mode: str = typer.Option("balanced", "--mode", help="Budget mode (minimal|balanced|deep)."),
|
|
24
|
+
budget: int = typer.Option(0, "--budget", help="Token budget (0 = use config default)."),
|
|
25
|
+
since: Optional[str] = typer.Option(None, "--since", help="Git ref to compare against (e.g. HEAD~1, main)."),
|
|
26
|
+
print_output: bool = typer.Option(False, "--print", help="Print context to stdout."),
|
|
27
|
+
refresh: bool = typer.Option(False, "--refresh", help="Rebuild summaries before packing."),
|
|
28
|
+
summary_provider: str = typer.Option("offline", "--summary-provider", help="Summary provider (offline|claude)."),
|
|
29
|
+
watch: bool = typer.Option(False, "--watch", help="Watch for file changes and re-pack automatically."),
|
|
30
|
+
session: bool = typer.Option(False, "--session", help="Keep re-packing on changes for the whole session (alias for --watch)."),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Generate a context pack for an AI coding agent."""
|
|
33
|
+
if mode not in ("minimal", "balanced", "deep"):
|
|
34
|
+
console.print(f"[red]Invalid mode: {mode}. Use minimal|balanced|deep.[/]")
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
|
|
37
|
+
resolved_task = _resolve_task(task)
|
|
38
|
+
|
|
39
|
+
if watch or session:
|
|
40
|
+
_pack_watch(agent=agent, task=resolved_task, mode=mode, budget=budget,
|
|
41
|
+
since=since, summary_provider=summary_provider)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
result = PackService().run(PackRequest(
|
|
45
|
+
root=_root(),
|
|
46
|
+
agent=agent,
|
|
47
|
+
task=resolved_task,
|
|
48
|
+
mode=mode,
|
|
49
|
+
budget=budget,
|
|
50
|
+
since=since,
|
|
51
|
+
refresh=refresh,
|
|
52
|
+
summary_provider=summary_provider,
|
|
53
|
+
))
|
|
54
|
+
_print_pack_summary(result)
|
|
55
|
+
if print_output:
|
|
56
|
+
print(result.out_path.read_text())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_task(task: str) -> str:
|
|
60
|
+
if task != "auto":
|
|
61
|
+
return task
|
|
62
|
+
inferred = git.infer_task_from_git(_root())
|
|
63
|
+
console.print(f"[dim]Auto task: {inferred}[/]")
|
|
64
|
+
return inferred
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _print_pack_summary(result: PackResult) -> None:
|
|
68
|
+
out_path = result.out_path
|
|
69
|
+
selected = result.pack.selected_files
|
|
70
|
+
packed_tokens = result.packed_tokens
|
|
71
|
+
raw_tokens = result.raw_tokens
|
|
72
|
+
saving_pct = result.saving_pct
|
|
73
|
+
changed_files = result.changed_files
|
|
74
|
+
task = result.pack.task
|
|
75
|
+
since = None # since is not stored in PackResult; shown via changed_files
|
|
76
|
+
|
|
77
|
+
stats = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
|
|
78
|
+
stats.add_column(style="dim")
|
|
79
|
+
stats.add_column(justify="right", style="bold")
|
|
80
|
+
stats.add_row("packed tokens", f"{packed_tokens:,}")
|
|
81
|
+
stats.add_row("raw tokens", f"{raw_tokens:,}")
|
|
82
|
+
stats.add_row("saving", f"[green]{saving_pct:.1f}%[/]")
|
|
83
|
+
|
|
84
|
+
MODE_STYLE = {"full": "green", "symbols": "yellow", "summary": "dim"}
|
|
85
|
+
files_tbl = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))
|
|
86
|
+
files_tbl.add_column("file", style="dim", no_wrap=False, max_width=55)
|
|
87
|
+
files_tbl.add_column("mode", justify="center", width=8)
|
|
88
|
+
files_tbl.add_column("why", style="dim", max_width=30)
|
|
89
|
+
|
|
90
|
+
changed_set = set(changed_files)
|
|
91
|
+
for sf in selected[:20]:
|
|
92
|
+
style = MODE_STYLE.get(sf.include_mode, "")
|
|
93
|
+
changed_marker = " [red]●[/]" if sf.path in changed_set else ""
|
|
94
|
+
files_tbl.add_row(
|
|
95
|
+
f"{sf.path}{changed_marker}",
|
|
96
|
+
f"[{style}]{sf.include_mode}[/]",
|
|
97
|
+
sf.reasons[0] if sf.reasons else "",
|
|
98
|
+
)
|
|
99
|
+
if len(selected) > 20:
|
|
100
|
+
files_tbl.add_row(f"[dim]... {len(selected) - 20} more[/]", "", "")
|
|
101
|
+
|
|
102
|
+
if changed_files:
|
|
103
|
+
changed_lines = "\n".join(f" [red]●[/] {f}" for f in changed_files[:10])
|
|
104
|
+
if len(changed_files) > 10:
|
|
105
|
+
changed_lines += f"\n [dim]... {len(changed_files) - 10} more[/]"
|
|
106
|
+
else:
|
|
107
|
+
changed_lines = " [dim]none detected[/]"
|
|
108
|
+
|
|
109
|
+
console.print()
|
|
110
|
+
console.print(Panel(
|
|
111
|
+
f"[bold cyan]{task}[/]",
|
|
112
|
+
title="[bold green]✓ Context Pack Ready[/]",
|
|
113
|
+
subtitle=f"[dim]{out_path}[/]",
|
|
114
|
+
border_style="green",
|
|
115
|
+
padding=(0, 1),
|
|
116
|
+
))
|
|
117
|
+
console.print()
|
|
118
|
+
console.print(Columns([stats, files_tbl], equal=False, expand=False))
|
|
119
|
+
|
|
120
|
+
if changed_files:
|
|
121
|
+
console.print(f"\n[bold]Changed files[/] ({len(changed_files)}):")
|
|
122
|
+
console.print(changed_lines)
|
|
123
|
+
|
|
124
|
+
console.print(f"\n[bold]Next step:[/]")
|
|
125
|
+
console.print(f" [bold white]claude < {out_path}[/]")
|
|
126
|
+
console.print(f" [dim]or: agentpack pack --task \"{task}\" --print | claude[/]")
|
|
127
|
+
console.print()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _pack_watch(
|
|
131
|
+
agent: str,
|
|
132
|
+
task: str,
|
|
133
|
+
mode: str,
|
|
134
|
+
budget: int,
|
|
135
|
+
since: str | None,
|
|
136
|
+
summary_provider: str,
|
|
137
|
+
) -> None:
|
|
138
|
+
try:
|
|
139
|
+
from watchdog.observers import Observer
|
|
140
|
+
from watchdog.events import FileSystemEventHandler
|
|
141
|
+
except ImportError:
|
|
142
|
+
console.print("[red]watchdog is required for --watch mode.[/]")
|
|
143
|
+
console.print("Install it: [bold]pip install watchdog[/]")
|
|
144
|
+
raise typer.Exit(1)
|
|
145
|
+
|
|
146
|
+
root = _root()
|
|
147
|
+
console.print(f"[bold]Watch mode active.[/] Repacking on file changes... (Ctrl+C to stop)")
|
|
148
|
+
console.print(f" Task: {task}")
|
|
149
|
+
|
|
150
|
+
def _run_pack() -> None:
|
|
151
|
+
result = PackService().run(PackRequest(
|
|
152
|
+
root=root, agent=agent, task=task, mode=mode, budget=budget,
|
|
153
|
+
since=since, refresh=False, summary_provider=summary_provider,
|
|
154
|
+
))
|
|
155
|
+
_print_pack_summary(result)
|
|
156
|
+
|
|
157
|
+
_run_pack()
|
|
158
|
+
|
|
159
|
+
_last_pack = [time.time()]
|
|
160
|
+
_DEBOUNCE = 2.0
|
|
161
|
+
|
|
162
|
+
class Handler(FileSystemEventHandler):
|
|
163
|
+
def on_any_event(self, event): # type: ignore[override]
|
|
164
|
+
if event.is_directory:
|
|
165
|
+
return
|
|
166
|
+
path = str(event.src_path)
|
|
167
|
+
if ".agentpack" in path:
|
|
168
|
+
return
|
|
169
|
+
now = time.time()
|
|
170
|
+
if now - _last_pack[0] < _DEBOUNCE:
|
|
171
|
+
return
|
|
172
|
+
_last_pack[0] = now
|
|
173
|
+
console.print(f"\n[dim]Change detected: {event.src_path}[/]")
|
|
174
|
+
try:
|
|
175
|
+
_run_pack()
|
|
176
|
+
except Exception as e:
|
|
177
|
+
console.print(f"[red]Pack error: {e}[/]")
|
|
178
|
+
|
|
179
|
+
observer = Observer()
|
|
180
|
+
observer.schedule(Handler(), str(root), recursive=True)
|
|
181
|
+
observer.start()
|
|
182
|
+
try:
|
|
183
|
+
while True:
|
|
184
|
+
time.sleep(1)
|
|
185
|
+
except KeyboardInterrupt:
|
|
186
|
+
observer.stop()
|
|
187
|
+
console.print("\n[dim]Watch mode stopped.[/]")
|
|
188
|
+
observer.join()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from agentpack.core.config import load_config
|
|
7
|
+
from agentpack.core.ignore import load_spec
|
|
8
|
+
from agentpack.core.scanner import scan
|
|
9
|
+
from agentpack.commands._shared import console, _root
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(app: typer.Typer) -> None:
|
|
13
|
+
@app.command(name="scan")
|
|
14
|
+
def scan_cmd() -> None:
|
|
15
|
+
"""Scan the repository and report file statistics."""
|
|
16
|
+
root = _root()
|
|
17
|
+
cfg = load_config(root)
|
|
18
|
+
ignore_spec = load_spec(root / cfg.project.ignore_file)
|
|
19
|
+
|
|
20
|
+
console.print("[bold]Scanning repository...[/]")
|
|
21
|
+
scan_result = scan(root, ignore_spec, cfg.context.max_file_tokens)
|
|
22
|
+
|
|
23
|
+
total = len(scan_result.all_files)
|
|
24
|
+
ignored = len(scan_result.ignored) + len(scan_result.binary)
|
|
25
|
+
scanned = len(scan_result.packable)
|
|
26
|
+
raw_tokens = sum(f.estimated_tokens for f in scan_result.all_files)
|
|
27
|
+
after_ignore = sum(f.estimated_tokens for f in scan_result.packable)
|
|
28
|
+
|
|
29
|
+
largest = sorted(
|
|
30
|
+
scan_result.packable,
|
|
31
|
+
key=lambda x: x.estimated_tokens,
|
|
32
|
+
reverse=True,
|
|
33
|
+
)[:10]
|
|
34
|
+
|
|
35
|
+
table = Table(title="Repository Scan", show_header=True)
|
|
36
|
+
table.add_column("Metric", style="cyan")
|
|
37
|
+
table.add_column("Value", justify="right")
|
|
38
|
+
table.add_row("Files discovered", str(total))
|
|
39
|
+
table.add_row("Files ignored / binary", str(ignored))
|
|
40
|
+
table.add_row("Files scanned", str(scanned))
|
|
41
|
+
table.add_row("Raw estimated tokens", f"{raw_tokens:,}")
|
|
42
|
+
table.add_row("Tokens after ignore", f"{after_ignore:,}")
|
|
43
|
+
console.print(table)
|
|
44
|
+
|
|
45
|
+
if largest:
|
|
46
|
+
lt = Table(title="Largest Files", show_header=True)
|
|
47
|
+
lt.add_column("File", style="dim")
|
|
48
|
+
lt.add_column("Tokens", justify="right")
|
|
49
|
+
for f in largest:
|
|
50
|
+
lt.add_row(f.path, f"{f.estimated_tokens:,}")
|
|
51
|
+
console.print(lt)
|