kanibako-cli 1.5.0.dev14__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.
- kanibako/__init__.py +3 -0
- kanibako/__main__.py +6 -0
- kanibako/auth_browser.py +296 -0
- kanibako/auth_parser.py +51 -0
- kanibako/browser_sidecar.py +183 -0
- kanibako/browser_state.py +103 -0
- kanibako/bun_sea.py +144 -0
- kanibako/cli.py +344 -0
- kanibako/commands/__init__.py +0 -0
- kanibako/commands/archive.py +228 -0
- kanibako/commands/box/__init__.py +22 -0
- kanibako/commands/box/_duplicate.py +395 -0
- kanibako/commands/box/_migrate.py +574 -0
- kanibako/commands/box/_parser.py +1178 -0
- kanibako/commands/clean.py +166 -0
- kanibako/commands/crab_cmd.py +480 -0
- kanibako/commands/diagnose.py +239 -0
- kanibako/commands/fork_cmd.py +51 -0
- kanibako/commands/helper_cmd.py +669 -0
- kanibako/commands/image.py +1300 -0
- kanibako/commands/install.py +152 -0
- kanibako/commands/refresh_credentials.py +67 -0
- kanibako/commands/restore.py +298 -0
- kanibako/commands/setup_cmd.py +89 -0
- kanibako/commands/start.py +1600 -0
- kanibako/commands/stop.py +116 -0
- kanibako/commands/system_cmd.py +224 -0
- kanibako/commands/upgrade.py +161 -0
- kanibako/commands/vault_cmd.py +199 -0
- kanibako/commands/workset_cmd.py +552 -0
- kanibako/config.py +514 -0
- kanibako/config_interface.py +573 -0
- kanibako/config_io.py +36 -0
- kanibako/container.py +607 -0
- kanibako/containerfiles.py +58 -0
- kanibako/containers/Containerfile.kanibako +99 -0
- kanibako/containers/Containerfile.template-android +55 -0
- kanibako/containers/Containerfile.template-dotnet +29 -0
- kanibako/containers/Containerfile.template-js +43 -0
- kanibako/containers/Containerfile.template-jvm +27 -0
- kanibako/containers/Containerfile.template-systems +46 -0
- kanibako/containers/__init__.py +0 -0
- kanibako/crabs.py +89 -0
- kanibako/errors.py +33 -0
- kanibako/freshness.py +67 -0
- kanibako/git.py +114 -0
- kanibako/helper_client.py +132 -0
- kanibako/helper_listener.py +538 -0
- kanibako/helpers.py +339 -0
- kanibako/hygiene.py +296 -0
- kanibako/image_sharing.py +133 -0
- kanibako/instructions.py +160 -0
- kanibako/log.py +31 -0
- kanibako/names.py +248 -0
- kanibako/paths.py +1483 -0
- kanibako/plugins/__init__.py +10 -0
- kanibako/registry.py +71 -0
- kanibako/rig_bundle.py +121 -0
- kanibako/rig_meta.py +92 -0
- kanibako/rig_registry.py +132 -0
- kanibako/rig_resolve.py +182 -0
- kanibako/rig_source.py +245 -0
- kanibako/scripts/__init__.py +0 -0
- kanibako/scripts/helper-init.sh +45 -0
- kanibako/scripts/kanibako-entry +12 -0
- kanibako/settings_resolve.py +312 -0
- kanibako/settings_seeds.py +154 -0
- kanibako/settings_shares.py +154 -0
- kanibako/shellenv.py +75 -0
- kanibako/snapshots.py +281 -0
- kanibako/targets/__init__.py +173 -0
- kanibako/targets/base.py +243 -0
- kanibako/targets/no_agent.py +58 -0
- kanibako/templates.py +60 -0
- kanibako/templates_image.py +224 -0
- kanibako/tweakcc.py +140 -0
- kanibako/tweakcc_cache.py +171 -0
- kanibako/utils.py +136 -0
- kanibako/workset.py +347 -0
- kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
- kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
- kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
- kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
kanibako/helpers.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Helper spawning: B-ary tree numbering and spawn budget management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from kanibako.config_io import dump_doc, load_doc
|
|
10
|
+
|
|
11
|
+
# When breadth is unlimited (-1), use 2^16 for numbering purposes.
|
|
12
|
+
# Large enough to never collide; small enough for human-readable numbers.
|
|
13
|
+
UNLIMITED_BREADTH = 2**16
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def effective_breadth(breadth: int) -> int:
|
|
17
|
+
"""Return the breadth used for numbering.
|
|
18
|
+
|
|
19
|
+
Maps -1 (unlimited) to ``UNLIMITED_BREADTH``. Positive values pass
|
|
20
|
+
through unchanged.
|
|
21
|
+
"""
|
|
22
|
+
if breadth == -1:
|
|
23
|
+
return UNLIMITED_BREADTH
|
|
24
|
+
if breadth < 1:
|
|
25
|
+
msg = f"breadth must be positive or -1, got {breadth}"
|
|
26
|
+
raise ValueError(msg)
|
|
27
|
+
return breadth
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def children_of(agent: int, breadth: int) -> tuple[int, int]:
|
|
31
|
+
"""Return the (first_child, last_child) global numbers for *agent*.
|
|
32
|
+
|
|
33
|
+
Both bounds are inclusive. The range always contains exactly
|
|
34
|
+
``effective_breadth(breadth)`` slots, regardless of how many children
|
|
35
|
+
are actually spawned.
|
|
36
|
+
"""
|
|
37
|
+
b = effective_breadth(breadth)
|
|
38
|
+
first = agent * b + 1
|
|
39
|
+
last = agent * b + b
|
|
40
|
+
return first, last
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parent_of(agent: int, breadth: int) -> int | None:
|
|
44
|
+
"""Return the global number of *agent*'s parent.
|
|
45
|
+
|
|
46
|
+
Returns ``None`` if *agent* is the director (agent 0).
|
|
47
|
+
"""
|
|
48
|
+
if agent == 0:
|
|
49
|
+
return None
|
|
50
|
+
b = effective_breadth(breadth)
|
|
51
|
+
return (agent - 1) // b
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def agent_depth(agent: int, breadth: int) -> int:
|
|
55
|
+
"""Return the depth of *agent* in the tree (director = 0)."""
|
|
56
|
+
depth = 0
|
|
57
|
+
current = agent
|
|
58
|
+
while current != 0:
|
|
59
|
+
current = parent_of(current, breadth) # type: ignore[assignment]
|
|
60
|
+
depth += 1
|
|
61
|
+
return depth
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def nth_child(agent: int, n: int, breadth: int) -> int:
|
|
65
|
+
"""Return the global number of *agent*'s *n*-th child (0-indexed).
|
|
66
|
+
|
|
67
|
+
Raises ``ValueError`` if *n* is out of range for the given breadth.
|
|
68
|
+
"""
|
|
69
|
+
b = effective_breadth(breadth)
|
|
70
|
+
if n < 0 or n >= b:
|
|
71
|
+
msg = f"child index {n} out of range for breadth {b}"
|
|
72
|
+
raise ValueError(msg)
|
|
73
|
+
return agent * b + 1 + n
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sibling_index(agent: int, breadth: int) -> int:
|
|
77
|
+
"""Return the 0-based index of *agent* among its parent's children.
|
|
78
|
+
|
|
79
|
+
The director (agent 0) has no siblings; returns 0 by convention.
|
|
80
|
+
"""
|
|
81
|
+
if agent == 0:
|
|
82
|
+
return 0
|
|
83
|
+
b = effective_breadth(breadth)
|
|
84
|
+
return (agent - 1) % b
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Spawn budget
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
DEFAULT_DEPTH = 4
|
|
92
|
+
DEFAULT_BREADTH = 4
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class SpawnBudget:
|
|
97
|
+
"""Spawn limits for an agent. Immutable."""
|
|
98
|
+
|
|
99
|
+
depth: int = DEFAULT_DEPTH
|
|
100
|
+
breadth: int = DEFAULT_BREADTH
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def check_spawn_allowed(budget: SpawnBudget, current_children: int) -> str | None:
|
|
104
|
+
"""Return an error message if spawning is not allowed, else ``None``."""
|
|
105
|
+
if budget.depth == 0:
|
|
106
|
+
return "spawn depth exhausted (depth=0)"
|
|
107
|
+
if budget.breadth != -1 and current_children >= budget.breadth:
|
|
108
|
+
return f"breadth limit reached ({current_children}/{budget.breadth})"
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def child_budget(parent: SpawnBudget) -> SpawnBudget:
|
|
113
|
+
"""Compute the spawn budget for a child of *parent*.
|
|
114
|
+
|
|
115
|
+
Depth is decremented by 1 (unless unlimited). Breadth is inherited.
|
|
116
|
+
"""
|
|
117
|
+
new_depth = parent.depth if parent.depth == -1 else parent.depth - 1
|
|
118
|
+
return SpawnBudget(depth=new_depth, breadth=parent.breadth)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_spawn_budget(
|
|
122
|
+
ro_config: SpawnBudget | None,
|
|
123
|
+
host_config: SpawnBudget | None,
|
|
124
|
+
cli_depth: int | None,
|
|
125
|
+
cli_breadth: int | None,
|
|
126
|
+
) -> SpawnBudget:
|
|
127
|
+
"""Resolve the effective spawn budget using config precedence.
|
|
128
|
+
|
|
129
|
+
Order: RO config > host config > CLI flags > built-in defaults.
|
|
130
|
+
CLI flags only apply when neither RO nor host config exist.
|
|
131
|
+
"""
|
|
132
|
+
if ro_config is not None:
|
|
133
|
+
return ro_config
|
|
134
|
+
if host_config is not None:
|
|
135
|
+
return host_config
|
|
136
|
+
depth = cli_depth if cli_depth is not None else DEFAULT_DEPTH
|
|
137
|
+
breadth = cli_breadth if cli_breadth is not None else DEFAULT_BREADTH
|
|
138
|
+
return SpawnBudget(depth=depth, breadth=breadth)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Spawn config I/O
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def read_spawn_config(path: Path) -> SpawnBudget | None:
|
|
147
|
+
"""Read spawn limits from a config file (kanibako.yaml or RO spawn config).
|
|
148
|
+
|
|
149
|
+
Looks for a ``spawn`` section with ``depth`` and ``breadth`` keys.
|
|
150
|
+
Returns ``None`` if the file or section is absent.
|
|
151
|
+
"""
|
|
152
|
+
if not path.exists():
|
|
153
|
+
return None
|
|
154
|
+
data = load_doc(path)
|
|
155
|
+
spawn = data.get("spawn")
|
|
156
|
+
if spawn is None:
|
|
157
|
+
return None
|
|
158
|
+
return SpawnBudget(
|
|
159
|
+
depth=int(spawn.get("depth", DEFAULT_DEPTH)),
|
|
160
|
+
breadth=int(spawn.get("breadth", DEFAULT_BREADTH)),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def write_spawn_config(path: Path, budget: SpawnBudget) -> None:
|
|
165
|
+
"""Write spawn limits as a ``spawn`` section in a config file.
|
|
166
|
+
|
|
167
|
+
For RO spawn configs this creates a standalone file.
|
|
168
|
+
For kanibako.yaml this preserves other sections.
|
|
169
|
+
"""
|
|
170
|
+
existing = load_doc(path)
|
|
171
|
+
existing["spawn"] = {"depth": budget.depth, "breadth": budget.breadth}
|
|
172
|
+
dump_doc(path, existing)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Directory structure
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def create_helper_dirs(helpers_dir: Path, helper_num: int) -> Path:
|
|
181
|
+
"""Create the directory layout for a single helper.
|
|
182
|
+
|
|
183
|
+
Creates vault (with ro, rw), workspace, playbook/scripts,
|
|
184
|
+
and peers directories. Returns the helper's root directory.
|
|
185
|
+
"""
|
|
186
|
+
root = helpers_dir / str(helper_num)
|
|
187
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
|
|
189
|
+
# Vault with communication channels
|
|
190
|
+
vault = root / "vault"
|
|
191
|
+
vault.mkdir(exist_ok=True)
|
|
192
|
+
(vault / "ro").mkdir(exist_ok=True)
|
|
193
|
+
(vault / "rw").mkdir(exist_ok=True)
|
|
194
|
+
|
|
195
|
+
# Standard layout
|
|
196
|
+
(root / "workspace").mkdir(exist_ok=True)
|
|
197
|
+
playbook = root / "playbook"
|
|
198
|
+
playbook.mkdir(exist_ok=True)
|
|
199
|
+
(playbook / "scripts").mkdir(exist_ok=True)
|
|
200
|
+
|
|
201
|
+
# Peers directory
|
|
202
|
+
(root / "peers").mkdir(exist_ok=True)
|
|
203
|
+
|
|
204
|
+
return root
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def create_broadcast_dirs(helpers_dir: Path) -> Path:
|
|
208
|
+
"""Create the broadcast channel directories under ``helpers/``.
|
|
209
|
+
|
|
210
|
+
Creates ``all/rw`` and ``all/ro``. Idempotent.
|
|
211
|
+
Returns the ``all/`` directory.
|
|
212
|
+
"""
|
|
213
|
+
all_dir = helpers_dir / "all"
|
|
214
|
+
(all_dir / "rw").mkdir(parents=True, exist_ok=True)
|
|
215
|
+
(all_dir / "ro").mkdir(parents=True, exist_ok=True)
|
|
216
|
+
return all_dir
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def create_peer_channels(
|
|
220
|
+
helpers_dir: Path,
|
|
221
|
+
new_helper: int,
|
|
222
|
+
existing_helpers: list[int],
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Create peer channels between *new_helper* and each existing sibling.
|
|
225
|
+
|
|
226
|
+
For each pair (A, B) where A < B, creates:
|
|
227
|
+
- ``A:B-ro`` directory (A writes, B reads)
|
|
228
|
+
- ``B:A-ro`` directory (B writes, A reads)
|
|
229
|
+
- ``A:B-rw`` directory (shared read-write, owned by lower number)
|
|
230
|
+
|
|
231
|
+
The directories are created under ``helpers_dir`` and symlinked into
|
|
232
|
+
each helper's ``peers/`` directory.
|
|
233
|
+
"""
|
|
234
|
+
channels_dir = helpers_dir / "channels"
|
|
235
|
+
channels_dir.mkdir(exist_ok=True)
|
|
236
|
+
|
|
237
|
+
for existing in existing_helpers:
|
|
238
|
+
lower = min(new_helper, existing)
|
|
239
|
+
higher = max(new_helper, existing)
|
|
240
|
+
|
|
241
|
+
# Create the three channel directories
|
|
242
|
+
ro_low_high = channels_dir / f"{lower}:{higher}-ro"
|
|
243
|
+
ro_high_low = channels_dir / f"{higher}:{lower}-ro"
|
|
244
|
+
rw_shared = channels_dir / f"{lower}:{higher}-rw"
|
|
245
|
+
|
|
246
|
+
ro_low_high.mkdir(exist_ok=True)
|
|
247
|
+
ro_high_low.mkdir(exist_ok=True)
|
|
248
|
+
rw_shared.mkdir(exist_ok=True)
|
|
249
|
+
|
|
250
|
+
# Symlink into each helper's peers/
|
|
251
|
+
_link_peer(helpers_dir, lower, f"{lower}:{higher}-ro", ro_low_high)
|
|
252
|
+
_link_peer(helpers_dir, lower, f"{higher}:{lower}-ro", ro_high_low)
|
|
253
|
+
_link_peer(helpers_dir, lower, f"{lower}:{higher}-rw", rw_shared)
|
|
254
|
+
|
|
255
|
+
_link_peer(helpers_dir, higher, f"{lower}:{higher}-ro", ro_low_high)
|
|
256
|
+
_link_peer(helpers_dir, higher, f"{higher}:{lower}-ro", ro_high_low)
|
|
257
|
+
_link_peer(helpers_dir, higher, f"{lower}:{higher}-rw", rw_shared)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _link_peer(helpers_dir: Path, helper_num: int, name: str, target: Path) -> None:
|
|
261
|
+
"""Create a symlink in helper's peers/ pointing to a channel directory."""
|
|
262
|
+
link = helpers_dir / str(helper_num) / "peers" / name
|
|
263
|
+
if not link.exists():
|
|
264
|
+
link.symlink_to(target.resolve())
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def link_broadcast(helpers_dir: Path, helper_num: int) -> None:
|
|
268
|
+
"""Create an ``all`` symlink in a helper's filesystem pointing to broadcast dirs."""
|
|
269
|
+
all_dir = helpers_dir / "all"
|
|
270
|
+
link = helpers_dir / str(helper_num) / "all"
|
|
271
|
+
if not link.exists():
|
|
272
|
+
link.symlink_to(all_dir.resolve())
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def remove_helper_dirs(
|
|
276
|
+
helpers_dir: Path,
|
|
277
|
+
helper_num: int,
|
|
278
|
+
sibling_helpers: list[int],
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Remove a helper's directory tree and clean up its peer channels.
|
|
281
|
+
|
|
282
|
+
Removes:
|
|
283
|
+
- The helper's root directory (``helpers/{N}/``)
|
|
284
|
+
- Channel directories involving this helper
|
|
285
|
+
- Peer symlinks in siblings that pointed to removed channels
|
|
286
|
+
"""
|
|
287
|
+
import shutil
|
|
288
|
+
|
|
289
|
+
# Remove peer symlinks in siblings and channel dirs
|
|
290
|
+
channels_dir = helpers_dir / "channels"
|
|
291
|
+
for sibling in sibling_helpers:
|
|
292
|
+
lower = min(helper_num, sibling)
|
|
293
|
+
higher = max(helper_num, sibling)
|
|
294
|
+
channel_names = [
|
|
295
|
+
f"{lower}:{higher}-ro",
|
|
296
|
+
f"{higher}:{lower}-ro",
|
|
297
|
+
f"{lower}:{higher}-rw",
|
|
298
|
+
]
|
|
299
|
+
# Remove symlinks from the sibling's peers/
|
|
300
|
+
for name in channel_names:
|
|
301
|
+
link = helpers_dir / str(sibling) / "peers" / name
|
|
302
|
+
if link.is_symlink():
|
|
303
|
+
link.unlink()
|
|
304
|
+
# Remove channel directories
|
|
305
|
+
for name in channel_names:
|
|
306
|
+
chan = channels_dir / name
|
|
307
|
+
if chan.exists():
|
|
308
|
+
shutil.rmtree(chan)
|
|
309
|
+
|
|
310
|
+
# Remove the helper's root directory
|
|
311
|
+
helper_root = helpers_dir / str(helper_num)
|
|
312
|
+
if helper_root.exists():
|
|
313
|
+
shutil.rmtree(helper_root)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# helper-init.sh template
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
_INIT_SCRIPT_NAME = "helper-init.sh"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def bundled_init_script() -> Path:
|
|
324
|
+
"""Return the path to the bundled default ``helper-init.sh``."""
|
|
325
|
+
resource = importlib.resources.files("kanibako.scripts").joinpath(_INIT_SCRIPT_NAME)
|
|
326
|
+
return Path(str(resource))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def resolve_init_script(parent_scripts_dir: Path | None) -> Path:
|
|
330
|
+
"""Return the init script to use for helpers.
|
|
331
|
+
|
|
332
|
+
Checks the parent's ``playbook/scripts/`` for a custom version first,
|
|
333
|
+
then falls back to the bundled default.
|
|
334
|
+
"""
|
|
335
|
+
if parent_scripts_dir is not None:
|
|
336
|
+
custom = parent_scripts_dir / _INIT_SCRIPT_NAME
|
|
337
|
+
if custom.is_file():
|
|
338
|
+
return custom
|
|
339
|
+
return bundled_init_script()
|
kanibako/hygiene.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Shell directory cleanup: remove waste files, compress old conversation logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from kanibako.log import get_logger
|
|
12
|
+
|
|
13
|
+
# Directories whose *contents* are always safe to delete.
|
|
14
|
+
_WASTE_DIRS = (
|
|
15
|
+
".claude/telemetry",
|
|
16
|
+
".claude/debug",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Subdirectories under .cache/ that are safe to purge.
|
|
20
|
+
# We intentionally keep pip, uv, npm, etc. — only remove known-waste dirs.
|
|
21
|
+
_CACHE_WASTE_DIRS = (
|
|
22
|
+
".cache/claude",
|
|
23
|
+
".cache/sentry",
|
|
24
|
+
".cache/@anthropic",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Files older than this many days get compressed (conversation logs).
|
|
28
|
+
_COMPRESS_AGE_DAYS = 7
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cleanup_shell_dir(
|
|
32
|
+
shell_dir: Path,
|
|
33
|
+
dry_run: bool = False,
|
|
34
|
+
) -> list[str]:
|
|
35
|
+
"""Remove stale/waste files from a persistent shell directory.
|
|
36
|
+
|
|
37
|
+
Returns a list of human-readable action strings describing what was
|
|
38
|
+
(or would be, in dry_run mode) cleaned up. The list is empty when
|
|
39
|
+
there is nothing to do.
|
|
40
|
+
"""
|
|
41
|
+
logger = get_logger("hygiene")
|
|
42
|
+
actions: list[str] = []
|
|
43
|
+
|
|
44
|
+
if not shell_dir.is_dir():
|
|
45
|
+
return actions
|
|
46
|
+
|
|
47
|
+
# 1. Delete known waste directories.
|
|
48
|
+
actions.extend(_clean_waste_dirs(shell_dir, dry_run, logger))
|
|
49
|
+
|
|
50
|
+
# 2. Delete .cache waste subdirectories.
|
|
51
|
+
actions.extend(_clean_cache_waste(shell_dir, dry_run, logger))
|
|
52
|
+
|
|
53
|
+
# 3. Remove duplicate claude binaries outside .local/.
|
|
54
|
+
actions.extend(_clean_duplicate_binaries(shell_dir, dry_run, logger))
|
|
55
|
+
|
|
56
|
+
# 4. Compress old conversation logs.
|
|
57
|
+
actions.extend(_compress_old_logs(shell_dir, dry_run, logger))
|
|
58
|
+
|
|
59
|
+
if actions:
|
|
60
|
+
total = len(actions)
|
|
61
|
+
prefix = "[dry-run] " if dry_run else ""
|
|
62
|
+
logger.info("%sHygiene: %d action(s) taken", prefix, total)
|
|
63
|
+
|
|
64
|
+
return actions
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _clean_waste_dirs(
|
|
68
|
+
shell_dir: Path,
|
|
69
|
+
dry_run: bool,
|
|
70
|
+
logger: object,
|
|
71
|
+
) -> list[str]:
|
|
72
|
+
"""Delete contents of known waste directories."""
|
|
73
|
+
actions: list[str] = []
|
|
74
|
+
for rel in _WASTE_DIRS:
|
|
75
|
+
target = shell_dir / rel
|
|
76
|
+
if not target.is_dir():
|
|
77
|
+
continue
|
|
78
|
+
freed = _dir_size(target)
|
|
79
|
+
if freed == 0:
|
|
80
|
+
continue
|
|
81
|
+
desc = (
|
|
82
|
+
f"{'[dry-run] ' if dry_run else ''}"
|
|
83
|
+
f"Removed {rel}/ contents ({_fmt_size(freed)})"
|
|
84
|
+
)
|
|
85
|
+
if not dry_run:
|
|
86
|
+
_remove_dir_contents(target)
|
|
87
|
+
actions.append(desc)
|
|
88
|
+
return actions
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _clean_cache_waste(
|
|
92
|
+
shell_dir: Path,
|
|
93
|
+
dry_run: bool,
|
|
94
|
+
logger: object,
|
|
95
|
+
) -> list[str]:
|
|
96
|
+
"""Delete waste subdirectories under .cache/."""
|
|
97
|
+
actions: list[str] = []
|
|
98
|
+
for rel in _CACHE_WASTE_DIRS:
|
|
99
|
+
target = shell_dir / rel
|
|
100
|
+
if not target.is_dir():
|
|
101
|
+
continue
|
|
102
|
+
freed = _dir_size(target)
|
|
103
|
+
if freed == 0:
|
|
104
|
+
continue
|
|
105
|
+
desc = (
|
|
106
|
+
f"{'[dry-run] ' if dry_run else ''}"
|
|
107
|
+
f"Removed {rel}/ ({_fmt_size(freed)})"
|
|
108
|
+
)
|
|
109
|
+
if not dry_run:
|
|
110
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
111
|
+
actions.append(desc)
|
|
112
|
+
return actions
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _clean_duplicate_binaries(
|
|
116
|
+
shell_dir: Path,
|
|
117
|
+
dry_run: bool,
|
|
118
|
+
logger: object,
|
|
119
|
+
) -> list[str]:
|
|
120
|
+
"""Remove claude binary copies outside .local/.
|
|
121
|
+
|
|
122
|
+
The legitimate binary lives at .local/bin/claude (bind-mounted from
|
|
123
|
+
host). Anything else that looks like a large claude binary is waste
|
|
124
|
+
— typically 200+ MB copies left by install scripts or updates.
|
|
125
|
+
"""
|
|
126
|
+
actions: list[str] = []
|
|
127
|
+
# Minimum size to consider: 100 MB (real binary is ~227 MB).
|
|
128
|
+
min_size = 100 * 1024 * 1024
|
|
129
|
+
|
|
130
|
+
for candidate in _find_claude_binaries(shell_dir):
|
|
131
|
+
# Skip the legitimate location.
|
|
132
|
+
try:
|
|
133
|
+
rel = candidate.relative_to(shell_dir / ".local")
|
|
134
|
+
# It's under .local — leave it alone.
|
|
135
|
+
_ = rel
|
|
136
|
+
continue
|
|
137
|
+
except ValueError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
size = candidate.stat().st_size
|
|
142
|
+
except OSError:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
if size < min_size:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
rel_path = candidate.relative_to(shell_dir)
|
|
149
|
+
desc = (
|
|
150
|
+
f"{'[dry-run] ' if dry_run else ''}"
|
|
151
|
+
f"Removed duplicate binary {rel_path} ({_fmt_size(size)})"
|
|
152
|
+
)
|
|
153
|
+
if not dry_run:
|
|
154
|
+
try:
|
|
155
|
+
candidate.unlink()
|
|
156
|
+
except OSError:
|
|
157
|
+
continue
|
|
158
|
+
actions.append(desc)
|
|
159
|
+
|
|
160
|
+
return actions
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _find_claude_binaries(shell_dir: Path) -> list[Path]:
|
|
164
|
+
"""Find files named 'claude' that look like binaries in shell_dir.
|
|
165
|
+
|
|
166
|
+
Only walks directories that are likely to contain stray copies:
|
|
167
|
+
the top level and a few common subdirectories. Does NOT recurse
|
|
168
|
+
the entire tree (that would be too slow).
|
|
169
|
+
"""
|
|
170
|
+
candidates: list[Path] = []
|
|
171
|
+
|
|
172
|
+
# Check top-level and common locations for stray binaries.
|
|
173
|
+
search_dirs = [
|
|
174
|
+
shell_dir,
|
|
175
|
+
shell_dir / ".claude" / "bin",
|
|
176
|
+
shell_dir / "bin",
|
|
177
|
+
shell_dir / ".bin",
|
|
178
|
+
shell_dir / ".npm" / "_npx",
|
|
179
|
+
]
|
|
180
|
+
for d in search_dirs:
|
|
181
|
+
if not d.is_dir():
|
|
182
|
+
continue
|
|
183
|
+
claude_file = d / "claude"
|
|
184
|
+
if claude_file.is_file() and not claude_file.is_symlink():
|
|
185
|
+
candidates.append(claude_file)
|
|
186
|
+
|
|
187
|
+
return candidates
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _compress_old_logs(
|
|
191
|
+
shell_dir: Path,
|
|
192
|
+
dry_run: bool,
|
|
193
|
+
logger: object,
|
|
194
|
+
) -> list[str]:
|
|
195
|
+
"""Gzip conversation logs older than _COMPRESS_AGE_DAYS.
|
|
196
|
+
|
|
197
|
+
Looks for .jsonl files under .claude/projects/*/conversation_logs/.
|
|
198
|
+
"""
|
|
199
|
+
actions: list[str] = []
|
|
200
|
+
cutoff = time.time() - (_COMPRESS_AGE_DAYS * 86400)
|
|
201
|
+
|
|
202
|
+
projects_dir = shell_dir / ".claude" / "projects"
|
|
203
|
+
if not projects_dir.is_dir():
|
|
204
|
+
return actions
|
|
205
|
+
|
|
206
|
+
# Glob for conversation log files.
|
|
207
|
+
for log_file in projects_dir.glob("*/conversation_logs/*.jsonl"):
|
|
208
|
+
if not log_file.is_file():
|
|
209
|
+
continue
|
|
210
|
+
# Already compressed?
|
|
211
|
+
if log_file.suffix == ".gz":
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
mtime = log_file.stat().st_mtime
|
|
216
|
+
except OSError:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
if mtime >= cutoff:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
original_size = log_file.stat().st_size
|
|
223
|
+
if original_size == 0:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
rel_path = log_file.relative_to(shell_dir)
|
|
227
|
+
gz_path = log_file.with_suffix(log_file.suffix + ".gz")
|
|
228
|
+
|
|
229
|
+
if not dry_run:
|
|
230
|
+
try:
|
|
231
|
+
_gzip_file(log_file, gz_path)
|
|
232
|
+
compressed_size = gz_path.stat().st_size
|
|
233
|
+
except OSError:
|
|
234
|
+
continue
|
|
235
|
+
else:
|
|
236
|
+
compressed_size = original_size # estimate unavailable in dry-run
|
|
237
|
+
|
|
238
|
+
desc = (
|
|
239
|
+
f"{'[dry-run] ' if dry_run else ''}"
|
|
240
|
+
f"Compressed {rel_path} ({_fmt_size(original_size)}"
|
|
241
|
+
)
|
|
242
|
+
if not dry_run:
|
|
243
|
+
desc += f" -> {_fmt_size(compressed_size)}"
|
|
244
|
+
desc += ")"
|
|
245
|
+
actions.append(desc)
|
|
246
|
+
|
|
247
|
+
return actions
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _gzip_file(src: Path, dst: Path) -> None:
|
|
251
|
+
"""Compress *src* to *dst* with gzip and remove the original."""
|
|
252
|
+
with open(src, "rb") as f_in, gzip.open(dst, "wb") as f_out:
|
|
253
|
+
shutil.copyfileobj(f_in, f_out)
|
|
254
|
+
# Preserve modification time on the compressed file.
|
|
255
|
+
stat = src.stat()
|
|
256
|
+
os.utime(dst, (stat.st_atime, stat.st_mtime))
|
|
257
|
+
src.unlink()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _remove_dir_contents(d: Path) -> None:
|
|
261
|
+
"""Remove all entries inside *d* without removing *d* itself."""
|
|
262
|
+
for entry in d.iterdir():
|
|
263
|
+
if entry.is_dir() and not entry.is_symlink():
|
|
264
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
265
|
+
else:
|
|
266
|
+
try:
|
|
267
|
+
entry.unlink()
|
|
268
|
+
except OSError:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _dir_size(d: Path) -> int:
|
|
273
|
+
"""Return total size of all files under *d* (non-recursive symlink-safe)."""
|
|
274
|
+
total = 0
|
|
275
|
+
try:
|
|
276
|
+
for entry in d.rglob("*"):
|
|
277
|
+
if entry.is_file() and not entry.is_symlink():
|
|
278
|
+
try:
|
|
279
|
+
total += entry.stat().st_size
|
|
280
|
+
except OSError:
|
|
281
|
+
pass
|
|
282
|
+
except OSError:
|
|
283
|
+
pass
|
|
284
|
+
return total
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _fmt_size(nbytes: int) -> str:
|
|
288
|
+
"""Format byte count as a human-readable string."""
|
|
289
|
+
if nbytes < 1024:
|
|
290
|
+
return f"{nbytes} B"
|
|
291
|
+
elif nbytes < 1024 * 1024:
|
|
292
|
+
return f"{nbytes / 1024:.1f} KB"
|
|
293
|
+
elif nbytes < 1024 * 1024 * 1024:
|
|
294
|
+
return f"{nbytes / (1024 * 1024):.1f} MB"
|
|
295
|
+
else:
|
|
296
|
+
return f"{nbytes / (1024 * 1024 * 1024):.1f} GB"
|