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
@@ -0,0 +1,224 @@
1
+ """Template image management: create, list, delete user templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import re
7
+ from pathlib import Path
8
+ from typing import NamedTuple, TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from kanibako.container import ContainerRuntime
12
+
13
+ _TEMPLATE_PREFIX = "kanibako-template-"
14
+ _RIG_PREFIX = "kanibako-rig-"
15
+ _VALID_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
16
+
17
+ # Bundled template Containerfiles follow this naming convention. Only files
18
+ # named exactly ``Containerfile.template-<name>`` (with a valid <name>) are
19
+ # treated as shipped templates -- this excludes ``Containerfile.kanibako``
20
+ # (the buildable base) and any non-matching files for free.
21
+ _TEMPLATE_FILE_PREFIX = "Containerfile.template-"
22
+
23
+ # Optional description header inside a template Containerfile, e.g.
24
+ # # kanibako-template: Java, Kotlin, Maven (JVM toolchain)
25
+ _DESC_HEADER_RE = re.compile(r"^#\s*kanibako-template:\s*(.+?)\s*$")
26
+
27
+ # Read at most this many lines looking for the description header.
28
+ _DESC_HEADER_SCAN_LINES = 10
29
+
30
+ # Optional smoke-check header(s) inside a template Containerfile, e.g.
31
+ # # kanibako-template-check: java -version
32
+ # Zero or more per file; each captured command is one non-interactive smoke
33
+ # test that must exit 0 in the built image.
34
+ _CHECK_HEADER_RE = re.compile(r"^#\s*kanibako-template-check:\s*(.+?)\s*$")
35
+
36
+ # Backstop: never scan past this many lines looking for check headers.
37
+ _CHECK_HEADER_SCAN_LINES = 30
38
+
39
+
40
+ def validate_template_name(name: str) -> None:
41
+ """Raise *ValueError* if *name* contains invalid characters.
42
+
43
+ Template names must start with a lowercase letter or digit and contain
44
+ only lowercase letters, digits, hyphens, and underscores.
45
+ """
46
+ if not _VALID_NAME_RE.match(name):
47
+ raise ValueError(
48
+ f"Invalid template name '{name}': must contain only lowercase "
49
+ "letters, digits, hyphens, and underscores, and must start with "
50
+ "a letter or digit."
51
+ )
52
+
53
+
54
+ def template_image_name(name: str) -> str:
55
+ """Return the OCI image name for a template.
56
+
57
+ Raises *ValueError* if *name* is invalid.
58
+ """
59
+ validate_template_name(name)
60
+ return f"{_TEMPLATE_PREFIX}{name}"
61
+
62
+
63
+ def rig_image_name(name: str) -> str:
64
+ """Return the OCI image name for an *extended* rig.
65
+
66
+ Extended rigs (interactively built, non-reproducible) live under the
67
+ ``kanibako-rig-`` prefix, distinct from the ``kanibako-template-`` prefix
68
+ used for buildable templates. Validates *name* the same way
69
+ :func:`template_image_name` does.
70
+
71
+ Raises *ValueError* if *name* is invalid.
72
+ """
73
+ validate_template_name(name)
74
+ return f"{_RIG_PREFIX}{name}"
75
+
76
+
77
+ class BundledTemplate(NamedTuple):
78
+ """A template Containerfile available to kanibako.
79
+
80
+ *source* is ``"bundled"`` for templates shipped with kanibako and
81
+ ``"user"`` for templates dropped into the user-override directory.
82
+ """
83
+
84
+ name: str
85
+ description: str
86
+ source: str = "bundled"
87
+
88
+
89
+ def _bundled_containers_dir() -> Path | None:
90
+ """Return the path to kanibako's shipped ``containers/`` directory.
91
+
92
+ Returns ``None`` if the package data cannot be resolved as a real
93
+ filesystem path (e.g. running from a zip import).
94
+ """
95
+ try:
96
+ traversable = importlib.resources.files("kanibako.containers")
97
+ path = Path(str(traversable))
98
+ except (TypeError, FileNotFoundError):
99
+ return None
100
+ return path if path.is_dir() else None
101
+
102
+
103
+ def _read_template_description(containerfile: Path, name: str) -> str:
104
+ """Return the ``# kanibako-template:`` header text, or a fallback label."""
105
+ try:
106
+ with containerfile.open(encoding="utf-8") as fh:
107
+ for _, line in zip(range(_DESC_HEADER_SCAN_LINES), fh):
108
+ match = _DESC_HEADER_RE.match(line)
109
+ if match:
110
+ return match.group(1)
111
+ except OSError:
112
+ pass
113
+ return f"{name} template"
114
+
115
+
116
+ def read_template_checks(containerfile: Path) -> tuple[str, ...]:
117
+ """Return the ``# kanibako-template-check:`` commands from *containerfile*.
118
+
119
+ Each ``# kanibako-template-check: <command>`` header in the file's leading
120
+ comment block declares one non-interactive smoke command (expected to exit
121
+ 0 in the built image). Commands are returned in file order so they can be
122
+ run top-to-bottom.
123
+
124
+ Only the leading comment block is scanned: scanning starts at line 1 and
125
+ stops at the first line that is neither blank nor a ``#`` comment (i.e. the
126
+ first directive such as ``ARG``/``FROM``). As a backstop the scan also
127
+ stops after :data:`_CHECK_HEADER_SCAN_LINES` lines. Returns an empty tuple
128
+ when no check headers are present or the file cannot be read.
129
+ """
130
+ checks: list[str] = []
131
+ try:
132
+ with containerfile.open(encoding="utf-8") as fh:
133
+ for _, line in zip(range(_CHECK_HEADER_SCAN_LINES), fh):
134
+ stripped = line.strip()
135
+ if stripped and not stripped.startswith("#"):
136
+ break
137
+ match = _CHECK_HEADER_RE.match(line)
138
+ if match:
139
+ checks.append(match.group(1))
140
+ except OSError:
141
+ return ()
142
+ return tuple(checks)
143
+
144
+
145
+ def _scan_template_dir(
146
+ containers_dir: Path | None, source: str
147
+ ) -> list[BundledTemplate]:
148
+ """Scan *containers_dir* for ``Containerfile.template-<name>`` files.
149
+
150
+ Returns a list of :class:`BundledTemplate` tagged with *source*. Invalid
151
+ names and non-matching files are skipped. Order is the raw iteration order
152
+ (the caller is responsible for any sorting/merging).
153
+ """
154
+ if containers_dir is None or not containers_dir.is_dir():
155
+ return []
156
+
157
+ templates: list[BundledTemplate] = []
158
+ for entry in containers_dir.iterdir():
159
+ if not entry.is_file():
160
+ continue
161
+ if not entry.name.startswith(_TEMPLATE_FILE_PREFIX):
162
+ continue
163
+ name = entry.name[len(_TEMPLATE_FILE_PREFIX):]
164
+ if not _VALID_NAME_RE.match(name):
165
+ continue
166
+ description = _read_template_description(entry, name)
167
+ templates.append(
168
+ BundledTemplate(name=name, description=description, source=source)
169
+ )
170
+ return templates
171
+
172
+
173
+ def list_bundled_templates(
174
+ containers_dir: Path | None = None,
175
+ *,
176
+ override_dir: Path | None = None,
177
+ ) -> list[BundledTemplate]:
178
+ """Discover template Containerfiles, merging bundled and user overrides.
179
+
180
+ Scans *containers_dir* (defaulting to kanibako's bundled ``containers/``
181
+ directory) non-recursively for files named exactly
182
+ ``Containerfile.template-<name>`` where ``<name>`` is a valid template
183
+ name (source ``"bundled"``). The ``archive/`` subdirectory and
184
+ non-matching files (such as ``Containerfile.kanibako``) are excluded.
185
+
186
+ If *override_dir* is a directory, it is scanned the same way for
187
+ user-dropped templates (source ``"user"``). A user template with the same
188
+ ``<name>`` as a bundled one *overrides* it -- mirroring
189
+ :func:`kanibako.containerfiles.get_containerfile`'s override-first
190
+ precedence -- so the result carries the user file's description and
191
+ ``source="user"``.
192
+
193
+ Each template's description is taken from a ``# kanibako-template: <desc>``
194
+ header comment near the top of the file, falling back to ``"<name>
195
+ template"`` when absent. Results are sorted by name.
196
+ """
197
+ if containers_dir is None:
198
+ containers_dir = _bundled_containers_dir()
199
+
200
+ merged: dict[str, BundledTemplate] = {}
201
+ for tmpl in _scan_template_dir(containers_dir, "bundled"):
202
+ merged[tmpl.name] = tmpl
203
+ for tmpl in _scan_template_dir(override_dir, "user"):
204
+ merged[tmpl.name] = tmpl
205
+
206
+ return sorted(merged.values(), key=lambda t: t.name)
207
+
208
+
209
+ def list_templates(runtime: ContainerRuntime) -> list[tuple[str, str, str]]:
210
+ """Return (short_name, full_image, size) for all local template images."""
211
+ images = runtime.list_local_images()
212
+ result = []
213
+ for repo, size in images:
214
+ # Strip tag if present for matching
215
+ bare = repo.split(":")[0] if ":" in repo else repo
216
+ if bare.startswith(_TEMPLATE_PREFIX):
217
+ short = bare[len(_TEMPLATE_PREFIX):]
218
+ result.append((short, bare, size))
219
+ return result
220
+
221
+
222
+ def delete_template(runtime: ContainerRuntime, name: str) -> None:
223
+ """Delete a template image by short name."""
224
+ runtime.remove_image(template_image_name(name))
kanibako/tweakcc.py ADDED
@@ -0,0 +1,140 @@
1
+ """tweakcc integration: config merging and patched binary caching.
2
+
3
+ tweakcc customises Claude Code by patching its cli.js bundle. Kanibako
4
+ manages the patching lifecycle — config merging, binary caching on tmpfs,
5
+ flock-based reference counting — so that patched variants are transparent
6
+ to the user and shared across projects with identical configs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from kanibako.log import get_logger
16
+
17
+ logger = get_logger("tweakcc")
18
+
19
+
20
+ @dataclass
21
+ class TweakccConfig:
22
+ """Resolved tweakcc configuration for a launch.
23
+
24
+ Produced by merging agent-level defaults, an optional external config
25
+ file, and per-project inline overrides.
26
+ """
27
+
28
+ enabled: bool = False
29
+ config_path: str | None = None # path to external tweakcc config.json
30
+ overrides: dict = field(default_factory=dict) # inline [tweakcc] overrides
31
+
32
+
33
+ def load_tweakcc_section(data: dict) -> dict:
34
+ """Extract the ``[tweakcc]`` section from parsed TOML data.
35
+
36
+ Returns a plain dict. Missing section → empty dict.
37
+ """
38
+ return dict(data.get("tweakcc", {}))
39
+
40
+
41
+ def resolve_tweakcc_config(
42
+ agent_tweakcc: dict,
43
+ project_tweakcc: dict | None = None,
44
+ ) -> TweakccConfig:
45
+ """Merge agent and project tweakcc sections into a resolved config.
46
+
47
+ Resolution order (highest wins):
48
+ 1. Project ``[tweakcc]`` overrides
49
+ 2. Agent ``[tweakcc]`` defaults
50
+
51
+ The ``enabled`` and ``config`` keys are extracted; everything else is
52
+ treated as inline overrides passed through to tweakcc.
53
+ """
54
+ merged = dict(agent_tweakcc)
55
+ if project_tweakcc:
56
+ merged.update(project_tweakcc)
57
+
58
+ enabled = bool(merged.pop("enabled", False))
59
+ config_path = merged.pop("config", None)
60
+ if config_path is not None:
61
+ config_path = str(config_path)
62
+
63
+ return TweakccConfig(
64
+ enabled=enabled,
65
+ config_path=config_path,
66
+ overrides=merged,
67
+ )
68
+
69
+
70
+ def load_external_config(config_path: str | None) -> dict:
71
+ """Load an external tweakcc config.json file.
72
+
73
+ Returns empty dict if *config_path* is None or the file doesn't exist.
74
+ """
75
+ if not config_path:
76
+ return {}
77
+
78
+ path = Path(config_path).expanduser()
79
+ if not path.is_file():
80
+ logger.debug("External tweakcc config not found: %s", path)
81
+ return {}
82
+
83
+ try:
84
+ with open(path) as f:
85
+ data = json.load(f)
86
+ logger.debug("Loaded external tweakcc config: %s", path)
87
+ return data if isinstance(data, dict) else {}
88
+ except (json.JSONDecodeError, OSError) as e:
89
+ logger.warning("Failed to load tweakcc config %s: %s", path, e)
90
+ return {}
91
+
92
+
93
+ def _deep_merge(base: dict, override: dict) -> dict:
94
+ """Recursively merge *override* into *base*, returning a new dict."""
95
+ result = dict(base)
96
+ for key, val in override.items():
97
+ if key in result and isinstance(result[key], dict) and isinstance(val, dict):
98
+ result[key] = _deep_merge(result[key], val)
99
+ else:
100
+ result[key] = val
101
+ return result
102
+
103
+
104
+ def build_merged_config(
105
+ tweakcc_cfg: TweakccConfig,
106
+ kanibako_defaults: dict | None = None,
107
+ ) -> dict:
108
+ """Build the final tweakcc config dict for ``tweakcc --apply``.
109
+
110
+ Merge order (highest wins):
111
+ 1. Inline overrides from ``tweakcc_cfg.overrides``
112
+ 2. External config file (``tweakcc_cfg.config_path``)
113
+ 3. Kanibako's own defaults (``kanibako_defaults``)
114
+
115
+ Returns a dict ready to be serialized as config.json.
116
+ """
117
+ result: dict = {}
118
+
119
+ # Layer 1: kanibako defaults (lowest priority)
120
+ if kanibako_defaults:
121
+ result = _deep_merge(result, kanibako_defaults)
122
+
123
+ # Layer 2: external config file
124
+ external = load_external_config(tweakcc_cfg.config_path)
125
+ if external:
126
+ result = _deep_merge(result, external)
127
+
128
+ # Layer 3: inline overrides (highest priority)
129
+ if tweakcc_cfg.overrides:
130
+ result = _deep_merge(result, tweakcc_cfg.overrides)
131
+
132
+ return result
133
+
134
+
135
+ def write_merged_config(config: dict, output_path: Path) -> None:
136
+ """Write the merged tweakcc config to a JSON file."""
137
+ output_path.parent.mkdir(parents=True, exist_ok=True)
138
+ with open(output_path, "w") as f:
139
+ json.dump(config, f, indent=2)
140
+ logger.debug("Wrote merged tweakcc config: %s", output_path)
@@ -0,0 +1,171 @@
1
+ """tweakcc binary cache with flock-based reference counting.
2
+
3
+ Patched Claude Code binaries are cached to avoid redundant tweakcc runs.
4
+ The cache directory can be on tmpfs (for RAM speed and auto-cleanup on
5
+ reboot) or regular disk — kanibako doesn't care.
6
+
7
+ Usage::
8
+
9
+ cache = TweakccCache(Path("~/.cache/kanibako/tweakcc"))
10
+ key = cache.cache_key(binary_hash, cfg_hash)
11
+ entry = cache.get(key)
12
+ if entry is None:
13
+ def patch_fn(staging_dir, binary_path):
14
+ subprocess.run(["podman", "run", "--rm", ...], check=True)
15
+ entry = cache.put(key, source_binary, patch_fn)
16
+ # ... use entry.path as the patched binary ...
17
+ cache.release(entry)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import fcntl
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import shutil
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+ from kanibako.log import get_logger
32
+
33
+ logger = get_logger("tweakcc_cache")
34
+
35
+
36
+ class TweakccCacheError(Exception):
37
+ """Error during tweakcc cache operations."""
38
+
39
+
40
+ @dataclass
41
+ class CacheEntry:
42
+ """A locked reference to a cached patched binary."""
43
+
44
+ path: Path
45
+ fd: int
46
+
47
+
48
+ def config_hash(config: dict) -> str:
49
+ """SHA-256 hex digest of a config dict (deterministic JSON)."""
50
+ serialized = json.dumps(config, sort_keys=True, separators=(",", ":")).encode()
51
+ return hashlib.sha256(serialized).hexdigest()
52
+
53
+
54
+ class TweakccCache:
55
+ """Cache for tweakcc-patched binaries with flock reference counting.
56
+
57
+ Each cached binary is identified by a key derived from the source
58
+ binary's cli.js hash and the tweakcc config hash. Active users hold
59
+ shared flocks; cleanup happens when no shared locks remain.
60
+ """
61
+
62
+ def __init__(self, cache_dir: Path) -> None:
63
+ self.cache_dir = cache_dir
64
+
65
+ def ensure_dir(self) -> None:
66
+ """Create the cache directory if it doesn't exist."""
67
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
68
+
69
+ def cache_key(self, cli_js_hash: str, cfg_hash: str) -> str:
70
+ """Derive a short cache key from binary and config hashes."""
71
+ combined = f"{cli_js_hash}:{cfg_hash}"
72
+ return hashlib.sha256(combined.encode()).hexdigest()[:16]
73
+
74
+ def _entry_path(self, key: str) -> Path:
75
+ return self.cache_dir / key
76
+
77
+ def get(self, key: str) -> CacheEntry | None:
78
+ """Look up a cached binary and acquire a shared lock.
79
+
80
+ Returns *None* on cache miss (file missing or unlockable).
81
+ """
82
+ path = self._entry_path(key)
83
+ try:
84
+ fd = os.open(str(path), os.O_RDONLY)
85
+ except FileNotFoundError:
86
+ return None
87
+
88
+ try:
89
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
90
+ logger.debug("Cache hit: %s", key)
91
+ return CacheEntry(path=path, fd=fd)
92
+ except OSError:
93
+ os.close(fd)
94
+ return None
95
+
96
+ def put(
97
+ self,
98
+ key: str,
99
+ source_binary: Path,
100
+ patch_fn: Callable[[Path, Path], None],
101
+ ) -> CacheEntry:
102
+ """Copy *source_binary* to a staging dir, patch it, cache the result.
103
+
104
+ *patch_fn(staging_dir, binary_path)* is called with the staging
105
+ directory and the path to the copied binary within it. The callable
106
+ must modify the binary in-place (it may create temp files in
107
+ *staging_dir*).
108
+
109
+ Raises :class:`TweakccCacheError` if *patch_fn* raises.
110
+ """
111
+ self.ensure_dir()
112
+ entry_path = self._entry_path(key)
113
+ staging_dir = self.cache_dir / f".staging-{key}-{os.getpid()}"
114
+
115
+ try:
116
+ staging_dir.mkdir(parents=True)
117
+ staging_binary = staging_dir / source_binary.name
118
+ shutil.copy2(str(source_binary), str(staging_binary))
119
+ staging_binary.chmod(0o755)
120
+
121
+ # Delegate patching to the caller-supplied function
122
+ logger.debug("Running patch_fn in staging dir: %s", staging_dir)
123
+ patch_fn(staging_dir, staging_binary)
124
+
125
+ # Move patched binary to cache entry
126
+ os.rename(str(staging_binary), str(entry_path))
127
+ logger.debug("Cached patched binary: %s", key)
128
+
129
+ # Acquire shared lock on the cached entry
130
+ fd = os.open(str(entry_path), os.O_RDONLY)
131
+ fcntl.flock(fd, fcntl.LOCK_SH)
132
+ return CacheEntry(path=entry_path, fd=fd)
133
+
134
+ except TweakccCacheError:
135
+ raise
136
+ except Exception as exc:
137
+ raise TweakccCacheError(f"Cache put failed: {exc}") from exc
138
+ finally:
139
+ # Clean up staging directory
140
+ if staging_dir.exists():
141
+ shutil.rmtree(staging_dir, ignore_errors=True)
142
+
143
+ def release(self, entry: CacheEntry) -> bool:
144
+ """Release a cache entry. Unlinks the file if no other users remain.
145
+
146
+ Returns *True* if the file was unlinked, *False* otherwise.
147
+ The fd is always closed.
148
+ """
149
+ os.close(entry.fd)
150
+
151
+ # Best-effort cleanup: try exclusive lock on the file
152
+ try:
153
+ fd = os.open(str(entry.path), os.O_RDONLY)
154
+ except FileNotFoundError:
155
+ return True # already gone
156
+
157
+ try:
158
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
159
+ # We're the only one — safe to unlink
160
+ try:
161
+ os.unlink(str(entry.path))
162
+ logger.debug("Cleaned up cache entry: %s", entry.path.name)
163
+ return True
164
+ except FileNotFoundError:
165
+ return True
166
+ finally:
167
+ os.close(fd)
168
+ except OSError:
169
+ # Another process holds a lock — leave it
170
+ os.close(fd)
171
+ return False
kanibako/utils.py ADDED
@@ -0,0 +1,136 @@
1
+ """Utility functions: cp_if_newer, confirm_prompt, short_hash, path encoding, container naming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from kanibako.errors import UserCancelled
12
+
13
+ if TYPE_CHECKING:
14
+ from kanibako.paths import ProjectPaths
15
+
16
+
17
+ def cp_if_newer(src: str | os.PathLike, dst: str | os.PathLike) -> bool:
18
+ """Copy *src* to *dst* only if *src* is strictly newer (by mtime).
19
+
20
+ Creates parent directories for *dst* if needed.
21
+ Returns True if the copy was performed.
22
+ """
23
+ src_s = str(src)
24
+ dst_s = str(dst)
25
+ if not os.path.isfile(src_s):
26
+ return False
27
+ do_copy = (
28
+ not os.path.isfile(dst_s)
29
+ or os.stat(src_s).st_mtime > os.stat(dst_s).st_mtime
30
+ )
31
+ if do_copy:
32
+ os.makedirs(os.path.dirname(dst_s) or ".", exist_ok=True)
33
+ shutil.copy2(src_s, dst_s)
34
+ return do_copy
35
+
36
+
37
+ def confirm_prompt(message: str) -> None:
38
+ """Print *message*, read a line, raise UserCancelled unless it is 'yes'."""
39
+ print(message, end="", flush=True)
40
+ try:
41
+ response = input()
42
+ except (EOFError, KeyboardInterrupt):
43
+ print()
44
+ raise UserCancelled("Aborted.")
45
+ if response.strip() != "yes":
46
+ raise UserCancelled("Aborted.")
47
+
48
+
49
+ def short_hash(full_hash: str, length: int = 8) -> str:
50
+ """Return the first *length* characters of *full_hash*."""
51
+ return full_hash[:length]
52
+
53
+
54
+ def container_name_for(proj: ProjectPaths) -> str:
55
+ """Deterministic container name for a project.
56
+
57
+ - Local with name: ``kanibako-{name}``
58
+ - Local without name (legacy): ``kanibako-{short_hash}``
59
+ - Workset: ``kanibako-{short_hash}`` (name-based pending workset naming)
60
+ - Standalone: ``kanibako-ronin-{escape_path(project_path)}``
61
+ """
62
+ if proj.mode.value == "standalone":
63
+ return f"kanibako-ronin-{escape_path(str(proj.project_path))}"
64
+ if proj.name:
65
+ return f"kanibako-{proj.name}"
66
+ return f"kanibako-{short_hash(proj.project_hash)}"
67
+
68
+
69
+ def project_hash(project_path: str) -> str:
70
+ """SHA-256 hex digest of the project path string."""
71
+ return hashlib.sha256(project_path.encode()).hexdigest()
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Standalone path encoding (for container names)
76
+ # ---------------------------------------------------------------------------
77
+
78
+ _DASH_ESCAPE = "-."
79
+
80
+
81
+ def escape_path(path: str) -> str:
82
+ """Encode a filesystem path for use in container names.
83
+
84
+ - Drop leading ``/``
85
+ - Escape literal ``-`` → ``-.`` (dash-dot)
86
+ - Replace ``/`` → ``-``
87
+
88
+ Example: ``/home/user/my-project/app`` → ``home-user-my.-project-app``
89
+ """
90
+ path = path.lstrip("/")
91
+ path = path.replace("-", _DASH_ESCAPE)
92
+ path = path.replace("/", "-")
93
+ return path
94
+
95
+
96
+ def unescape_path(encoded: str) -> str:
97
+ """Decode a container-name-encoded path back to a filesystem path.
98
+
99
+ Reverses ``escape_path``: ``-.`` → ``-``, lone ``-`` → ``/``,
100
+ prepends ``/``.
101
+ """
102
+ # Use a sentinel to avoid double-replacement.
103
+ sentinel = "\x00"
104
+ result = encoded.replace(_DASH_ESCAPE, sentinel)
105
+ result = result.replace("-", "/")
106
+ result = result.replace(sentinel, "-")
107
+ return "/" + result
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Project .gitignore helper
112
+ # ---------------------------------------------------------------------------
113
+
114
+ _GITIGNORE_ENTRIES = [".kanibako/"]
115
+
116
+
117
+ def write_project_gitignore(project_path: Path) -> None:
118
+ """Append .kanibako/ to the project's root .gitignore."""
119
+ gitignore = project_path / ".gitignore"
120
+ existing = ""
121
+ if gitignore.is_file():
122
+ existing = gitignore.read_text()
123
+
124
+ lines_to_add = [
125
+ entry for entry in _GITIGNORE_ENTRIES
126
+ if entry not in existing.splitlines()
127
+ ]
128
+
129
+ if not lines_to_add:
130
+ return
131
+
132
+ with open(gitignore, "a") as f:
133
+ if existing and not existing.endswith("\n"):
134
+ f.write("\n")
135
+ for line in lines_to_add:
136
+ f.write(line + "\n")