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,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
|