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/config.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""YAML config loading, writing, defaults, and merge logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field, fields
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from kanibako.config_io import dump_doc, load_doc
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Defaults (match the old kanibako.rc values)
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
_DEFAULTS = {
|
|
17
|
+
"paths_project_toml": "project.yaml",
|
|
18
|
+
"paths_shared": "shared",
|
|
19
|
+
"paths_shell": "shell",
|
|
20
|
+
"paths_vault": "vault",
|
|
21
|
+
"box_image": "ghcr.io/doctorjei/kanibako-oci:latest",
|
|
22
|
+
"box_crab": "",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Backward-compat aliases: old field name -> new field name.
|
|
26
|
+
# Applied during load_config() so old config files still work.
|
|
27
|
+
_FIELD_ALIASES: dict[str, str] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class KanibakoConfig:
|
|
32
|
+
"""Merged configuration (hardcoded defaults < kanibako.yaml < project.yaml < CLI)."""
|
|
33
|
+
|
|
34
|
+
paths_project_toml: str = _DEFAULTS["paths_project_toml"]
|
|
35
|
+
paths_shared: str = _DEFAULTS["paths_shared"]
|
|
36
|
+
paths_shell: str = _DEFAULTS["paths_shell"]
|
|
37
|
+
paths_vault: str = _DEFAULTS["paths_vault"]
|
|
38
|
+
box_image: str = _DEFAULTS["box_image"]
|
|
39
|
+
box_crab: str = _DEFAULTS["box_crab"]
|
|
40
|
+
allow_helpers: bool = True
|
|
41
|
+
box_share_images: bool = False
|
|
42
|
+
shared_caches: dict[str, str] = field(default_factory=dict)
|
|
43
|
+
# System-level path tier: raw set-values keyed by full dotted name
|
|
44
|
+
# ("system.path.<leaf>"), read from the file's [system][path] table.
|
|
45
|
+
# System-only (never supplied by project/workset configs).
|
|
46
|
+
system_paths: dict[str, str] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _flatten_toml(data: dict, prefix: str = "") -> dict[str, object]:
|
|
50
|
+
"""Flatten nested config dict into underscore-joined keys.
|
|
51
|
+
|
|
52
|
+
``{"paths": {"boxes": "x"}}`` → ``{"paths_boxes": "x"}``
|
|
53
|
+
Booleans are preserved; other scalars are stringified.
|
|
54
|
+
"""
|
|
55
|
+
out: dict[str, object] = {}
|
|
56
|
+
for k, v in data.items():
|
|
57
|
+
key = f"{prefix}_{k}" if prefix else k
|
|
58
|
+
if isinstance(v, dict):
|
|
59
|
+
out.update(_flatten_toml(v, key))
|
|
60
|
+
elif isinstance(v, bool):
|
|
61
|
+
out[key] = v
|
|
62
|
+
else:
|
|
63
|
+
out[key] = str(v)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def config_file_path(config_home: Path) -> Path:
|
|
68
|
+
"""Return the path to kanibako.yaml, checking new then old location.
|
|
69
|
+
|
|
70
|
+
New: ``$XDG_CONFIG_HOME/kanibako.yaml``
|
|
71
|
+
Old: ``$XDG_CONFIG_HOME/kanibako/kanibako.yaml``
|
|
72
|
+
|
|
73
|
+
Returns the new path if neither exists (for first-time setup).
|
|
74
|
+
"""
|
|
75
|
+
new_path = config_home / "kanibako.yaml"
|
|
76
|
+
if new_path.exists():
|
|
77
|
+
return new_path
|
|
78
|
+
old_path = config_home / "kanibako" / "kanibako.yaml"
|
|
79
|
+
if old_path.exists():
|
|
80
|
+
return old_path
|
|
81
|
+
return new_path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def migrate_config(config_home: Path) -> Path:
|
|
85
|
+
"""Migrate config file from old location to new, if needed.
|
|
86
|
+
|
|
87
|
+
Returns the final config file path (new location).
|
|
88
|
+
Prints a notice to stderr when migration occurs.
|
|
89
|
+
"""
|
|
90
|
+
new_path = config_home / "kanibako.yaml"
|
|
91
|
+
old_path = config_home / "kanibako" / "kanibako.yaml"
|
|
92
|
+
if old_path.exists() and not new_path.exists():
|
|
93
|
+
import shutil
|
|
94
|
+
shutil.move(str(old_path), str(new_path))
|
|
95
|
+
print(
|
|
96
|
+
f"Migrated config: {old_path} → {new_path}",
|
|
97
|
+
file=sys.stderr,
|
|
98
|
+
)
|
|
99
|
+
# Remove empty old config dir if it's now empty.
|
|
100
|
+
old_dir = old_path.parent
|
|
101
|
+
try:
|
|
102
|
+
if old_dir.is_dir() and not any(old_dir.iterdir()):
|
|
103
|
+
old_dir.rmdir()
|
|
104
|
+
except OSError:
|
|
105
|
+
pass
|
|
106
|
+
return new_path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_config(path: Path) -> KanibakoConfig:
|
|
110
|
+
"""Read a single config file and return a KanibakoConfig with defaults filled in."""
|
|
111
|
+
cfg = KanibakoConfig()
|
|
112
|
+
if path.exists():
|
|
113
|
+
data = load_doc(path)
|
|
114
|
+
# Extract [shared] section before flattening (it's a key-value dict,
|
|
115
|
+
# not nested config fields).
|
|
116
|
+
shared = data.pop("shared", {})
|
|
117
|
+
# Extract the [system][path] table before flattening: these are the
|
|
118
|
+
# system-level path tier (resolver expressions), not flat fields.
|
|
119
|
+
system_path = data.get("system", {}).pop("path", {})
|
|
120
|
+
if "system" in data and not data["system"]:
|
|
121
|
+
data.pop("system")
|
|
122
|
+
cfg.system_paths = {
|
|
123
|
+
f"system.path.{k}": str(v) for k, v in system_path.items()
|
|
124
|
+
}
|
|
125
|
+
flat = _flatten_toml(data)
|
|
126
|
+
valid_keys = {fld.name for fld in fields(cfg)}
|
|
127
|
+
for k, v in flat.items():
|
|
128
|
+
# Apply backward-compat aliases.
|
|
129
|
+
k = _FIELD_ALIASES.get(k, k)
|
|
130
|
+
if k in valid_keys:
|
|
131
|
+
setattr(cfg, k, v)
|
|
132
|
+
cfg.shared_caches = {k: str(v) for k, v in shared.items()}
|
|
133
|
+
return cfg
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_merged_config(
|
|
137
|
+
global_path: Path,
|
|
138
|
+
project_path: Path | None = None,
|
|
139
|
+
*,
|
|
140
|
+
workset_path: Path | None = None,
|
|
141
|
+
cli_overrides: dict[str, str] | None = None,
|
|
142
|
+
) -> KanibakoConfig:
|
|
143
|
+
"""Load global config, overlay workset config, project config, then CLI overrides.
|
|
144
|
+
|
|
145
|
+
Precedence: CLI flags > project.yaml > workset config.yaml > kanibako.yaml > hardcoded defaults.
|
|
146
|
+
"""
|
|
147
|
+
cfg = load_config(global_path)
|
|
148
|
+
defaults = KanibakoConfig()
|
|
149
|
+
# system_paths is SYSTEM-ONLY: only the global config supplies it. Skip it
|
|
150
|
+
# in the project/workset overlay so a non-global file never clobbers the
|
|
151
|
+
# global's resolved system path tier (its default {} would otherwise be a
|
|
152
|
+
# no-op, but skipping makes the system-only invariant explicit).
|
|
153
|
+
if workset_path and workset_path.exists():
|
|
154
|
+
ws = load_config(workset_path)
|
|
155
|
+
# Only override non-default values from workset config.
|
|
156
|
+
for fld in fields(ws):
|
|
157
|
+
if fld.name == "system_paths":
|
|
158
|
+
continue
|
|
159
|
+
val = getattr(ws, fld.name)
|
|
160
|
+
if val != getattr(defaults, fld.name):
|
|
161
|
+
setattr(cfg, fld.name, val)
|
|
162
|
+
if project_path and project_path.exists():
|
|
163
|
+
proj = load_config(project_path)
|
|
164
|
+
# Only override non-default values from project config.
|
|
165
|
+
for fld in fields(proj):
|
|
166
|
+
if fld.name == "system_paths":
|
|
167
|
+
continue
|
|
168
|
+
val = getattr(proj, fld.name)
|
|
169
|
+
if val != getattr(defaults, fld.name):
|
|
170
|
+
setattr(cfg, fld.name, val)
|
|
171
|
+
if cli_overrides:
|
|
172
|
+
valid_keys = {fld.name for fld in fields(cfg)}
|
|
173
|
+
for k, v in cli_overrides.items():
|
|
174
|
+
if k in valid_keys:
|
|
175
|
+
setattr(cfg, k, v)
|
|
176
|
+
return cfg
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def write_global_config(path: Path, cfg: KanibakoConfig | None = None) -> None:
|
|
180
|
+
"""Write a YAML config file with the structured layout.
|
|
181
|
+
|
|
182
|
+
If *cfg* is None, writes defaults.
|
|
183
|
+
"""
|
|
184
|
+
if cfg is None:
|
|
185
|
+
cfg = KanibakoConfig()
|
|
186
|
+
# System-level path tier (settings-framework "system.path.*"), written at
|
|
187
|
+
# the DEFAULT expressions. Kept in lock-step with
|
|
188
|
+
# paths.SYSTEM_PATH_DEFAULTS (imported lazily there to avoid an import
|
|
189
|
+
# cycle); the resolver fills these in if the file omits them.
|
|
190
|
+
data: dict = {
|
|
191
|
+
"system": {
|
|
192
|
+
"path": {
|
|
193
|
+
"data": "$XDG_DATA_HOME/kanibako",
|
|
194
|
+
"boxes": "@system.path.data/boxes",
|
|
195
|
+
"crabs": "@system.path.data/crabs",
|
|
196
|
+
"comms": "@system.path.data/comms",
|
|
197
|
+
"templates": "@system.path.data/templates",
|
|
198
|
+
"ws_hints": "@system.path.data/worksets.yaml",
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
"box": {
|
|
202
|
+
"image": cfg.box_image,
|
|
203
|
+
"crab": cfg.box_crab,
|
|
204
|
+
"share_images": cfg.box_share_images,
|
|
205
|
+
},
|
|
206
|
+
# Global shared caches (lazy: only mounted if the dir exists on host).
|
|
207
|
+
"shared": {},
|
|
208
|
+
}
|
|
209
|
+
dump_doc(path, data)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def write_project_config(path: Path, image: str) -> None:
|
|
213
|
+
"""Write or update a project.yaml with the given image."""
|
|
214
|
+
write_project_config_key(path, "box_image", image)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def write_project_meta(
|
|
218
|
+
path: Path,
|
|
219
|
+
*,
|
|
220
|
+
mode: str,
|
|
221
|
+
layout: str,
|
|
222
|
+
workspace: str,
|
|
223
|
+
shell: str,
|
|
224
|
+
vault_ro: str,
|
|
225
|
+
vault_rw: str,
|
|
226
|
+
enable_vault: bool = True,
|
|
227
|
+
group_auth: bool = True,
|
|
228
|
+
metadata: str = "",
|
|
229
|
+
project_hash: str = "",
|
|
230
|
+
global_shared: str = "",
|
|
231
|
+
local_shared: str = "",
|
|
232
|
+
name: str = "",
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Write resolved project metadata to project.yaml, preserving other sections."""
|
|
235
|
+
existing = load_doc(path)
|
|
236
|
+
|
|
237
|
+
project_sec: dict = {
|
|
238
|
+
"mode": mode, "layout": layout,
|
|
239
|
+
"enable_vault": enable_vault, "group_auth": group_auth,
|
|
240
|
+
}
|
|
241
|
+
if name:
|
|
242
|
+
project_sec["name"] = name
|
|
243
|
+
existing["project"] = project_sec
|
|
244
|
+
existing.setdefault("resolved", {})
|
|
245
|
+
existing["resolved"]["workspace"] = workspace
|
|
246
|
+
existing["resolved"]["shell"] = shell
|
|
247
|
+
existing["resolved"]["vault_ro"] = vault_ro
|
|
248
|
+
existing["resolved"]["vault_rw"] = vault_rw
|
|
249
|
+
existing["resolved"]["metadata"] = metadata
|
|
250
|
+
existing["resolved"]["project_hash"] = project_hash
|
|
251
|
+
existing["resolved"]["global_shared"] = global_shared
|
|
252
|
+
existing["resolved"]["local_shared"] = local_shared
|
|
253
|
+
|
|
254
|
+
dump_doc(path, existing)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def read_project_meta(path: Path) -> dict | None:
|
|
258
|
+
"""Read stored project metadata from project.yaml.
|
|
259
|
+
|
|
260
|
+
Returns a dict with 'mode', 'workspace', 'shell', 'vault_ro', 'vault_rw'
|
|
261
|
+
or None if no project metadata is stored.
|
|
262
|
+
"""
|
|
263
|
+
if not path.exists():
|
|
264
|
+
return None
|
|
265
|
+
data = load_doc(path)
|
|
266
|
+
|
|
267
|
+
project_sec = data.get("project", {})
|
|
268
|
+
# Support both old ("paths") and new ("resolved") section names.
|
|
269
|
+
resolved_sec = data.get("resolved", data.get("paths", {}))
|
|
270
|
+
|
|
271
|
+
if not project_sec.get("mode"):
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
# Backward compat: terminology renamed over time. "account_centric"
|
|
275
|
+
# (v1.0) and "local" (v1.5.0 mode rename) both map to "default"; old
|
|
276
|
+
# "decentralized" maps to "standalone".
|
|
277
|
+
_MODE_COMPAT = {"account_centric": "default", "decentralized": "standalone", "local": "default"}
|
|
278
|
+
raw_mode = project_sec["mode"]
|
|
279
|
+
mode = _MODE_COMPAT.get(raw_mode, raw_mode)
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
"mode": mode,
|
|
283
|
+
# Backward compat: "tree" was renamed to "robust" in v0.6.0.
|
|
284
|
+
"layout": "robust" if project_sec.get("layout") == "tree" else project_sec.get("layout", ""),
|
|
285
|
+
"enable_vault": project_sec.get("enable_vault", True),
|
|
286
|
+
"group_auth": project_sec.get("group_auth", True),
|
|
287
|
+
"name": project_sec.get("name", ""),
|
|
288
|
+
"workspace": resolved_sec.get("workspace", ""),
|
|
289
|
+
"shell": resolved_sec.get("shell", ""),
|
|
290
|
+
"vault_ro": resolved_sec.get("vault_ro", ""),
|
|
291
|
+
"vault_rw": resolved_sec.get("vault_rw", ""),
|
|
292
|
+
"metadata": resolved_sec.get("metadata", ""),
|
|
293
|
+
"project_hash": resolved_sec.get("project_hash", ""),
|
|
294
|
+
"global_shared": resolved_sec.get("global_shared", ""),
|
|
295
|
+
"local_shared": resolved_sec.get("local_shared", ""),
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _split_config_key(flat_key: str) -> tuple[str, str]:
|
|
300
|
+
"""Split a flat config key into (section, key).
|
|
301
|
+
|
|
302
|
+
``"box_image"`` → ``("box", "image")``
|
|
303
|
+
``"paths_dot_path"`` → ``("paths", "dot_path")``
|
|
304
|
+
"""
|
|
305
|
+
for prefix in ("paths_", "box_"):
|
|
306
|
+
if flat_key.startswith(prefix):
|
|
307
|
+
section = prefix.rstrip("_")
|
|
308
|
+
key = flat_key[len(prefix):]
|
|
309
|
+
return section, key
|
|
310
|
+
raise ValueError(f"Cannot determine config section for key: {flat_key}")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def config_keys() -> list[str]:
|
|
314
|
+
"""Return all valid flat config key names."""
|
|
315
|
+
return [fld.name for fld in fields(KanibakoConfig)]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def write_project_config_key(path: Path, flat_key: str, value: str) -> None:
|
|
319
|
+
"""Write or update a single key in a project.yaml.
|
|
320
|
+
|
|
321
|
+
*flat_key* is the underscore-joined config name (e.g. ``"box_image"``).
|
|
322
|
+
"""
|
|
323
|
+
section, key = _split_config_key(flat_key)
|
|
324
|
+
data = load_doc(path)
|
|
325
|
+
sec = data.get(section)
|
|
326
|
+
if not isinstance(sec, dict):
|
|
327
|
+
sec = {}
|
|
328
|
+
data[section] = sec
|
|
329
|
+
sec[key] = value
|
|
330
|
+
dump_doc(path, data)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def unset_project_config_key(path: Path, flat_key: str) -> bool:
|
|
334
|
+
"""Remove a single key from a project.yaml.
|
|
335
|
+
|
|
336
|
+
Returns True if the key was found and removed, False if it was not present.
|
|
337
|
+
"""
|
|
338
|
+
if not path.exists():
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
section, key = _split_config_key(flat_key)
|
|
342
|
+
data = load_doc(path)
|
|
343
|
+
sec = data.get(section)
|
|
344
|
+
if not isinstance(sec, dict) or key not in sec:
|
|
345
|
+
return False
|
|
346
|
+
del sec[key]
|
|
347
|
+
# Clean up an empty section.
|
|
348
|
+
if not sec:
|
|
349
|
+
data.pop(section, None)
|
|
350
|
+
dump_doc(path, data)
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def load_project_overrides(path: Path) -> dict[str, str]:
|
|
355
|
+
"""Load only the project-level overrides from a project.yaml.
|
|
356
|
+
|
|
357
|
+
Returns a dict of flat_key → value for keys that differ from defaults.
|
|
358
|
+
"""
|
|
359
|
+
if not path.exists():
|
|
360
|
+
return {}
|
|
361
|
+
proj_cfg = load_config(path)
|
|
362
|
+
defaults = KanibakoConfig()
|
|
363
|
+
overrides: dict[str, str] = {}
|
|
364
|
+
for fld in fields(proj_cfg):
|
|
365
|
+
val = getattr(proj_cfg, fld.name)
|
|
366
|
+
if val != getattr(defaults, fld.name):
|
|
367
|
+
overrides[fld.name] = val
|
|
368
|
+
return overrides
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
# Target settings overrides (per-project)
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
def read_crab_settings(path: Path) -> dict[str, str]:
|
|
376
|
+
"""Read crab-state overrides from a project.yaml ``crab`` section.
|
|
377
|
+
|
|
378
|
+
project.yaml's ``crab`` holds box-level crab-state overrides (e.g.
|
|
379
|
+
``{"model": "sonnet"}``); identity keys live in ``box.crab``, not here.
|
|
380
|
+
Returns an empty dict when the file or section is absent.
|
|
381
|
+
"""
|
|
382
|
+
if not path.exists():
|
|
383
|
+
return {}
|
|
384
|
+
data = load_doc(path)
|
|
385
|
+
return {k: str(v) for k, v in data.get("crab", {}).items()}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def write_crab_setting(path: Path, key: str, value: str) -> None:
|
|
389
|
+
"""Write a single crab-state override to ``crab`` in project.yaml.
|
|
390
|
+
|
|
391
|
+
Preserves all other sections.
|
|
392
|
+
"""
|
|
393
|
+
existing = load_doc(path)
|
|
394
|
+
existing.setdefault("crab", {})
|
|
395
|
+
existing["crab"][key] = value
|
|
396
|
+
dump_doc(path, existing)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def remove_crab_setting(path: Path, key: str) -> bool:
|
|
400
|
+
"""Remove a single crab-state override from ``crab`` in project.yaml.
|
|
401
|
+
|
|
402
|
+
Returns True if the setting was found and removed, False otherwise.
|
|
403
|
+
"""
|
|
404
|
+
if not path.exists():
|
|
405
|
+
return False
|
|
406
|
+
existing = load_doc(path)
|
|
407
|
+
settings = existing.get("crab", {})
|
|
408
|
+
if key not in settings:
|
|
409
|
+
return False
|
|
410
|
+
del settings[key]
|
|
411
|
+
if not settings:
|
|
412
|
+
existing.pop("crab", None)
|
|
413
|
+
dump_doc(path, existing)
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
# Scoped shares (settings-framework {scope}.path.share_{ro,rw}.*)
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
def _flatten_dotted(data: dict, prefix: str = "") -> dict[str, str]:
|
|
422
|
+
"""Flatten nested dict into DOTTED-key form, stringifying scalar leaves.
|
|
423
|
+
|
|
424
|
+
``{"system": {"path": {"share_rw": {"foo": "h:g"}}}}`` →
|
|
425
|
+
``{"system.path.share_rw.foo": "h:g"}``.
|
|
426
|
+
"""
|
|
427
|
+
out: dict[str, str] = {}
|
|
428
|
+
for k, v in data.items():
|
|
429
|
+
key = f"{prefix}.{k}" if prefix else k
|
|
430
|
+
if isinstance(v, dict):
|
|
431
|
+
out.update(_flatten_dotted(v, key))
|
|
432
|
+
else:
|
|
433
|
+
out[key] = str(v)
|
|
434
|
+
return out
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def read_shares(path: Path | None) -> dict[str, str]:
|
|
438
|
+
"""Read scoped-share keys ({scope}.path.share_{ro,rw}.{name}) from a config
|
|
439
|
+
file as a flat dotted-key dict. Missing/None/unreadable path → {}."""
|
|
440
|
+
from kanibako.settings_shares import is_share_key
|
|
441
|
+
|
|
442
|
+
if path is None:
|
|
443
|
+
return {}
|
|
444
|
+
try:
|
|
445
|
+
if not path.exists():
|
|
446
|
+
return {}
|
|
447
|
+
data = load_doc(path)
|
|
448
|
+
except Exception:
|
|
449
|
+
return {}
|
|
450
|
+
flat = _flatten_dotted(data)
|
|
451
|
+
return {k: v for k, v in flat.items() if is_share_key(k)}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def read_seeds(path: Path | None) -> dict[str, str]:
|
|
455
|
+
"""Read seed keys ({scope}.path.seeded.{name}) from a config file as a flat
|
|
456
|
+
dotted-key dict. Missing/None/unreadable path → {}."""
|
|
457
|
+
from kanibako.settings_seeds import is_seed_key
|
|
458
|
+
|
|
459
|
+
if path is None:
|
|
460
|
+
return {}
|
|
461
|
+
try:
|
|
462
|
+
if not path.exists():
|
|
463
|
+
return {}
|
|
464
|
+
data = load_doc(path)
|
|
465
|
+
except Exception:
|
|
466
|
+
return {}
|
|
467
|
+
flat = _flatten_dotted(data)
|
|
468
|
+
return {k: v for k, v in flat.items() if is_seed_key(k)}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
# Resource scope overrides (per-project)
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
def read_resource_overrides(path: Path) -> dict[str, str]:
|
|
476
|
+
"""Read ``resource_overrides`` from a project.yaml.
|
|
477
|
+
|
|
478
|
+
Returns a dict of resource_path → scope_string (e.g. ``"shared"``).
|
|
479
|
+
Returns an empty dict when the file or section is absent.
|
|
480
|
+
"""
|
|
481
|
+
if not path.exists():
|
|
482
|
+
return {}
|
|
483
|
+
data = load_doc(path)
|
|
484
|
+
return {k: str(v) for k, v in data.get("resource_overrides", {}).items()}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def write_resource_override(path: Path, resource_path: str, scope: str) -> None:
|
|
488
|
+
"""Write a single resource scope override to ``resource_overrides`` in project.yaml.
|
|
489
|
+
|
|
490
|
+
Preserves all other sections.
|
|
491
|
+
"""
|
|
492
|
+
existing = load_doc(path)
|
|
493
|
+
existing.setdefault("resource_overrides", {})
|
|
494
|
+
existing["resource_overrides"][resource_path] = scope
|
|
495
|
+
dump_doc(path, existing)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def remove_resource_override(path: Path, resource_path: str) -> bool:
|
|
499
|
+
"""Remove a single resource scope override from ``resource_overrides``.
|
|
500
|
+
|
|
501
|
+
Returns True if the override was found and removed, False otherwise.
|
|
502
|
+
"""
|
|
503
|
+
if not path.exists():
|
|
504
|
+
return False
|
|
505
|
+
existing = load_doc(path)
|
|
506
|
+
overrides = existing.get("resource_overrides", {})
|
|
507
|
+
if resource_path not in overrides:
|
|
508
|
+
return False
|
|
509
|
+
del overrides[resource_path]
|
|
510
|
+
if not overrides:
|
|
511
|
+
# Remove the empty section entirely.
|
|
512
|
+
existing.pop("resource_overrides", None)
|
|
513
|
+
dump_doc(path, existing)
|
|
514
|
+
return True
|