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.
Files changed (85) hide show
  1. kanibako/__init__.py +3 -0
  2. kanibako/__main__.py +6 -0
  3. kanibako/auth_browser.py +296 -0
  4. kanibako/auth_parser.py +51 -0
  5. kanibako/browser_sidecar.py +183 -0
  6. kanibako/browser_state.py +103 -0
  7. kanibako/bun_sea.py +144 -0
  8. kanibako/cli.py +344 -0
  9. kanibako/commands/__init__.py +0 -0
  10. kanibako/commands/archive.py +228 -0
  11. kanibako/commands/box/__init__.py +22 -0
  12. kanibako/commands/box/_duplicate.py +395 -0
  13. kanibako/commands/box/_migrate.py +574 -0
  14. kanibako/commands/box/_parser.py +1178 -0
  15. kanibako/commands/clean.py +166 -0
  16. kanibako/commands/crab_cmd.py +480 -0
  17. kanibako/commands/diagnose.py +239 -0
  18. kanibako/commands/fork_cmd.py +51 -0
  19. kanibako/commands/helper_cmd.py +669 -0
  20. kanibako/commands/image.py +1300 -0
  21. kanibako/commands/install.py +152 -0
  22. kanibako/commands/refresh_credentials.py +67 -0
  23. kanibako/commands/restore.py +298 -0
  24. kanibako/commands/setup_cmd.py +89 -0
  25. kanibako/commands/start.py +1600 -0
  26. kanibako/commands/stop.py +116 -0
  27. kanibako/commands/system_cmd.py +224 -0
  28. kanibako/commands/upgrade.py +161 -0
  29. kanibako/commands/vault_cmd.py +199 -0
  30. kanibako/commands/workset_cmd.py +552 -0
  31. kanibako/config.py +514 -0
  32. kanibako/config_interface.py +573 -0
  33. kanibako/config_io.py +36 -0
  34. kanibako/container.py +607 -0
  35. kanibako/containerfiles.py +58 -0
  36. kanibako/containers/Containerfile.kanibako +99 -0
  37. kanibako/containers/Containerfile.template-android +55 -0
  38. kanibako/containers/Containerfile.template-dotnet +29 -0
  39. kanibako/containers/Containerfile.template-js +43 -0
  40. kanibako/containers/Containerfile.template-jvm +27 -0
  41. kanibako/containers/Containerfile.template-systems +46 -0
  42. kanibako/containers/__init__.py +0 -0
  43. kanibako/crabs.py +89 -0
  44. kanibako/errors.py +33 -0
  45. kanibako/freshness.py +67 -0
  46. kanibako/git.py +114 -0
  47. kanibako/helper_client.py +132 -0
  48. kanibako/helper_listener.py +538 -0
  49. kanibako/helpers.py +339 -0
  50. kanibako/hygiene.py +296 -0
  51. kanibako/image_sharing.py +133 -0
  52. kanibako/instructions.py +160 -0
  53. kanibako/log.py +31 -0
  54. kanibako/names.py +248 -0
  55. kanibako/paths.py +1483 -0
  56. kanibako/plugins/__init__.py +10 -0
  57. kanibako/registry.py +71 -0
  58. kanibako/rig_bundle.py +121 -0
  59. kanibako/rig_meta.py +92 -0
  60. kanibako/rig_registry.py +132 -0
  61. kanibako/rig_resolve.py +182 -0
  62. kanibako/rig_source.py +245 -0
  63. kanibako/scripts/__init__.py +0 -0
  64. kanibako/scripts/helper-init.sh +45 -0
  65. kanibako/scripts/kanibako-entry +12 -0
  66. kanibako/settings_resolve.py +312 -0
  67. kanibako/settings_seeds.py +154 -0
  68. kanibako/settings_shares.py +154 -0
  69. kanibako/shellenv.py +75 -0
  70. kanibako/snapshots.py +281 -0
  71. kanibako/targets/__init__.py +173 -0
  72. kanibako/targets/base.py +243 -0
  73. kanibako/targets/no_agent.py +58 -0
  74. kanibako/templates.py +60 -0
  75. kanibako/templates_image.py +224 -0
  76. kanibako/tweakcc.py +140 -0
  77. kanibako/tweakcc_cache.py +171 -0
  78. kanibako/utils.py +136 -0
  79. kanibako/workset.py +347 -0
  80. kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
  81. kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
  82. kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
  83. kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
  84. kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
  85. 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"