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,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from agentpack.commands._shared import console, _root
|
|
11
|
+
from agentpack.commands.session import _run_refresh, _now_iso
|
|
12
|
+
from agentpack.session.state import CONTEXT_FILE, TASK_FILE, load_session, log_activity, save_session
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(app: typer.Typer) -> None:
|
|
16
|
+
@app.command("claude")
|
|
17
|
+
def claude_cmd() -> None:
|
|
18
|
+
"""Launch Claude CLI with the current AgentPack context."""
|
|
19
|
+
root = _root()
|
|
20
|
+
state = load_session(root)
|
|
21
|
+
|
|
22
|
+
if state is None or not state.active:
|
|
23
|
+
console.print("[yellow]No active session.[/]")
|
|
24
|
+
console.print("Start one with: [bold]agentpack session start[/]")
|
|
25
|
+
raise typer.Exit(1)
|
|
26
|
+
|
|
27
|
+
console.print("Session active. Refreshing context...")
|
|
28
|
+
result = _run_refresh(root, state.agent, state.mode, budget=0)
|
|
29
|
+
if result:
|
|
30
|
+
console.print(
|
|
31
|
+
f"[green]✓[/] refreshed: {result['files']} files, "
|
|
32
|
+
f"{result['tokens'] / 1000:.1f}k tokens"
|
|
33
|
+
)
|
|
34
|
+
state.last_refresh_at = _now_iso()
|
|
35
|
+
state.refresh_count += 1
|
|
36
|
+
task_path = root / TASK_FILE
|
|
37
|
+
if task_path.exists():
|
|
38
|
+
state.last_task_hash = hashlib.sha256(task_path.read_bytes()).hexdigest()[:16]
|
|
39
|
+
save_session(root, state)
|
|
40
|
+
log_activity(root, f"claude cmd — {result['files']} files, {result['tokens']:,} tokens")
|
|
41
|
+
else:
|
|
42
|
+
console.print("[red]Refresh failed — proceeding with existing context if available.[/]")
|
|
43
|
+
|
|
44
|
+
context_path = root / CONTEXT_FILE
|
|
45
|
+
console.print(f"\nContext ready: [bold]{context_path}[/]\n")
|
|
46
|
+
|
|
47
|
+
claude_bin = shutil.which("claude")
|
|
48
|
+
if claude_bin:
|
|
49
|
+
console.print("Launching Claude CLI...")
|
|
50
|
+
console.print("[dim](Claude reads .agentpack/context.md via CLAUDE.md or /agentpack)[/]\n")
|
|
51
|
+
subprocess.run(["claude"])
|
|
52
|
+
else:
|
|
53
|
+
console.print("[yellow]Claude CLI not found.[/]")
|
|
54
|
+
console.print("Install: https://claude.ai/download")
|
|
55
|
+
console.print(f"\nOnce installed, run [bold]claude[/] and use: [bold]{context_path}[/]")
|
|
@@ -0,0 +1,46 @@
|
|
|
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.core.snapshot import build_snapshot, load_snapshot
|
|
10
|
+
from agentpack.core.diff import diff_snapshots
|
|
11
|
+
from agentpack.commands._shared import console, _root
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(app: typer.Typer) -> None:
|
|
15
|
+
@app.command()
|
|
16
|
+
def diff() -> None:
|
|
17
|
+
"""Show changes since last snapshot."""
|
|
18
|
+
root = _root()
|
|
19
|
+
cfg = load_config(root)
|
|
20
|
+
ignore_spec = load_spec(root / cfg.project.ignore_file)
|
|
21
|
+
|
|
22
|
+
scan_result = scan(root, ignore_spec, cfg.context.max_file_tokens)
|
|
23
|
+
current = build_snapshot(scan_result.packable)
|
|
24
|
+
previous = load_snapshot(root)
|
|
25
|
+
result = diff_snapshots(previous, current)
|
|
26
|
+
|
|
27
|
+
table = Table(title="Snapshot Diff", show_header=True)
|
|
28
|
+
table.add_column("Category", style="cyan")
|
|
29
|
+
table.add_column("Count", justify="right")
|
|
30
|
+
table.add_row("Added files", str(len(result.added)))
|
|
31
|
+
table.add_row("Modified files", str(len(result.modified)))
|
|
32
|
+
table.add_row("Deleted files", str(len(result.deleted)))
|
|
33
|
+
table.add_row("Unchanged files", str(len(result.unchanged)))
|
|
34
|
+
console.print(table)
|
|
35
|
+
|
|
36
|
+
for label, items, style in [
|
|
37
|
+
("Added", result.added, "green"),
|
|
38
|
+
("Modified", result.modified, "yellow"),
|
|
39
|
+
("Deleted", result.deleted, "red"),
|
|
40
|
+
]:
|
|
41
|
+
if items:
|
|
42
|
+
console.print(f"\n[{style}]{label}:[/]")
|
|
43
|
+
for f in items[:30]:
|
|
44
|
+
console.print(f" {f}")
|
|
45
|
+
if len(items) > 30:
|
|
46
|
+
console.print(f" ... and {len(items) - 30} more")
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from agentpack.integrations.global_install import (
|
|
11
|
+
_GIT_TEMPLATE_DIR,
|
|
12
|
+
_AGENTPACK_MARKER,
|
|
13
|
+
_SHELL_MARKER_START,
|
|
14
|
+
_HOOK_SCRIPTS,
|
|
15
|
+
_detect_rc_file,
|
|
16
|
+
)
|
|
17
|
+
from agentpack.commands._shared import console, _root
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(app: typer.Typer) -> None:
|
|
21
|
+
@app.command()
|
|
22
|
+
def doctor() -> None:
|
|
23
|
+
"""Diagnose agentpack installation state — global hooks, per-repo config, agent setup."""
|
|
24
|
+
ok = True
|
|
25
|
+
|
|
26
|
+
# --- CLI binary ---
|
|
27
|
+
console.print("[bold]CLI[/]")
|
|
28
|
+
binary = shutil.which("agentpack")
|
|
29
|
+
if binary:
|
|
30
|
+
try:
|
|
31
|
+
result = subprocess.run(["agentpack", "--version"], capture_output=True, text=True)
|
|
32
|
+
ver = result.stdout.strip() or result.stderr.strip()
|
|
33
|
+
console.print(f" [green]✓[/] agentpack found at {binary} ({ver})")
|
|
34
|
+
except Exception:
|
|
35
|
+
console.print(f" [green]✓[/] agentpack found at {binary}")
|
|
36
|
+
else:
|
|
37
|
+
console.print(" [red]✗[/] agentpack not on PATH — run: pipx install agentpack-cli")
|
|
38
|
+
ok = False
|
|
39
|
+
|
|
40
|
+
# --- Git template hooks ---
|
|
41
|
+
console.print("\n[bold]Git template hooks (~/.git-templates/hooks/)[/]")
|
|
42
|
+
hooks_dir = _GIT_TEMPLATE_DIR / "hooks"
|
|
43
|
+
if not hooks_dir.exists():
|
|
44
|
+
console.print(" [yellow]![/] ~/.git-templates/hooks/ does not exist — run: agentpack global-install")
|
|
45
|
+
ok = False
|
|
46
|
+
else:
|
|
47
|
+
for name in _HOOK_SCRIPTS:
|
|
48
|
+
hook_path = hooks_dir / name
|
|
49
|
+
if hook_path.exists() and _AGENTPACK_MARKER in hook_path.read_text():
|
|
50
|
+
console.print(f" [green]✓[/] {name}")
|
|
51
|
+
else:
|
|
52
|
+
console.print(f" [red]✗[/] {name} missing — run: agentpack global-install --no-shell-hook --no-pipx")
|
|
53
|
+
ok = False
|
|
54
|
+
|
|
55
|
+
# --- git config init.templateDir ---
|
|
56
|
+
console.print("\n[bold]git config init.templateDir[/]")
|
|
57
|
+
try:
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
["git", "config", "--global", "init.templateDir"],
|
|
60
|
+
capture_output=True, text=True,
|
|
61
|
+
)
|
|
62
|
+
configured_dir = result.stdout.strip()
|
|
63
|
+
if configured_dir == str(_GIT_TEMPLATE_DIR):
|
|
64
|
+
console.print(f" [green]✓[/] init.templateDir = {configured_dir}")
|
|
65
|
+
elif configured_dir:
|
|
66
|
+
console.print(f" [yellow]![/] init.templateDir = {configured_dir} (not agentpack's dir)")
|
|
67
|
+
else:
|
|
68
|
+
console.print(" [red]✗[/] init.templateDir not set — run: agentpack global-install --no-shell-hook --no-pipx")
|
|
69
|
+
ok = False
|
|
70
|
+
except Exception:
|
|
71
|
+
console.print(" [yellow]![/] Could not check git config")
|
|
72
|
+
|
|
73
|
+
# --- Shell hook ---
|
|
74
|
+
console.print("\n[bold]Shell cd hook[/]")
|
|
75
|
+
rc_file = _detect_rc_file()
|
|
76
|
+
if rc_file is None:
|
|
77
|
+
console.print(f" [yellow]![/] Unknown shell ({os.environ.get('SHELL', 'unset')}) — cannot check")
|
|
78
|
+
elif not rc_file.exists():
|
|
79
|
+
console.print(f" [red]✗[/] {rc_file} does not exist — run: agentpack global-install --no-git-template --no-pipx")
|
|
80
|
+
ok = False
|
|
81
|
+
elif _SHELL_MARKER_START in rc_file.read_text():
|
|
82
|
+
console.print(f" [green]✓[/] Hook present in {rc_file}")
|
|
83
|
+
else:
|
|
84
|
+
console.print(f" [red]✗[/] Hook missing from {rc_file} — run: agentpack global-install --no-git-template --no-pipx")
|
|
85
|
+
ok = False
|
|
86
|
+
|
|
87
|
+
# --- Per-repo state ---
|
|
88
|
+
console.print("\n[bold]Per-repo state[/]")
|
|
89
|
+
try:
|
|
90
|
+
root = _root()
|
|
91
|
+
except Exception:
|
|
92
|
+
console.print(" [dim]Not in a git repository[/]")
|
|
93
|
+
_print_summary(ok)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
config_path = root / ".agentpack" / "config.toml"
|
|
97
|
+
context_path = root / ".agentpack" / "context.claude.md"
|
|
98
|
+
if not config_path.exists():
|
|
99
|
+
console.print(f" [yellow]![/] Not initialized in {root} — run: agentpack init")
|
|
100
|
+
else:
|
|
101
|
+
console.print(f" [green]✓[/] .agentpack/config.toml present")
|
|
102
|
+
if context_path.exists():
|
|
103
|
+
import time
|
|
104
|
+
age = time.time() - context_path.stat().st_mtime
|
|
105
|
+
age_str = f"{int(age // 3600)}h {int((age % 3600) // 60)}m" if age > 3600 else f"{int(age // 60)}m"
|
|
106
|
+
console.print(f" [green]✓[/] context pack present (age: {age_str})")
|
|
107
|
+
else:
|
|
108
|
+
console.print(" [yellow]![/] No context pack yet — run: agentpack pack --task \"<task>\"")
|
|
109
|
+
|
|
110
|
+
# --- Agent-specific config ---
|
|
111
|
+
console.print("\n[bold]Agent config[/]")
|
|
112
|
+
_check_agent_file(root, "CLAUDE.md", "claude")
|
|
113
|
+
_check_agent_file(root, ".cursorrules", "cursor")
|
|
114
|
+
_check_agent_file(root, ".windsurfrules", "windsurf")
|
|
115
|
+
_check_agent_file(root, "AGENTS.md", "codex")
|
|
116
|
+
|
|
117
|
+
claude_settings = root / ".claude" / "settings.json"
|
|
118
|
+
global_claude_settings = Path.home() / ".claude" / "settings.json"
|
|
119
|
+
import json as _json
|
|
120
|
+
_local_has_hooks = False
|
|
121
|
+
_global_has_hooks = False
|
|
122
|
+
if claude_settings.exists():
|
|
123
|
+
try:
|
|
124
|
+
data = _json.loads(claude_settings.read_text())
|
|
125
|
+
hooks = data.get("hooks", {})
|
|
126
|
+
if "UserPromptSubmit" in hooks or "SessionStart" in hooks:
|
|
127
|
+
console.print(f" [green]✓[/] Claude hooks present (local): {claude_settings}")
|
|
128
|
+
_local_has_hooks = True
|
|
129
|
+
else:
|
|
130
|
+
console.print(f" [yellow]![/] Claude hooks missing (local) — run: agentpack install --agent claude")
|
|
131
|
+
ok = False
|
|
132
|
+
except Exception:
|
|
133
|
+
console.print(f" [yellow]![/] Could not parse {claude_settings}")
|
|
134
|
+
else:
|
|
135
|
+
console.print(" [dim]-[/] .claude/settings.json not present (run: agentpack install --agent claude)")
|
|
136
|
+
if global_claude_settings.exists():
|
|
137
|
+
try:
|
|
138
|
+
data = _json.loads(global_claude_settings.read_text())
|
|
139
|
+
hooks = data.get("hooks", {})
|
|
140
|
+
if "UserPromptSubmit" in hooks or "SessionStart" in hooks:
|
|
141
|
+
console.print(f" [green]✓[/] Claude hooks present (global): {global_claude_settings}")
|
|
142
|
+
_global_has_hooks = True
|
|
143
|
+
else:
|
|
144
|
+
console.print(f" [yellow]![/] Claude hooks missing (global) — run: agentpack install --agent claude --global")
|
|
145
|
+
except Exception:
|
|
146
|
+
console.print(f" [yellow]![/] Could not parse {global_claude_settings}")
|
|
147
|
+
else:
|
|
148
|
+
console.print(" [dim]-[/] ~/.claude/settings.json has no agentpack hooks — run: agentpack install --agent claude --global")
|
|
149
|
+
if _local_has_hooks and not _global_has_hooks:
|
|
150
|
+
console.print(" [yellow]![/] Hooks local-only — context won't auto-inject in other repos. Run: agentpack install --agent claude --global")
|
|
151
|
+
|
|
152
|
+
# --- Slash command ---
|
|
153
|
+
console.print("\n[bold]Slash command (/agentpack)[/]")
|
|
154
|
+
local_cmd = root / ".claude" / "commands" / "agentpack.md"
|
|
155
|
+
global_cmd = Path.home() / ".claude" / "commands" / "agentpack.md"
|
|
156
|
+
if local_cmd.exists():
|
|
157
|
+
console.print(f" [green]✓[/] Slash command installed (local): {local_cmd}")
|
|
158
|
+
else:
|
|
159
|
+
console.print(" [dim]-[/] Slash command not installed locally — run: agentpack install --agent claude")
|
|
160
|
+
if global_cmd.exists():
|
|
161
|
+
console.print(f" [green]✓[/] Slash command installed (global): {global_cmd}")
|
|
162
|
+
else:
|
|
163
|
+
console.print(" [dim]-[/] Slash command not installed globally — run: agentpack install --agent claude --global")
|
|
164
|
+
|
|
165
|
+
_print_summary(ok)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _check_agent_file(root: Path, filename: str, agent: str) -> None:
|
|
169
|
+
path = root / filename
|
|
170
|
+
if path.exists():
|
|
171
|
+
content = path.read_text()
|
|
172
|
+
if "agentpack" in content.lower():
|
|
173
|
+
console.print(f" [green]✓[/] {filename} (agentpack configured)")
|
|
174
|
+
else:
|
|
175
|
+
console.print(f" [dim]-[/] {filename} exists but agentpack not configured — run: agentpack install --agent {agent}")
|
|
176
|
+
else:
|
|
177
|
+
console.print(f" [dim]-[/] {filename} not present (optional)")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _print_summary(ok: bool) -> None:
|
|
181
|
+
console.print("")
|
|
182
|
+
if ok:
|
|
183
|
+
console.print("[bold green]All checks passed.[/]")
|
|
184
|
+
else:
|
|
185
|
+
console.print("[bold yellow]Some checks failed. Run the suggested commands above to fix.[/]")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from agentpack.application.pack_service import PackPlanner, PackRequest
|
|
9
|
+
from agentpack.core.context_pack import select_files
|
|
10
|
+
from agentpack.commands._shared import console, _root
|
|
11
|
+
from agentpack.commands.pack import _resolve_task
|
|
12
|
+
from agentpack.core.config import load_config, ScoringWeights
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_signal_weight(reason: str, weights: ScoringWeights) -> float:
|
|
16
|
+
"""Map a reason string to its numeric weight value."""
|
|
17
|
+
reason_lower = reason.lower()
|
|
18
|
+
if reason_lower == "modified":
|
|
19
|
+
return weights.modified
|
|
20
|
+
if reason_lower == "staged":
|
|
21
|
+
return weights.staged
|
|
22
|
+
if reason_lower == "filename keyword match":
|
|
23
|
+
return weights.filename_keyword
|
|
24
|
+
if reason_lower == "symbol keyword match":
|
|
25
|
+
return weights.symbol_keyword
|
|
26
|
+
m = re.match(r"content keyword match \((\d+)\)", reason_lower)
|
|
27
|
+
if m:
|
|
28
|
+
n = int(m.group(1))
|
|
29
|
+
return min(n * weights.content_keyword_per_hit, weights.content_keyword_max)
|
|
30
|
+
if reason_lower == "direct dependency of changed file":
|
|
31
|
+
return weights.direct_dep
|
|
32
|
+
if reason_lower == "reverse dependency":
|
|
33
|
+
return weights.reverse_dep
|
|
34
|
+
if reason_lower == "has related tests":
|
|
35
|
+
return weights.related_test
|
|
36
|
+
if reason_lower == "config file":
|
|
37
|
+
return weights.config_file
|
|
38
|
+
if reason_lower == "recently modified":
|
|
39
|
+
return weights.recently_modified
|
|
40
|
+
if reason_lower in ("large/unrelated file", "large unrelated file"):
|
|
41
|
+
return weights.large_unrelated_penalty
|
|
42
|
+
return 0.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _print_file_detail(
|
|
46
|
+
file_path: str,
|
|
47
|
+
plan: object,
|
|
48
|
+
weights: ScoringWeights,
|
|
49
|
+
near_cutoff_paths: set[str],
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Print per-file score breakdown."""
|
|
52
|
+
score_map: dict[str, tuple[float, list[str]]] = {
|
|
53
|
+
fi.path: (score, reasons) for fi, score, reasons in plan.scored # type: ignore[attr-defined]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if file_path not in score_map:
|
|
57
|
+
console.print(f"[red]File not found in scoring data: {file_path}[/]")
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
|
|
60
|
+
score_val, reasons = score_map[file_path]
|
|
61
|
+
|
|
62
|
+
# Find in selected
|
|
63
|
+
selected_file = None
|
|
64
|
+
for sf in plan.selected: # type: ignore[attr-defined]
|
|
65
|
+
if sf.path == file_path:
|
|
66
|
+
selected_file = sf
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
is_selected = selected_file is not None
|
|
70
|
+
would_appear = file_path in near_cutoff_paths
|
|
71
|
+
|
|
72
|
+
# Token count from selected file, fall back to FileInfo scan estimate
|
|
73
|
+
token_count = 0
|
|
74
|
+
if selected_file is not None:
|
|
75
|
+
from agentpack.application.pack_service import _sf_tokens
|
|
76
|
+
token_count = _sf_tokens(selected_file)
|
|
77
|
+
else:
|
|
78
|
+
for fi in plan.scan_result.packable: # type: ignore[attr-defined]
|
|
79
|
+
if fi.path == file_path:
|
|
80
|
+
token_count = fi.estimated_tokens
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
include_mode = selected_file.include_mode if selected_file else "—"
|
|
84
|
+
|
|
85
|
+
# Symbols from plan.summaries
|
|
86
|
+
summary_data = plan.summaries.get(file_path, {}) # type: ignore[attr-defined]
|
|
87
|
+
raw_symbols = summary_data.get("symbols", []) if isinstance(summary_data, dict) else []
|
|
88
|
+
symbol_names = [s["name"] if isinstance(s, dict) else s.name for s in raw_symbols]
|
|
89
|
+
|
|
90
|
+
console.print()
|
|
91
|
+
console.print(f"[bold]{file_path}[/]")
|
|
92
|
+
console.print(f" selected: {'[green]yes[/]' if is_selected else '[yellow]no[/]'}")
|
|
93
|
+
if not is_selected:
|
|
94
|
+
console.print(f" would appear with larger budget: {'[cyan]yes[/]' if would_appear else 'no'}")
|
|
95
|
+
console.print(f" score: [bold]{score_val:.0f}[/]")
|
|
96
|
+
console.print(f" include: {include_mode}")
|
|
97
|
+
console.print(f" tokens: {token_count:,}")
|
|
98
|
+
console.print()
|
|
99
|
+
console.print(" [bold]signals:[/]")
|
|
100
|
+
if reasons:
|
|
101
|
+
for reason in reasons:
|
|
102
|
+
weight = _resolve_signal_weight(reason, weights)
|
|
103
|
+
sign = "+" if weight >= 0 else ""
|
|
104
|
+
color = "green" if weight > 0 else "red" if weight < 0 else "dim"
|
|
105
|
+
console.print(f" [{color}]{sign}{weight:.0f}[/] {reason}")
|
|
106
|
+
else:
|
|
107
|
+
console.print(" [dim](none)[/]")
|
|
108
|
+
if symbol_names:
|
|
109
|
+
console.print()
|
|
110
|
+
console.print(f" [bold]symbols:[/] {', '.join(symbol_names)}")
|
|
111
|
+
console.print()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def register(app: typer.Typer) -> None:
|
|
115
|
+
@app.command()
|
|
116
|
+
def explain(
|
|
117
|
+
task: str = typer.Option("auto", "--task", help="Task description, or 'auto' to infer from git."),
|
|
118
|
+
mode: str = typer.Option("balanced", "--mode", help="Budget mode (minimal|balanced|deep)."),
|
|
119
|
+
budget: int = typer.Option(0, "--budget", help="Token budget (0 = use config default)."),
|
|
120
|
+
since: Optional[str] = typer.Option(None, "--since", help="Git ref to compare against (e.g. HEAD~1, main)."),
|
|
121
|
+
summary_provider: str = typer.Option("offline", "--summary-provider", help="Summary provider (offline|claude)."),
|
|
122
|
+
file: Optional[str] = typer.Option(None, "--file", help="Show detailed score breakdown for a specific file."),
|
|
123
|
+
omitted: bool = typer.Option(False, "--omitted", is_flag=True, help="Show top-10 excluded files and why."),
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Explain which files would be selected and why, without writing a context file."""
|
|
126
|
+
if mode not in ("minimal", "balanced", "deep"):
|
|
127
|
+
console.print(f"[red]Invalid mode: {mode}. Use minimal|balanced|deep.[/]")
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
|
|
130
|
+
root = _root()
|
|
131
|
+
resolved_task = _resolve_task(task)
|
|
132
|
+
|
|
133
|
+
request = PackRequest(
|
|
134
|
+
root=root,
|
|
135
|
+
agent="generic",
|
|
136
|
+
task=resolved_task,
|
|
137
|
+
mode=mode,
|
|
138
|
+
budget=budget,
|
|
139
|
+
since=since,
|
|
140
|
+
refresh=False,
|
|
141
|
+
summary_provider=summary_provider,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
with console.status("[bold]Planning..."):
|
|
145
|
+
plan = PackPlanner().plan(request)
|
|
146
|
+
|
|
147
|
+
selected = plan.selected
|
|
148
|
+
receipts = plan.receipts
|
|
149
|
+
score_map: dict[str, tuple[float, list[str]]] = {
|
|
150
|
+
fi.path: (score, reasons) for fi, score, reasons in plan.scored
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
selected_paths = {sf.path for sf in selected}
|
|
154
|
+
excluded_receipts = [
|
|
155
|
+
r for r in receipts
|
|
156
|
+
if r.action == "excluded" and r.reason not in ("ignored or binary",)
|
|
157
|
+
and r.path in score_map
|
|
158
|
+
]
|
|
159
|
+
excluded_receipts.sort(key=lambda r: -score_map[r.path][0])
|
|
160
|
+
|
|
161
|
+
cfg = load_config(root)
|
|
162
|
+
deep_budget = plan.budget * 2
|
|
163
|
+
_, deep_receipts = select_files(
|
|
164
|
+
files=plan.scan_result.packable,
|
|
165
|
+
scored=plan.scored,
|
|
166
|
+
changed_paths=plan.all_changed,
|
|
167
|
+
summaries=plan.summaries,
|
|
168
|
+
mode=mode, # type: ignore[arg-type]
|
|
169
|
+
budget=deep_budget,
|
|
170
|
+
max_file_tokens=cfg.context.max_file_tokens,
|
|
171
|
+
keywords=plan.keywords,
|
|
172
|
+
)
|
|
173
|
+
deep_selected_paths = {
|
|
174
|
+
r.path for r in deep_receipts if r.action in ("included", "summarized")
|
|
175
|
+
}
|
|
176
|
+
near_cutoff_paths = deep_selected_paths - selected_paths
|
|
177
|
+
|
|
178
|
+
# --file: per-file detail view
|
|
179
|
+
if file is not None:
|
|
180
|
+
console.print(f"\n[bold]Task:[/] [cyan]{resolved_task}[/] [dim]mode={mode} budget={plan.budget:,}[/]")
|
|
181
|
+
_print_file_detail(file, plan, cfg.scoring, near_cutoff_paths)
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# --omitted: dedicated excluded file view
|
|
185
|
+
if omitted:
|
|
186
|
+
console.print(f"\n[bold]Task:[/] [cyan]{resolved_task}[/] [dim]mode={mode} budget={plan.budget:,}[/]\n")
|
|
187
|
+
console.print("[bold]Top excluded files (by score):[/]")
|
|
188
|
+
if not excluded_receipts:
|
|
189
|
+
console.print(" [dim](none)[/]")
|
|
190
|
+
else:
|
|
191
|
+
for r in excluded_receipts[:10]:
|
|
192
|
+
score_val, reasons = score_map.get(r.path, (0, []))
|
|
193
|
+
reason_str = reasons[0] if reasons else ""
|
|
194
|
+
console.print(
|
|
195
|
+
f" [dim]-[/] {r.path:<50} "
|
|
196
|
+
f"[dim]score={score_val:.0f} {r.reason}[/]"
|
|
197
|
+
+ (f" [dim]({reason_str})[/]" if reason_str and reason_str != r.reason else "")
|
|
198
|
+
)
|
|
199
|
+
console.print()
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
console.print(f"\n[bold]Task:[/] [cyan]{resolved_task}[/] [dim]mode={mode} budget={plan.budget:,}[/]\n")
|
|
203
|
+
|
|
204
|
+
console.print("[bold]Top selected files (ranked):[/]")
|
|
205
|
+
for i, sf in enumerate(selected, 1):
|
|
206
|
+
score_val, reasons = score_map.get(sf.path, (sf.score, sf.reasons))
|
|
207
|
+
reason_str = reasons[0] if reasons else ""
|
|
208
|
+
console.print(
|
|
209
|
+
f" [bold]{i}.[/] {sf.path:<50} "
|
|
210
|
+
f"[dim]score={score_val:.0f}[/] "
|
|
211
|
+
f"[[{'green' if sf.include_mode == 'full' else 'yellow' if sf.include_mode == 'symbols' else 'dim'}]{sf.include_mode}[/]] "
|
|
212
|
+
f"[dim]{reason_str}[/]"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if near_cutoff_paths:
|
|
216
|
+
console.print(f"\n[bold]Files near budget cutoff[/] [dim](would appear with larger budget):[/]")
|
|
217
|
+
near_sorted = sorted(
|
|
218
|
+
near_cutoff_paths,
|
|
219
|
+
key=lambda p: -score_map.get(p, (0, []))[0],
|
|
220
|
+
)
|
|
221
|
+
for i, path in enumerate(near_sorted[:5], len(selected) + 1):
|
|
222
|
+
score_val, reasons = score_map.get(path, (0, []))
|
|
223
|
+
reason_str = reasons[0] if reasons else ""
|
|
224
|
+
console.print(
|
|
225
|
+
f" [dim]{i}.[/] {path:<50} "
|
|
226
|
+
f"[dim]score={score_val:.0f} {reason_str}[/]"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if excluded_receipts:
|
|
230
|
+
console.print(f"\n[bold]Excluded[/] [dim](top 5 by score):[/]")
|
|
231
|
+
for r in excluded_receipts[:5]:
|
|
232
|
+
score_val, _ = score_map.get(r.path, (0, []))
|
|
233
|
+
console.print(
|
|
234
|
+
f" [dim]-[/] {r.path:<50} "
|
|
235
|
+
f"[dim]score={score_val:.0f} {r.reason}[/]"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
console.print()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from agentpack.core.config import DEFAULT_CONFIG, save_config
|
|
9
|
+
from agentpack.core.ignore import DEFAULT_AGENTIGNORE
|
|
10
|
+
from agentpack.commands._shared import console, _root
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(app: typer.Typer) -> None:
|
|
14
|
+
@app.command()
|
|
15
|
+
def init(
|
|
16
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
|
|
17
|
+
mode: Optional[str] = typer.Option(None, "--mode", help="Default pack mode (minimal|balanced|deep)."),
|
|
18
|
+
budget: int = typer.Option(0, "--budget", help="Default token budget (0 = keep default 25000)."),
|
|
19
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive prompts, use defaults."),
|
|
20
|
+
silent: bool = typer.Option(False, "--silent", help="Suppress all output (for use in hooks/scripts)."),
|
|
21
|
+
share_cache: bool = typer.Option(False, "--share-cache", help="Commit summary cache to git (recommended for teams)."),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Initialize AgentPack in the current directory."""
|
|
24
|
+
if silent:
|
|
25
|
+
yes = True
|
|
26
|
+
console.quiet = True
|
|
27
|
+
root = _root()
|
|
28
|
+
agentpack_dir = root / ".agentpack"
|
|
29
|
+
agentpack_dir.mkdir(exist_ok=True)
|
|
30
|
+
(agentpack_dir / "snapshots").mkdir(exist_ok=True)
|
|
31
|
+
(agentpack_dir / "cache").mkdir(exist_ok=True)
|
|
32
|
+
|
|
33
|
+
gitignore = agentpack_dir / ".gitignore"
|
|
34
|
+
if not gitignore.exists() or force:
|
|
35
|
+
# With --share-cache, cache/ is committed so teammates skip the summarize step
|
|
36
|
+
cache_line = "" if share_cache else ".agentpack/cache/\n"
|
|
37
|
+
gitignore.write_text(
|
|
38
|
+
f"{cache_line}.agentpack/snapshots/\n.agentpack/context.*\n.agentpack/metrics.jsonl\n"
|
|
39
|
+
)
|
|
40
|
+
console.print("[green]Created[/] .agentpack/.gitignore")
|
|
41
|
+
if share_cache:
|
|
42
|
+
console.print(" [dim]cache/ not gitignored — commit it so teammates skip agentpack summarize[/]")
|
|
43
|
+
else:
|
|
44
|
+
console.print("[dim]Skipped[/] .agentpack/.gitignore (exists)")
|
|
45
|
+
|
|
46
|
+
config_path_file = agentpack_dir / "config.toml"
|
|
47
|
+
if not config_path_file.exists() or force:
|
|
48
|
+
cfg = DEFAULT_CONFIG.model_copy(deep=True)
|
|
49
|
+
|
|
50
|
+
# Interactive mode selection
|
|
51
|
+
if not yes and mode is None and sys.stdin.isatty():
|
|
52
|
+
console.print("\n[bold]Choose default pack mode:[/]")
|
|
53
|
+
console.print(" [cyan]1[/] minimal — changed files + configs only (fastest, fewest tokens)")
|
|
54
|
+
console.print(" [cyan]2[/] balanced — + deps, tests, summaries [bold](recommended)[/]")
|
|
55
|
+
console.print(" [cyan]3[/] deep — + docs, more full files (most context)")
|
|
56
|
+
choice = typer.prompt("Mode", default="2")
|
|
57
|
+
mode_map = {"1": "minimal", "2": "balanced", "3": "deep",
|
|
58
|
+
"minimal": "minimal", "balanced": "balanced", "deep": "deep"}
|
|
59
|
+
cfg.context.default_mode = mode_map.get(choice.strip(), "balanced")
|
|
60
|
+
elif mode in ("minimal", "balanced", "deep"):
|
|
61
|
+
cfg.context.default_mode = mode
|
|
62
|
+
|
|
63
|
+
if budget > 0:
|
|
64
|
+
cfg.context.default_budget = budget
|
|
65
|
+
|
|
66
|
+
save_config(cfg, root)
|
|
67
|
+
console.print(f"[green]Created[/] .agentpack/config.toml [dim](mode: {cfg.context.default_mode}, budget: {cfg.context.default_budget:,})[/]")
|
|
68
|
+
else:
|
|
69
|
+
console.print("[dim]Skipped[/] .agentpack/config.toml (exists)")
|
|
70
|
+
|
|
71
|
+
ignore_path = root / ".agentignore"
|
|
72
|
+
if not ignore_path.exists() or force:
|
|
73
|
+
ignore_path.write_text(DEFAULT_AGENTIGNORE)
|
|
74
|
+
console.print("[green]Created[/] .agentignore")
|
|
75
|
+
else:
|
|
76
|
+
console.print("[dim]Skipped[/] .agentignore (exists)")
|
|
77
|
+
|
|
78
|
+
console.print("\n[bold green]AgentPack initialized.[/]")
|
|
79
|
+
console.print("Run [bold]agentpack scan[/] to explore your repo.")
|