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/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