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
|
@@ -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")
|