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,160 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ _AGENTPACK_BLOCK = """\
8
+ <!-- agentpack:start -->
9
+ ## AgentPack Context
10
+
11
+ AgentPack keeps context fresh automatically. When a session is running:
12
+
13
+ 1. Check `.agentpack/session.json` — if `"active": true`, read `.agentpack/context.md`.
14
+ 2. When the user gives you a new coding task, write a one-line summary to `.agentpack/task.md`.
15
+ 3. Re-read `.agentpack/context.md` after watch mode refreshes it (a few seconds).
16
+ 4. Prefer files listed in context, but verify with actual code before editing.
17
+
18
+ If no session is running, generate context manually:
19
+
20
+ ```bash
21
+ agentpack pack --agent claude --task "<task>"
22
+ ```
23
+
24
+ Then read `.agentpack/context.claude.md`.
25
+ <!-- agentpack:end -->"""
26
+
27
+ _BLOCK_RE = re.compile(
28
+ r"<!-- agentpack:start -->.*?<!-- agentpack:end -->",
29
+ re.DOTALL,
30
+ )
31
+
32
+
33
+ class ClaudeInstaller:
34
+ """Configures Claude-specific repo and global files."""
35
+
36
+ def patch_claude_md(self, root: Path) -> str:
37
+ """Insert/update AgentPack block in CLAUDE.md. Returns action taken."""
38
+ claude_md = root / "CLAUDE.md"
39
+
40
+ if not claude_md.exists():
41
+ claude_md.write_text(f"{_AGENTPACK_BLOCK}\n")
42
+ return "created"
43
+
44
+ content = claude_md.read_text()
45
+ if _BLOCK_RE.search(content):
46
+ new_content = _BLOCK_RE.sub(_AGENTPACK_BLOCK, content)
47
+ if new_content != content:
48
+ claude_md.write_text(new_content)
49
+ return "updated"
50
+ return "unchanged"
51
+
52
+ claude_md.write_text(content.rstrip() + "\n\n" + _AGENTPACK_BLOCK + "\n")
53
+ return "appended"
54
+
55
+ def patch_claude_settings(self, root: Path, global_install: bool = False) -> str:
56
+ """Merge agentpack hooks into .claude/settings.json. Returns action taken."""
57
+ if global_install:
58
+ settings_path = Path.home() / ".claude" / "settings.json"
59
+ else:
60
+ settings_path = root / ".claude" / "settings.json"
61
+
62
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
63
+
64
+ existing: dict = {}
65
+ if settings_path.exists():
66
+ try:
67
+ existing = json.loads(settings_path.read_text())
68
+ except json.JSONDecodeError:
69
+ existing = {}
70
+
71
+ hooks = existing.setdefault("hooks", {})
72
+
73
+ # SessionStart: delete sentinel + kick off background repack so first prompt
74
+ # gets fresh context without blocking the session.
75
+ sentinel_cmd = (
76
+ "rm -f .agentpack/.context_injected"
77
+ " && agentpack pack --task auto --mode balanced >/dev/null 2>&1 &"
78
+ )
79
+ session_start = hooks.setdefault("SessionStart", [])
80
+ # Replace any stale agentpack session hooks (old cmd only deleted sentinel).
81
+ for entry in session_start:
82
+ entry["hooks"] = [
83
+ h for h in entry.get("hooks", [])
84
+ if not (".context_injected" in h.get("command", "") and "rm -f" in h.get("command", ""))
85
+ ]
86
+ session_start[:] = [e for e in session_start if e.get("hooks")]
87
+ already_has_session_hook = any(
88
+ any(h.get("command", "") == sentinel_cmd for h in entry.get("hooks", []))
89
+ for entry in session_start
90
+ )
91
+ if not already_has_session_hook:
92
+ session_start.append({"hooks": [{"type": "command", "command": sentinel_cmd}]})
93
+
94
+ # UserPromptSubmit: hash-gated injection.
95
+ # - Always runs `agentpack status` (cheap; uses cached file hashes).
96
+ # - Stale → background repack, no injection this turn.
97
+ # - Fresh + new pack hash → inject once, write hash to sentinel.
98
+ # - Fresh + same hash → skip (already injected this pack version).
99
+ inject_command = (
100
+ "python3 -c \"\n"
101
+ "import hashlib, json, pathlib, subprocess, sys\n"
102
+ "snap = pathlib.Path('.agentpack/snapshots/latest.json')\n"
103
+ "sentinel = pathlib.Path('.agentpack/.context_injected')\n"
104
+ "injected_hash = sentinel.read_text().strip() if sentinel.exists() else None\n"
105
+ "fresh_session = not sentinel.exists()\n"
106
+ # Fast path: compare snapshot hash directly — no subprocess needed.
107
+ # Only skip if snap exists AND matches injected hash (context already in window).
108
+ "current_hash = hashlib.md5(snap.read_bytes()).hexdigest() if snap.exists() else None\n"
109
+ "if current_hash and current_hash == injected_hash:\n"
110
+ " sys.exit(0)\n"
111
+ # Snapshot changed or missing — need to check/rebuild the pack.
112
+ # fresh_session: sentinel was just cleared by SessionStart hook.
113
+ "ctx = pathlib.Path('.agentpack/context.claude.md')\n"
114
+ "status = subprocess.run(['agentpack', 'status'], capture_output=True, text=True)\n"
115
+ "if status.returncode != 0:\n"
116
+ " if fresh_session and not ctx.exists():\n"
117
+ # No pack at all on fresh session — sync pack so first prompt has context.
118
+ " subprocess.run(['agentpack', 'pack', '--task', 'auto', '--mode', 'balanced'],\n"
119
+ " capture_output=True)\n"
120
+ " current_hash = hashlib.md5(snap.read_bytes()).hexdigest() if snap.exists() else None\n"
121
+ " else:\n"
122
+ # Pack exists but stale — background repack; inject stale pack this turn.
123
+ " subprocess.Popen(['agentpack', 'pack', '--task', 'auto', '--mode', 'balanced'],\n"
124
+ " stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n"
125
+ "if not ctx.exists(): sys.exit(0)\n"
126
+ "content = ctx.read_text()\n"
127
+ "if len(content) > 60000: content = content[:60000] + '\\n... [truncated]'\n"
128
+ "sentinel.write_text(current_hash or '1')\n"
129
+ "print(json.dumps({'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit',\n"
130
+ " 'additionalContext': '[agentpack: context injected]\\n\\n' + content}}))\n"
131
+ "\""
132
+ )
133
+ user_prompt = hooks.setdefault("UserPromptSubmit", [])
134
+ # Replace any stale agentpack inject hooks (identified by signature strings).
135
+ for entry in user_prompt:
136
+ entry["hooks"] = [
137
+ h for h in entry.get("hooks", [])
138
+ if "context.claude.md" not in h.get("command", "")
139
+ and ".context_injected" not in h.get("command", "")
140
+ ]
141
+ user_prompt[:] = [e for e in user_prompt if e.get("hooks")]
142
+ already_has_prompt_hook = any(
143
+ any(h.get("command", "") == inject_command for h in entry.get("hooks", []))
144
+ for entry in user_prompt
145
+ )
146
+ if not already_has_prompt_hook:
147
+ user_prompt.append({
148
+ "hooks": [{
149
+ "type": "command",
150
+ "command": inject_command,
151
+ "timeout": 15,
152
+ "statusMessage": "Checking agentpack context...",
153
+ }]
154
+ })
155
+
156
+ new_content = json.dumps(existing, indent=2) + "\n"
157
+ if settings_path.exists() and settings_path.read_text() == new_content:
158
+ return "unchanged"
159
+ settings_path.write_text(new_content)
160
+ return "updated"
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from agentpack.integrations.git_hooks import install_git_hooks
7
+
8
+ _AGENTPACK_BLOCK = """\
9
+ <!-- agentpack:start -->
10
+ ## AgentPack Context
11
+
12
+ If `.agentpack/session.json` exists and `"active": true`:
13
+
14
+ 1. Read `.agentpack/context.md` before making code changes.
15
+ 2. For a new coding task, write a one-line summary to `.agentpack/task.md`.
16
+ 3. Re-read `.agentpack/context.md` after watch mode refreshes it.
17
+ 4. Use AgentPack-selected files as starting points, not as absolute truth.
18
+ 5. If context is missing or stale: `agentpack session refresh`
19
+ <!-- agentpack:end -->"""
20
+
21
+ _BLOCK_RE = re.compile(
22
+ r"<!-- agentpack:start -->.*?<!-- agentpack:end -->",
23
+ re.DOTALL,
24
+ )
25
+
26
+
27
+ class CodexInstaller:
28
+ """Configures Codex/OpenAI-specific repo files and auto-repack hooks."""
29
+
30
+ def patch_agents_md(self, root: Path) -> str:
31
+ """Insert/update AgentPack block in AGENTS.md. Returns action taken."""
32
+ agents_md = root / "AGENTS.md"
33
+
34
+ if not agents_md.exists():
35
+ agents_md.write_text(f"{_AGENTPACK_BLOCK}\n")
36
+ return "created"
37
+
38
+ content = agents_md.read_text()
39
+ if _BLOCK_RE.search(content):
40
+ new_content = _BLOCK_RE.sub(_AGENTPACK_BLOCK, content)
41
+ if new_content != content:
42
+ agents_md.write_text(new_content)
43
+ return "updated"
44
+ return "unchanged"
45
+
46
+ agents_md.write_text(content.rstrip() + "\n\n" + _AGENTPACK_BLOCK + "\n")
47
+ return "appended"
48
+
49
+ def install_auto_repack(self, root: Path) -> dict[str, str]:
50
+ """Install git hooks for auto-repack. Returns results dict."""
51
+ results: dict[str, str] = {}
52
+ hook_results = install_git_hooks(root, agent="codex")
53
+ results.update({f"git:{k}": v for k, v in hook_results.items()})
54
+ return results
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from agentpack.integrations.git_hooks import install_git_hooks
7
+ from agentpack.integrations.vscode_tasks import install_vscode_tasks
8
+
9
+ _CURSOR_RULE = """\
10
+ <!-- agentpack:rule:start -->
11
+ If `.agentpack/session.json` exists and active, read `.agentpack/context.md` at the start of every conversation.
12
+ For a new task, update `.agentpack/task.md` with a one-line summary.
13
+ If context is stale or missing: agentpack session refresh
14
+ <!-- agentpack:rule:end -->"""
15
+
16
+ _RULE_RE = re.compile(
17
+ r"<!-- agentpack:rule:start -->.*?<!-- agentpack:rule:end -->",
18
+ re.DOTALL,
19
+ )
20
+
21
+
22
+ class CursorInstaller:
23
+ """Configures Cursor-specific repo files and auto-repack hooks."""
24
+
25
+ def patch_cursor_rules(self, root: Path) -> str:
26
+ """Insert/update agentpack rule in .cursorrules. Returns action taken."""
27
+ rules_file = root / ".cursorrules"
28
+
29
+ if not rules_file.exists():
30
+ rules_file.write_text(f"{_CURSOR_RULE}\n")
31
+ return "created"
32
+
33
+ content = rules_file.read_text()
34
+ if _RULE_RE.search(content):
35
+ new_content = _RULE_RE.sub(_CURSOR_RULE, content)
36
+ if new_content != content:
37
+ rules_file.write_text(new_content)
38
+ return "updated"
39
+ return "unchanged"
40
+
41
+ rules_file.write_text(content.rstrip() + "\n\n" + _CURSOR_RULE + "\n")
42
+ return "appended"
43
+
44
+ def patch_cursor_mdc(self, root: Path) -> str:
45
+ """Write agentpack rule as a Cursor .mdc rule file (Cursor v0.43+)."""
46
+ rules_dir = root / ".cursor" / "rules"
47
+ rules_dir.mkdir(parents=True, exist_ok=True)
48
+ mdc_path = rules_dir / "agentpack.mdc"
49
+
50
+ content = """\
51
+ ---
52
+ description: AgentPack session context injection
53
+ alwaysApply: true
54
+ ---
55
+
56
+ When working in this repository:
57
+
58
+ 1. Check `.agentpack/session.json` — if `"active": true`, read `.agentpack/context.md` before editing code.
59
+ 2. For a new user task, write a one-line summary to `.agentpack/task.md`.
60
+ 3. Prefer files with high AgentPack scores as starting points.
61
+ 4. If context seems unrelated to the task, run: `agentpack session refresh`
62
+ """
63
+ already = mdc_path.exists()
64
+ if already and mdc_path.read_text() == content:
65
+ return "unchanged"
66
+
67
+ mdc_path.write_text(content)
68
+ return "updated" if already else "created"
69
+
70
+ def install_auto_repack(self, root: Path) -> dict[str, str]:
71
+ """Install git hooks + VS Code tasks for auto-repack. Returns results dict."""
72
+ results: dict[str, str] = {}
73
+ hook_results = install_git_hooks(root, agent="cursor")
74
+ results.update({f"git:{k}": v for k, v in hook_results.items()})
75
+ results["vscode:tasks"] = install_vscode_tasks(root, agent="cursor")
76
+ return results
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from agentpack.integrations.git_hooks import install_git_hooks
7
+ from agentpack.integrations.vscode_tasks import install_vscode_tasks
8
+
9
+ _WINDSURF_RULE = """\
10
+ <!-- agentpack:rule:start -->
11
+ If `.agentpack/session.json` exists and active, read `.agentpack/context.md` at the start of every conversation.
12
+ For a new task, update `.agentpack/task.md` with a one-line summary.
13
+ If context is stale or missing: agentpack session refresh
14
+ <!-- agentpack:rule:end -->"""
15
+
16
+ _RULE_RE = re.compile(
17
+ r"<!-- agentpack:rule:start -->.*?<!-- agentpack:rule:end -->",
18
+ re.DOTALL,
19
+ )
20
+
21
+
22
+ class WindsurfInstaller:
23
+ """Configures Windsurf-specific repo files and auto-repack hooks."""
24
+
25
+ def patch_windsurfrules(self, root: Path) -> str:
26
+ """Insert/update agentpack rule in .windsurfrules. Returns action taken."""
27
+ rules_file = root / ".windsurfrules"
28
+
29
+ if not rules_file.exists():
30
+ rules_file.write_text(f"{_WINDSURF_RULE}\n")
31
+ return "created"
32
+
33
+ content = rules_file.read_text()
34
+ if _RULE_RE.search(content):
35
+ new_content = _RULE_RE.sub(_WINDSURF_RULE, content)
36
+ if new_content != content:
37
+ rules_file.write_text(new_content)
38
+ return "updated"
39
+ return "unchanged"
40
+
41
+ rules_file.write_text(content.rstrip() + "\n\n" + _WINDSURF_RULE + "\n")
42
+ return "appended"
43
+
44
+ def install_auto_repack(self, root: Path) -> dict[str, str]:
45
+ """Install git hooks + VS Code tasks for auto-repack. Returns results dict."""
46
+ results: dict[str, str] = {}
47
+ hook_results = install_git_hooks(root, agent="windsurf")
48
+ results.update({f"git:{k}": v for k, v in hook_results.items()})
49
+ results["vscode:tasks"] = install_vscode_tasks(root, agent="windsurf")
50
+ return results
File without changes
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import stat
4
+ from pathlib import Path
5
+
6
+ # Hooks that indicate the working tree changed and the pack may be stale.
7
+ _HOOK_EVENTS = ("post-commit", "post-merge", "post-checkout")
8
+
9
+ _AGENTPACK_MARKER = "# agentpack:auto-repack"
10
+
11
+
12
+ def _hook_script(agent: str) -> str:
13
+ return (
14
+ f"{_AGENTPACK_MARKER}\n"
15
+ f"agentpack pack --agent {agent} --task auto --mode balanced "
16
+ f">/dev/null 2>&1 &\n"
17
+ )
18
+
19
+
20
+ def install_git_hooks(root: Path, agent: str) -> dict[str, str]:
21
+ """Install agentpack auto-repack lines into .git/hooks/*.
22
+
23
+ Returns {hook_name: action} where action is created|updated|unchanged.
24
+ Idempotent — safe to re-run. Appends to existing hooks rather than replacing.
25
+ """
26
+ hooks_dir = root / ".git" / "hooks"
27
+ if not hooks_dir.exists():
28
+ return {}
29
+
30
+ results: dict[str, str] = {}
31
+ snippet = _hook_script(agent)
32
+
33
+ for event in _HOOK_EVENTS:
34
+ hook_path = hooks_dir / event
35
+ if hook_path.exists():
36
+ content = hook_path.read_text()
37
+ if _AGENTPACK_MARKER in content:
38
+ # Already installed — update the agent name if it changed
39
+ lines = content.splitlines(keepends=True)
40
+ new_lines = []
41
+ skip_next = False
42
+ for line in lines:
43
+ if line.strip() == _AGENTPACK_MARKER:
44
+ new_lines.append(snippet)
45
+ skip_next = True
46
+ continue
47
+ if skip_next:
48
+ skip_next = False
49
+ continue
50
+ new_lines.append(line)
51
+ new_content = "".join(new_lines)
52
+ if new_content != content:
53
+ hook_path.write_text(new_content)
54
+ results[event] = "updated"
55
+ else:
56
+ results[event] = "unchanged"
57
+ else:
58
+ # Append to existing hook
59
+ sep = "" if content.endswith("\n") else "\n"
60
+ hook_path.write_text(content + sep + snippet)
61
+ results[event] = "appended"
62
+ else:
63
+ hook_path.write_text(f"#!/bin/sh\n{snippet}")
64
+ results[event] = "created"
65
+
66
+ # Ensure executable
67
+ current = hook_path.stat().st_mode
68
+ hook_path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
69
+
70
+ return results
71
+
72
+
73
+ def remove_git_hooks(root: Path) -> dict[str, str]:
74
+ """Remove agentpack lines from .git/hooks/*. Returns {hook_name: action}."""
75
+ hooks_dir = root / ".git" / "hooks"
76
+ if not hooks_dir.exists():
77
+ return {}
78
+
79
+ results: dict[str, str] = {}
80
+ for event in _HOOK_EVENTS:
81
+ hook_path = hooks_dir / event
82
+ if not hook_path.exists():
83
+ continue
84
+ content = hook_path.read_text()
85
+ if _AGENTPACK_MARKER not in content:
86
+ results[event] = "unchanged"
87
+ continue
88
+ lines = content.splitlines(keepends=True)
89
+ new_lines = []
90
+ skip_next = False
91
+ for line in lines:
92
+ if line.strip() == _AGENTPACK_MARKER:
93
+ skip_next = True
94
+ continue
95
+ if skip_next:
96
+ skip_next = False
97
+ continue
98
+ new_lines.append(line)
99
+ new_content = "".join(new_lines)
100
+ # Remove file if only shebang remains
101
+ stripped = new_content.strip()
102
+ if stripped in ("", "#!/bin/sh", "#!/bin/bash"):
103
+ hook_path.unlink()
104
+ results[event] = "removed"
105
+ else:
106
+ hook_path.write_text(new_content)
107
+ results[event] = "cleaned"
108
+
109
+ return results
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import stat
6
+ from pathlib import Path
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Git template hooks — copied into .git/hooks/ on every git init / git clone
10
+ # ---------------------------------------------------------------------------
11
+
12
+ _GIT_TEMPLATE_DIR = Path.home() / ".git-templates"
13
+ _AGENTPACK_MARKER = "# agentpack:global"
14
+
15
+ _POST_CHECKOUT_SCRIPT = """\
16
+ #!/bin/sh
17
+ # agentpack:global
18
+ # Repack only if this repo has already been opted in to agentpack.
19
+ [ -f .agentpack/config.toml ] && agentpack pack --task auto --mode balanced >/dev/null 2>&1 &
20
+ """
21
+
22
+ _POST_COMMIT_SCRIPT = """\
23
+ #!/bin/sh
24
+ # agentpack:global
25
+ # Repack only if this repo has already been opted in to agentpack.
26
+ [ -f .agentpack/config.toml ] && agentpack pack --task auto --mode balanced >/dev/null 2>&1 &
27
+ """
28
+
29
+ _POST_MERGE_SCRIPT = """\
30
+ #!/bin/sh
31
+ # agentpack:global
32
+ # Repack only if this repo has already been opted in to agentpack.
33
+ [ -f .agentpack/config.toml ] && agentpack pack --task auto --mode balanced >/dev/null 2>&1 &
34
+ """
35
+
36
+ _HOOK_SCRIPTS = {
37
+ "post-checkout": _POST_CHECKOUT_SCRIPT,
38
+ "post-commit": _POST_COMMIT_SCRIPT,
39
+ "post-merge": _POST_MERGE_SCRIPT,
40
+ }
41
+
42
+
43
+ def install_git_template_hooks(dry_run: bool = False) -> dict[str, str]:
44
+ """Install agentpack hooks into ~/.git-templates/hooks/.
45
+
46
+ Git copies these into every new repo on `git init` or `git clone`.
47
+ Returns {hook_name: action}. With dry_run=True, reports what would happen.
48
+ """
49
+ hooks_dir = _GIT_TEMPLATE_DIR / "hooks"
50
+ if not dry_run:
51
+ hooks_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ results: dict[str, str] = {}
54
+ for name, script in _HOOK_SCRIPTS.items():
55
+ hook_path = hooks_dir / name
56
+ if hook_path.exists():
57
+ content = hook_path.read_text()
58
+ if _AGENTPACK_MARKER in content:
59
+ results[name] = "unchanged"
60
+ continue
61
+ results[name] = "would-append" if dry_run else "appended"
62
+ if not dry_run:
63
+ sep = "" if content.endswith("\n") else "\n"
64
+ hook_path.write_text(content + sep + script)
65
+ else:
66
+ results[name] = "would-create" if dry_run else "created"
67
+ if not dry_run:
68
+ hook_path.write_text(script)
69
+ if not dry_run:
70
+ hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
71
+
72
+ return results
73
+
74
+
75
+ def configure_git_template_dir(dry_run: bool = False) -> str:
76
+ """Set git's global init.templateDir to ~/.git-templates. Returns action taken."""
77
+ if dry_run:
78
+ return "would-configure"
79
+ import subprocess
80
+ result = subprocess.run(
81
+ ["git", "config", "--global", "init.templateDir", str(_GIT_TEMPLATE_DIR)],
82
+ capture_output=True, text=True,
83
+ )
84
+ return "configured" if result.returncode == 0 else "failed"
85
+
86
+
87
+ def remove_git_template_hooks() -> dict[str, str]:
88
+ """Remove agentpack lines from ~/.git-templates/hooks/*."""
89
+ hooks_dir = _GIT_TEMPLATE_DIR / "hooks"
90
+ if not hooks_dir.exists():
91
+ return {}
92
+
93
+ results: dict[str, str] = {}
94
+ for name in _HOOK_SCRIPTS:
95
+ hook_path = hooks_dir / name
96
+ if not hook_path.exists():
97
+ continue
98
+ content = hook_path.read_text()
99
+ if _AGENTPACK_MARKER not in content:
100
+ results[name] = "unchanged"
101
+ continue
102
+ # Remove agentpack block (shebang + marker + agentpack lines)
103
+ lines = content.splitlines(keepends=True)
104
+ new_lines: list[str] = []
105
+ skip = False
106
+ for line in lines:
107
+ if _AGENTPACK_MARKER in line:
108
+ skip = True
109
+ # Also remove the preceding shebang if it was the only line before marker
110
+ if new_lines and new_lines[-1].strip().startswith("#!/"):
111
+ prev = "".join(new_lines[:-1])
112
+ if not prev.strip():
113
+ new_lines.pop()
114
+ continue
115
+ if skip:
116
+ if line.startswith("agentpack ") or line.strip() == "":
117
+ continue
118
+ skip = False
119
+ new_lines.append(line)
120
+
121
+ new_content = "".join(new_lines).strip()
122
+ if new_content in ("", "#!/bin/sh", "#!/bin/bash"):
123
+ hook_path.unlink()
124
+ results[name] = "removed"
125
+ else:
126
+ hook_path.write_text(new_content + "\n")
127
+ results[name] = "cleaned"
128
+
129
+ return results
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Shell rc hook — `cd` into any git repo → auto-bootstrap agentpack
134
+ # ---------------------------------------------------------------------------
135
+
136
+ _SHELL_MARKER_START = "# agentpack:chpwd:start"
137
+ _SHELL_MARKER_END = "# agentpack:chpwd:end"
138
+
139
+ _ZSH_HOOK = """\
140
+ # agentpack:chpwd:start
141
+ _agentpack_chpwd() {
142
+ # Only act on repos explicitly opted in (have .agentpack/config.toml).
143
+ # Does NOT auto-init unknown repos — that's an explicit 'agentpack init' decision.
144
+ if [ -f .agentpack/config.toml ]; then
145
+ agentpack status >/dev/null 2>&1 || agentpack pack --task auto --mode balanced >/dev/null 2>&1 &
146
+ fi
147
+ }
148
+ autoload -Uz add-zsh-hook
149
+ add-zsh-hook chpwd _agentpack_chpwd
150
+ # agentpack:chpwd:end"""
151
+
152
+ _BASH_HOOK = """\
153
+ # agentpack:chpwd:start
154
+ _agentpack_chpwd() {
155
+ # Only act on repos explicitly opted in (have .agentpack/config.toml).
156
+ if [ -f .agentpack/config.toml ]; then
157
+ agentpack status >/dev/null 2>&1 || agentpack pack --task auto --mode balanced >/dev/null 2>&1 &
158
+ fi
159
+ }
160
+ if [[ "$PROMPT_COMMAND" != *"_agentpack_chpwd"* ]]; then
161
+ PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }_agentpack_chpwd"
162
+ fi
163
+ # agentpack:chpwd:end"""
164
+
165
+ _BLOCK_RE = re.compile(
166
+ r"# agentpack:chpwd:start.*?# agentpack:chpwd:end\n?",
167
+ re.DOTALL,
168
+ )
169
+
170
+
171
+ def _detect_rc_file() -> Path | None:
172
+ shell = os.environ.get("SHELL", "")
173
+ if "zsh" in shell:
174
+ return Path.home() / ".zshrc"
175
+ if "bash" in shell:
176
+ rc = Path.home() / ".bashrc"
177
+ profile = Path.home() / ".bash_profile"
178
+ return rc if rc.exists() else profile
179
+ return None
180
+
181
+
182
+ def install_shell_hook(rc_file: Path | None = None, dry_run: bool = False) -> tuple[str, Path | None]:
183
+ """Append agentpack chpwd hook to shell rc. Returns (action, rc_path)."""
184
+ target = rc_file or _detect_rc_file()
185
+ if target is None:
186
+ return "skipped (unknown shell)", None
187
+
188
+ shell_hook = _ZSH_HOOK if "zsh" in str(target) else _BASH_HOOK
189
+
190
+ if target.exists():
191
+ content = target.read_text()
192
+ if _SHELL_MARKER_START in content:
193
+ new_content = _BLOCK_RE.sub(shell_hook + "\n", content)
194
+ if new_content != content:
195
+ if not dry_run:
196
+ target.write_text(new_content)
197
+ return "would-update" if dry_run else "updated", target
198
+ return "unchanged", target
199
+ if not dry_run:
200
+ sep = "" if content.endswith("\n") else "\n"
201
+ target.write_text(content + sep + shell_hook + "\n")
202
+ return "would-append" if dry_run else "appended", target
203
+ else:
204
+ if not dry_run:
205
+ target.write_text(shell_hook + "\n")
206
+ return "would-create" if dry_run else "created", target
207
+
208
+
209
+ def remove_shell_hook(rc_file: Path | None = None) -> tuple[str, Path | None]:
210
+ """Remove agentpack chpwd hook from shell rc. Returns (action, rc_path)."""
211
+ target = rc_file or _detect_rc_file()
212
+ if target is None or not target.exists():
213
+ return "unchanged", target
214
+
215
+ content = target.read_text()
216
+ if _SHELL_MARKER_START not in content:
217
+ return "unchanged", target
218
+
219
+ new_content = _BLOCK_RE.sub("", content)
220
+ target.write_text(new_content)
221
+ return "removed", target