agent-hygiene 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.
@@ -0,0 +1,3 @@
1
+ """agent-hygiene: inventory and clean up the footprint of coding-agent CLIs."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,222 @@
1
+ """Registry of known coding-agent CLIs and where each one leaves its footprint.
2
+
3
+ Everything the scanner knows about a specific agent lives here, so adding an agent
4
+ means adding one AgentSpec. Paths are relative to $HOME unless absolute and may
5
+ contain glob characters.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import List, Optional, Tuple
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SelfUpdateSpec:
16
+ """A JSON settings file plus the nested key that turns self-update off."""
17
+
18
+ path: str
19
+ key: Tuple[str, ...]
20
+ disabled_value: object
21
+ note: str = ""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class AgentSpec:
26
+ name: str
27
+ display: str
28
+ home_dirs: Tuple[str, ...] = ()
29
+ data_dirs: Tuple[str, ...] = ()
30
+ npm_packages: Tuple[str, ...] = ()
31
+ pip_packages: Tuple[str, ...] = ()
32
+ binaries: Tuple[str, ...] = ()
33
+ versions_roots: Tuple[str, ...] = ()
34
+ current_links: Tuple[str, ...] = ()
35
+ process_patterns: Tuple[str, ...] = ()
36
+ autostart_patterns: Tuple[str, ...] = ()
37
+ self_update: Optional[SelfUpdateSpec] = None
38
+ self_update_note: str = ""
39
+
40
+ def process_regex(self) -> List["re.Pattern[str]"]:
41
+ return [re.compile(p) for p in self.process_patterns]
42
+
43
+ def autostart_regex(self) -> List["re.Pattern[str]"]:
44
+ return [re.compile(p, re.IGNORECASE) for p in self.autostart_patterns]
45
+
46
+ def matches_process(self, command: str) -> bool:
47
+ return any(r.search(command) for r in self.process_regex())
48
+
49
+ def matches_autostart(self, text: str) -> bool:
50
+ return any(r.search(text) for r in self.autostart_regex())
51
+
52
+
53
+ _CURSOR_IDE_MAC = "~/Library/Application Support/Cursor/User/globalStorage/anysphere.cursor-agent-worker"
54
+ _CURSOR_IDE_LINUX = "~/.config/Cursor/User/globalStorage/anysphere.cursor-agent-worker"
55
+
56
+ AGENTS: List[AgentSpec] = [
57
+ AgentSpec(
58
+ name="codex",
59
+ display="OpenAI Codex CLI",
60
+ home_dirs=("~/.codex",),
61
+ npm_packages=("@openai/codex",),
62
+ binaries=("codex",),
63
+ process_patterns=(r"(^|/)codex(\s|$)", r"@openai/codex", r"codex-(x86_64|aarch64)-"),
64
+ autostart_patterns=(r"openai", r"\bcodex\b"),
65
+ self_update_note="Codex CLI has no background self-updater; it only prints an "
66
+ "update notice. Update with `npm i -g @openai/codex`.",
67
+ ),
68
+ AgentSpec(
69
+ name="claude",
70
+ display="Anthropic Claude Code",
71
+ home_dirs=("~/.claude", "~/.claude.json", "~/.claude.json.backup"),
72
+ data_dirs=("~/.local/share/claude", "~/.cache/claude", "~/Library/Caches/claude-cli-nodejs"),
73
+ npm_packages=("@anthropic-ai/claude-code",),
74
+ binaries=("claude",),
75
+ versions_roots=("~/.local/share/claude/versions",),
76
+ current_links=("~/.local/bin/claude", "~/.claude/local/claude"),
77
+ process_patterns=(
78
+ r"(^|/)claude(\s|$)",
79
+ r"/\.local/share/claude/versions/",
80
+ r"@anthropic-ai/claude-code",
81
+ ),
82
+ autostart_patterns=(r"anthropic", r"\bclaude\b"),
83
+ self_update=SelfUpdateSpec(
84
+ path="~/.claude/settings.json",
85
+ key=("env", "DISABLE_AUTOUPDATER"),
86
+ disabled_value="1",
87
+ note="Sets env.DISABLE_AUTOUPDATER=\"1\" in ~/.claude/settings.json.",
88
+ ),
89
+ ),
90
+ AgentSpec(
91
+ name="cursor",
92
+ display="Cursor Agent CLI",
93
+ home_dirs=("~/.cursor",),
94
+ data_dirs=(
95
+ "~/.local/share/cursor-agent",
96
+ "~/.cache/cursor-agent",
97
+ # the Cursor IDE ships its own copy of the agent CLI and keeps every version
98
+ _CURSOR_IDE_MAC + "/agent-cli",
99
+ _CURSOR_IDE_LINUX + "/agent-cli",
100
+ ),
101
+ binaries=("cursor-agent", "agent"),
102
+ versions_roots=(
103
+ "~/.local/share/cursor-agent/versions",
104
+ _CURSOR_IDE_MAC + "/agent-cli/.local/share/cursor-agent/versions",
105
+ _CURSOR_IDE_LINUX + "/agent-cli/.local/share/cursor-agent/versions",
106
+ ),
107
+ current_links=(
108
+ "~/.local/bin/cursor-agent",
109
+ "~/.local/bin/agent",
110
+ _CURSOR_IDE_MAC + "/agent-cli/.local/bin/cursor-agent",
111
+ _CURSOR_IDE_LINUX + "/agent-cli/.local/bin/cursor-agent",
112
+ ),
113
+ process_patterns=(r"cursor[-_]agent", r"/\.local/share/cursor-agent/"),
114
+ autostart_patterns=(r"cursor[-_.]agent", r"anysphere.*agent"),
115
+ self_update_note="Cursor Agent CLI versions are managed by `cursor-agent update`; "
116
+ "there is no documented setting to disable the updater.",
117
+ ),
118
+ AgentSpec(
119
+ name="gemini",
120
+ display="Google Gemini CLI",
121
+ home_dirs=("~/.gemini",),
122
+ npm_packages=("@google/gemini-cli",),
123
+ binaries=("gemini",),
124
+ process_patterns=(r"(^|/)gemini(\s|$)", r"@google/gemini-cli"),
125
+ autostart_patterns=(r"gemini[-_.]cli", r"\bgemini\b"),
126
+ self_update=SelfUpdateSpec(
127
+ path="~/.gemini/settings.json",
128
+ key=("general", "disableAutoUpdate"),
129
+ disabled_value=True,
130
+ note="Sets general.disableAutoUpdate=true in ~/.gemini/settings.json.",
131
+ ),
132
+ ),
133
+ AgentSpec(
134
+ name="copilot",
135
+ display="GitHub Copilot CLI",
136
+ home_dirs=("~/.copilot",),
137
+ npm_packages=("@github/copilot",),
138
+ binaries=("copilot",),
139
+ process_patterns=(r"(^|/)copilot(\s|$)", r"@github/copilot"),
140
+ autostart_patterns=(r"github[-_.]copilot", r"\bcopilot\b"),
141
+ self_update_note="No documented setting; update with `npm i -g @github/copilot`.",
142
+ ),
143
+ AgentSpec(
144
+ name="opencode",
145
+ display="OpenCode",
146
+ home_dirs=("~/.config/opencode",),
147
+ data_dirs=("~/.local/share/opencode", "~/.cache/opencode", "~/.opencode"),
148
+ npm_packages=("opencode-ai",),
149
+ binaries=("opencode",),
150
+ process_patterns=(r"(^|/)opencode(\s|$)",),
151
+ autostart_patterns=(r"\bopencode\b",),
152
+ self_update=SelfUpdateSpec(
153
+ path="~/.config/opencode/opencode.json",
154
+ key=("autoupdate",),
155
+ disabled_value=False,
156
+ note="Sets autoupdate=false in ~/.config/opencode/opencode.json.",
157
+ ),
158
+ ),
159
+ AgentSpec(
160
+ name="devin",
161
+ display="Devin CLI",
162
+ data_dirs=("~/.local/share/devin", "~/.config/devin"),
163
+ binaries=("devin",),
164
+ process_patterns=(r"(^|/)devin(\s|$)", r"/\.local/share/devin/"),
165
+ autostart_patterns=(r"\bdevin\b", r"cognition"),
166
+ self_update_note="No documented setting to disable updates.",
167
+ ),
168
+ AgentSpec(
169
+ name="aider",
170
+ display="Aider",
171
+ home_dirs=("~/.aider", "~/.aider.conf.yml", "~/.aider.model.settings.yml"),
172
+ data_dirs=("~/.local/pipx/venvs/aider-chat", "~/.local/share/uv/tools/aider-chat"),
173
+ pip_packages=("aider-chat",),
174
+ binaries=("aider",),
175
+ process_patterns=(r"(^|/)aider(\s|$)",),
176
+ autostart_patterns=(r"\baider\b",),
177
+ self_update_note="Aider checks for updates at start; disable with --no-check-update "
178
+ "in ~/.aider.conf.yml (check-update: false).",
179
+ ),
180
+ ]
181
+
182
+ # Anything matching these is never attributed to an agent, even if an agent pattern hits.
183
+ EXCLUDE_PROCESS_PATTERNS = [
184
+ re.compile(r"agent[-_]hygiene"),
185
+ re.compile(r"(^|/)(rg|grep|egrep|ps|lsof|less|more|tail|cat|vim|nano|code)\s"),
186
+ ]
187
+
188
+
189
+ def by_name(name: str) -> Optional[AgentSpec]:
190
+ for spec in AGENTS:
191
+ if spec.name == name:
192
+ return spec
193
+ return None
194
+
195
+
196
+ def names() -> List[str]:
197
+ return [a.name for a in AGENTS]
198
+
199
+
200
+ def match_process(command: str) -> Optional[str]:
201
+ if any(p.search(command) for p in EXCLUDE_PROCESS_PATTERNS):
202
+ return None
203
+ for spec in AGENTS:
204
+ if spec.matches_process(command):
205
+ return spec.name
206
+ return None
207
+
208
+
209
+ def match_autostart(text: str) -> Optional[str]:
210
+ for spec in AGENTS:
211
+ if spec.matches_autostart(text):
212
+ return spec.name
213
+ return None
214
+
215
+
216
+ def guess_from_argv0(argv0: str) -> Optional[str]:
217
+ """Map the executable passed to `run -- <agent>` to a registered agent."""
218
+ base = argv0.rsplit("/", 1)[-1]
219
+ for spec in AGENTS:
220
+ if base in spec.binaries:
221
+ return spec.name
222
+ return match_process(argv0 + " ")
agent_hygiene/clean.py ADDED
@@ -0,0 +1,274 @@
1
+ """Build a plan of fixes from an inventory, preview it, and apply it with a backup journal.
2
+
3
+ Each applied action writes a restore record into the journal; `restore.py` reverses them
4
+ in reverse order. Killing a process is the only irreversible action and is opt-in.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import shutil
11
+ import signal
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from . import agents
18
+ from .journal import Journal
19
+ from .model import AutostartEntry, Inventory
20
+ from .output import human_bytes, shorten_home, table
21
+ from .platform import have, run, uid
22
+ from .scan.autostart import read_crontab
23
+ from .scan.footprint import select_prunable
24
+
25
+ DEFAULT_KEEP = 2
26
+
27
+
28
+ @dataclass
29
+ class Action:
30
+ kind: str # disable-launchagent | disable-systemd | disable-cron | disable-self-update | prune-version | kill-process
31
+ agent: str
32
+ target: str
33
+ description: str
34
+ reversible: bool = True
35
+ bytes: int = 0
36
+ data: Dict[str, Any] = field(default_factory=dict)
37
+
38
+
39
+ @dataclass
40
+ class Plan:
41
+ actions: List[Action] = field(default_factory=list)
42
+ skipped: List[str] = field(default_factory=list)
43
+
44
+ @property
45
+ def bytes_to_trash(self) -> int:
46
+ return sum(a.bytes for a in self.actions if a.kind == "prune-version")
47
+
48
+
49
+ # --- planning -----------------------------------------------------------------------
50
+
51
+ def build_plan(
52
+ inv: Inventory,
53
+ autostart: bool = True,
54
+ self_update: bool = True,
55
+ prune: bool = True,
56
+ kill: bool = False,
57
+ keep: int = DEFAULT_KEEP,
58
+ keep_per_agent: Optional[Dict[str, int]] = None,
59
+ ) -> Plan:
60
+ plan = Plan()
61
+ keep_per_agent = keep_per_agent or {}
62
+
63
+ if autostart:
64
+ for e in inv.autostarts:
65
+ if not e.enabled:
66
+ continue
67
+ if not e.user_writable:
68
+ plan.skipped.append(f"{e.kind} {e.id} is system-wide ({e.path}); needs sudo, skipped")
69
+ continue
70
+ plan.actions.append(_plan_autostart(e))
71
+
72
+ if self_update:
73
+ for a in inv.agents:
74
+ su = a.self_update
75
+ if su is None or not su.supported or su.disabled or not a.installed:
76
+ continue
77
+ plan.actions.append(Action(
78
+ kind="disable-self-update", agent=a.name, target=su.path or "",
79
+ description=f"set {su.key} in {shorten_home(su.path or '')} (backup of file kept)",
80
+ data={"path": su.path},
81
+ ))
82
+
83
+ if prune:
84
+ for a in inv.agents:
85
+ n = keep_per_agent.get(a.name, keep)
86
+ for v in select_prunable(a.versions, n):
87
+ plan.actions.append(Action(
88
+ kind="prune-version", agent=a.name, target=v.path, bytes=v.bytes,
89
+ description=f"move {a.name} {v.version} ({human_bytes(v.bytes)}) to trash; keep newest {n}",
90
+ data={"path": v.path, "version": v.version},
91
+ ))
92
+
93
+ if kill:
94
+ for p in inv.processes:
95
+ if p.orphaned:
96
+ plan.actions.append(Action(
97
+ kind="kill-process", agent=p.agent or "?", target=str(p.pid), reversible=False,
98
+ description=f"terminate pid {p.pid} ({p.command[:60]}) — session ended, parent gone",
99
+ data={"pid": p.pid, "command": p.command},
100
+ ))
101
+ return plan
102
+
103
+
104
+ def _plan_autostart(e: AutostartEntry) -> Action:
105
+ if e.kind == "launchagent":
106
+ return Action(kind="disable-launchagent", agent=e.agent or "?", target=e.id,
107
+ description=f"unload LaunchAgent {e.id} and move {shorten_home(e.path)} to backup",
108
+ data={"label": e.id, "path": e.path})
109
+ if e.kind == "systemd-user":
110
+ return Action(kind="disable-systemd", agent=e.agent or "?", target=e.id,
111
+ description=f"systemctl --user disable --now {e.id} (unit file left in place)",
112
+ data={"unit": e.id, "path": e.path})
113
+ return Action(kind="disable-cron", agent=e.agent or "?", target=e.id,
114
+ description=f"comment out crontab line: {e.detail} {e.program}"[:110],
115
+ data={"line_id": e.id, "program": e.program, "schedule": e.detail})
116
+
117
+
118
+ def render_plan(plan: Plan) -> str:
119
+ if not plan.actions and not plan.skipped:
120
+ return "nothing to clean"
121
+ rows = [[i + 1, a.kind, a.agent, "yes" if a.reversible else "NO",
122
+ human_bytes(a.bytes) if a.bytes else "", a.description] for i, a in enumerate(plan.actions)]
123
+ out = []
124
+ if rows:
125
+ out.append(table(["#", "action", "agent", "reversible", "size", "detail"], rows, max_col=110))
126
+ if plan.bytes_to_trash:
127
+ out.append(f"\n{human_bytes(plan.bytes_to_trash)} would move to trash; run `agent-hygiene purge` "
128
+ f"later to free it permanently, or `agent-hygiene restore` to put it back.")
129
+ for s in plan.skipped:
130
+ out.append(f"skipped: {s}")
131
+ return "\n".join(out)
132
+
133
+
134
+ # --- applying -------------------------------------------------------------------------
135
+
136
+ def apply_plan(plan: Plan, log=print) -> Optional[Journal]:
137
+ if not plan.actions:
138
+ return None
139
+ journal = Journal.create()
140
+ log(f"backup id: {journal.id} ({shorten_home(str(journal.root))})")
141
+ for action in plan.actions:
142
+ handler = _APPLIERS[action.kind]
143
+ try:
144
+ record = handler(action, journal)
145
+ except Exception as exc: # keep going; the journal only records what actually happened
146
+ log(f" FAILED {action.kind} {action.target}: {exc}")
147
+ continue
148
+ record.update({"kind": action.kind, "agent": action.agent, "target": action.target,
149
+ "reversible": action.reversible, "applied_at": time.time()})
150
+ journal.record(record)
151
+ log(f" done {action.kind} {action.target}")
152
+ return journal
153
+
154
+
155
+ def _apply_launchagent(action: Action, journal: Journal) -> Dict[str, Any]:
156
+ plist = Path(action.data["path"])
157
+ label = action.data["label"]
158
+ bootout = None
159
+ if have("launchctl"):
160
+ res = run(["launchctl", "bootout", f"gui/{uid()}/{label}"], timeout=15)
161
+ if res.returncode != 0:
162
+ res = run(["launchctl", "unload", str(plist)], timeout=15)
163
+ bootout = res.returncode
164
+ dest = journal.unique_file("launchagents", plist.name)
165
+ shutil.move(str(plist), str(dest))
166
+ return {"label": label, "original": str(plist), "backup": str(dest), "bootout_rc": bootout}
167
+
168
+
169
+ def _apply_systemd(action: Action, journal: Journal) -> Dict[str, Any]:
170
+ unit = action.data["unit"]
171
+ was_enabled = run(["systemctl", "--user", "is-enabled", unit], timeout=10).stdout.strip()
172
+ was_active = run(["systemctl", "--user", "is-active", unit], timeout=10).stdout.strip()
173
+ res = run(["systemctl", "--user", "disable", "--now", unit], timeout=20)
174
+ if res.returncode != 0:
175
+ raise RuntimeError(res.stderr.strip() or f"systemctl exited {res.returncode}")
176
+ return {"unit": unit, "was_enabled": was_enabled, "was_active": was_active}
177
+
178
+
179
+ def _apply_cron(action: Action, journal: Journal) -> Dict[str, Any]:
180
+ from .scan.autostart import cron_line_id
181
+
182
+ text = read_crontab() or ""
183
+ backup = journal.unique_file("cron", "crontab")
184
+ backup.write_text(text)
185
+ new_lines = []
186
+ hit = False
187
+ for line in text.splitlines():
188
+ if not hit and cron_line_id(line.strip()) == action.data["line_id"]:
189
+ new_lines.append(f"# agent-hygiene disabled: {line}")
190
+ hit = True
191
+ else:
192
+ new_lines.append(line)
193
+ if not hit:
194
+ raise RuntimeError("crontab line not found (changed since scan?)")
195
+ new_text = "\n".join(new_lines) + "\n"
196
+ res = run(["crontab", "-"], input_text=new_text, timeout=10)
197
+ if res.returncode != 0:
198
+ raise RuntimeError(res.stderr.strip() or "crontab write failed")
199
+ return {"backup": str(backup), "line_id": action.data["line_id"]}
200
+
201
+
202
+ def _apply_self_update(action: Action, journal: Journal) -> Dict[str, Any]:
203
+ spec = agents.by_name(action.agent)
204
+ assert spec is not None and spec.self_update is not None
205
+ path = Path(action.data["path"])
206
+ existed = path.is_file()
207
+ backup = None
208
+ if existed:
209
+ backup = journal.unique_file("settings", f"{action.agent}-{path.name}")
210
+ shutil.copy2(str(path), str(backup))
211
+ try:
212
+ data = json.loads(path.read_text() or "{}")
213
+ except ValueError as exc:
214
+ raise RuntimeError(f"{path} is not valid JSON: {exc}")
215
+ else:
216
+ data = {}
217
+ path.parent.mkdir(parents=True, exist_ok=True)
218
+ cur = data
219
+ for k in spec.self_update.key[:-1]:
220
+ if not isinstance(cur.get(k), dict):
221
+ cur[k] = {}
222
+ cur = cur[k]
223
+ cur[spec.self_update.key[-1]] = spec.self_update.disabled_value
224
+ tmp = path.with_suffix(path.suffix + ".agent-hygiene.tmp")
225
+ tmp.write_text(json.dumps(data, indent=2) + "\n")
226
+ if existed:
227
+ shutil.copymode(str(path), str(tmp))
228
+ os.replace(tmp, path)
229
+ return {"path": str(path), "existed": existed, "backup": str(backup) if backup else None}
230
+
231
+
232
+ def _apply_prune(action: Action, journal: Journal) -> Dict[str, Any]:
233
+ src = Path(action.data["path"])
234
+ dest = journal.trash_slot(action.agent, src.name)
235
+ # rename within the same filesystem is instant and keeps every attribute intact
236
+ shutil.move(str(src), str(dest))
237
+ return {"original": str(src), "trash": str(dest), "bytes": action.bytes}
238
+
239
+
240
+ def _apply_kill(action: Action, journal: Journal) -> Dict[str, Any]:
241
+ pid = int(action.data["pid"])
242
+ os.kill(pid, signal.SIGTERM)
243
+ deadline = time.time() + 5
244
+ while time.time() < deadline:
245
+ if not _alive(pid):
246
+ return {"pid": pid, "signal": "TERM", "command": action.data["command"]}
247
+ time.sleep(0.1)
248
+ os.kill(pid, signal.SIGKILL)
249
+ return {"pid": pid, "signal": "KILL", "command": action.data["command"]}
250
+
251
+
252
+ def _alive(pid: int) -> bool:
253
+ try:
254
+ os.kill(pid, 0)
255
+ except ProcessLookupError:
256
+ return False
257
+ except PermissionError:
258
+ return True
259
+ # zombie check on Linux
260
+ try:
261
+ with open(f"/proc/{pid}/stat") as fh:
262
+ return fh.read().split(") ")[-1][0] != "Z"
263
+ except OSError:
264
+ return True
265
+
266
+
267
+ _APPLIERS = {
268
+ "disable-launchagent": _apply_launchagent,
269
+ "disable-systemd": _apply_systemd,
270
+ "disable-cron": _apply_cron,
271
+ "disable-self-update": _apply_self_update,
272
+ "prune-version": _apply_prune,
273
+ "kill-process": _apply_kill,
274
+ }