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,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.")