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/rig_source.py ADDED
@@ -0,0 +1,245 @@
1
+ """Source detection + name derivation for the future ``rig add`` command.
2
+
3
+ Given a *source* string -- a local file path, a registry reference, or a URL --
4
+ these pure helpers decide whether the source describes an OCI **image** (a
5
+ prefab to pull / a saved image tar to load) or a buildable **template**
6
+ (a Containerfile), and derive a sensible default rig name.
7
+
8
+ The core helpers do **no network I/O**. A raw ``http(s)://`` URL is undecidable
9
+ on its own; the caller is expected to fetch it first via :func:`fetch_to_temp`
10
+ (a thin, separately monkeypatchable wrapper) and then classify the downloaded
11
+ file with :func:`detect_source_kind`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import tarfile
18
+ import tempfile
19
+ import urllib.request
20
+ from pathlib import Path
21
+
22
+ # How many leading lines to scan when sniffing a text file for template signals.
23
+ _TEMPLATE_SCAN_LINES = 20
24
+
25
+ # Header conventions mirrored from templates_image.py. We only need to *detect*
26
+ # their presence here, so a loose prefix match is enough.
27
+ _TEMPLATE_HEADER_RE = re.compile(
28
+ r"^#\s*kanibako-template(?:-check)?:\s*", re.IGNORECASE
29
+ )
30
+
31
+ # A leading ``FROM <image>`` directive marks a Containerfile/Dockerfile.
32
+ _FROM_DIRECTIVE_RE = re.compile(r"^\s*FROM\s+\S", re.IGNORECASE)
33
+
34
+ # Loose OCI registry-reference grammar: ``[registry/]repo[:tag][@digest]``.
35
+ # Lowercased before matching. Allows path segments separated by ``/`` and the
36
+ # usual ``.``/``-``/``_`` separators within a segment, an optional ``:tag`` and
37
+ # an optional ``@sha256:<hex>`` digest.
38
+ _REF_RE = re.compile(
39
+ r"^[a-z0-9]+([._-][a-z0-9]+)*" # first segment (registry host or repo)
40
+ r"(:[0-9]+)?" # optional :port on the host segment
41
+ r"(/[a-z0-9]+([._-][a-z0-9]+)*)*" # further /path segments
42
+ r"(:[\w][\w.-]*)?" # optional :tag
43
+ r"(@sha256:[a-f0-9]+)?$", # optional @digest
44
+ )
45
+
46
+ # Members whose presence in a tar marks it as an OCI / docker-save image archive.
47
+ _IMAGE_TAR_MARKERS = ("manifest.json", "oci-layout")
48
+
49
+
50
+ def fetch_to_temp(url: str) -> Path:
51
+ """Download *url* to a temporary file and return its :class:`Path`.
52
+
53
+ Thin wrapper over :func:`urllib.request.urlretrieve` so that callers can
54
+ fetch a remote source before classifying it, and tests can monkeypatch the
55
+ network hop. The temp file is **not** auto-deleted; the caller owns cleanup.
56
+ """
57
+ fd, tmp_name = tempfile.mkstemp(prefix="kanibako-rig-src-")
58
+ # Close our handle; urlretrieve manages the file by name.
59
+ import os
60
+
61
+ os.close(fd)
62
+ urllib.request.urlretrieve(url, tmp_name)
63
+ return Path(tmp_name)
64
+
65
+
66
+ def _is_image_tar(path: str) -> bool:
67
+ """Return True if *path* is a tar whose members mark it as an image archive."""
68
+ if not tarfile.is_tarfile(path):
69
+ return False
70
+ try:
71
+ with tarfile.open(path) as tar:
72
+ names = tar.getnames()
73
+ except (tarfile.TarError, OSError):
74
+ return False
75
+ for name in names:
76
+ norm = name.lstrip("./")
77
+ if norm in _IMAGE_TAR_MARKERS:
78
+ return True
79
+ # A top-level ``blobs/`` directory entry (OCI layout layout).
80
+ if norm == "blobs" or norm.startswith("blobs/"):
81
+ return True
82
+ return False
83
+
84
+
85
+ def _has_template_signal(path: Path) -> bool:
86
+ """Return True if *path* reads like a Containerfile/template."""
87
+ try:
88
+ with path.open(encoding="utf-8", errors="replace") as fh:
89
+ scanned = 0
90
+ for raw in fh:
91
+ if scanned >= _TEMPLATE_SCAN_LINES:
92
+ break
93
+ scanned += 1
94
+ line = raw.rstrip("\n")
95
+ if _TEMPLATE_HEADER_RE.match(line):
96
+ return True
97
+ stripped = line.strip()
98
+ if not stripped or stripped.startswith("#"):
99
+ continue
100
+ # First non-empty, non-comment line decides the FROM question.
101
+ return bool(_FROM_DIRECTIVE_RE.match(line))
102
+ except OSError:
103
+ return False
104
+ return False
105
+
106
+
107
+ def detect_source_kind(source: str, *, force: str | None = None) -> str:
108
+ """Classify *source* as ``"image"`` or ``"template"``.
109
+
110
+ Resolution order:
111
+
112
+ 1. *force* in ``{"image", "template"}`` -> returned directly.
113
+ 2. **Local file** (``Path(source).is_file()``) -> sniffed: an image-tar
114
+ (manifest.json / oci-layout / blobs/) is ``"image"``; a file with a
115
+ leading ``FROM`` directive or a ``# kanibako-template[-check]:`` header
116
+ is ``"template"``. Neither signal -> :class:`ValueError`.
117
+ 3. **Registry reference** (a non-existent path matching the loose ref
118
+ grammar, e.g. ``ghcr.io/corp/base:1.0`` or ``busybox``) -> ``"image"``.
119
+
120
+ A raw ``http(s)://`` URL is **undecidable** here -- fetch it first with
121
+ :func:`fetch_to_temp`, then classify the downloaded file. Anything that
122
+ matches none of the above raises :class:`ValueError` with guidance.
123
+ """
124
+ if force is not None:
125
+ if force in ("image", "template"):
126
+ return force
127
+ raise ValueError(
128
+ f"invalid force value '{force}'; expected 'image' or 'template'"
129
+ )
130
+
131
+ path = Path(source)
132
+ if path.is_file():
133
+ if _is_image_tar(source):
134
+ return "image"
135
+ if _has_template_signal(path):
136
+ return "template"
137
+ raise ValueError(
138
+ f"cannot classify source '{source}'; pass --as image|template"
139
+ )
140
+
141
+ lowered = source.lower()
142
+ if lowered.startswith(("http://", "https://")):
143
+ raise ValueError(
144
+ f"cannot classify URL '{source}' directly; fetch it first "
145
+ "(fetch_to_temp) then classify the downloaded file."
146
+ )
147
+
148
+ if _REF_RE.match(lowered):
149
+ return "image"
150
+
151
+ raise ValueError(
152
+ f"cannot classify source '{source}'; expected a local file, an image "
153
+ "reference, or a fetched URL (pass --as image|template to force)."
154
+ )
155
+
156
+
157
+ def _name_from_containerfile_basename(basename: str) -> str | None:
158
+ """Derive a rig name from a ``Containerfile.<rest>`` basename, else None."""
159
+ for prefix in ("Containerfile.", "Dockerfile."):
160
+ if basename.startswith(prefix):
161
+ rest = basename[len(prefix):]
162
+ if not rest:
163
+ return None
164
+ if rest.startswith("template-"):
165
+ rest = rest[len("template-"):]
166
+ return rest or None
167
+ return None
168
+
169
+
170
+ def _name_from_ref(ref: str) -> str | None:
171
+ """Return *ref* minus a leading registry-host segment, if any."""
172
+ if not ref:
173
+ return None
174
+ segments = ref.split("/")
175
+ if len(segments) > 1:
176
+ first = segments[0]
177
+ # A leading segment is a registry host if it carries a ``.`` or a port.
178
+ if "." in first or ":" in first:
179
+ return "/".join(segments[1:]) or None
180
+ return ref
181
+
182
+
183
+ def _name_from_image_tar(path: str) -> str | None:
184
+ """Derive a rig name from an image tar's first RepoTag, else its stem."""
185
+ try:
186
+ with tarfile.open(path) as tar:
187
+ try:
188
+ member = tar.getmember("manifest.json")
189
+ except KeyError:
190
+ member = None
191
+ if member is not None:
192
+ fh = tar.extractfile(member)
193
+ if fh is not None:
194
+ import json
195
+
196
+ try:
197
+ manifest = json.loads(fh.read().decode("utf-8"))
198
+ except (ValueError, UnicodeDecodeError):
199
+ manifest = None
200
+ if isinstance(manifest, list) and manifest:
201
+ tags = manifest[0].get("RepoTags")
202
+ if isinstance(tags, list) and tags:
203
+ first = tags[0]
204
+ if isinstance(first, str) and first:
205
+ return first
206
+ except (tarfile.TarError, OSError):
207
+ pass
208
+ return Path(path).stem or None
209
+
210
+
211
+ def derive_name(source: str, kind: str) -> str | None:
212
+ """Best-effort default rig name for *source* (of the given *kind*), or None.
213
+
214
+ - A Containerfile/Dockerfile path or URL whose basename is
215
+ ``Containerfile.<rest>`` -> ``<rest>`` with an optional leading
216
+ ``template-`` stripped (a bare ``Containerfile``/``Dockerfile`` -> None).
217
+ - An image **reference** -> the ref minus a leading registry-host segment
218
+ (``ghcr.io/corp/base:1.0`` -> ``corp/base:1.0``; ``busybox`` -> ``busybox``).
219
+ - An image **tar** (an existing file) -> its first RepoTag, else the stem.
220
+ - Anything underivable -> None.
221
+
222
+ Note: derived image names may legitimately contain ``/`` and ``:`` and are
223
+ *not* run through :func:`kanibako.templates_image.validate_template_name`.
224
+ """
225
+ # A basename-based Containerfile name wins regardless of kind: ``rig add``
226
+ # of a template URL/path should name itself after the file.
227
+ basename = source.rsplit("/", 1)[-1]
228
+ cf_name = _name_from_containerfile_basename(basename)
229
+ if cf_name is not None:
230
+ return cf_name
231
+
232
+ if kind == "template":
233
+ return None
234
+
235
+ # kind == "image"
236
+ path = Path(source)
237
+ if path.is_file():
238
+ return _name_from_image_tar(source)
239
+
240
+ lowered = source.lower()
241
+ if lowered.startswith(("http://", "https://")):
242
+ # A URL with no Containerfile-style basename is underivable here.
243
+ return None
244
+
245
+ return _name_from_ref(source)
File without changes
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # helper-init.sh — Default helper entrypoint wrapper (kanibako)
3
+ #
4
+ # This script is copied into every helper's playbook/scripts/ directory
5
+ # by the parent agent. It runs as the container entrypoint.
6
+ #
7
+ # The parent creates the directory structure (vault, workspace, playbook,
8
+ # peers, broadcast channels) before launching the helper. This script
9
+ # handles registration with the hub and additional bootstrap, then execs
10
+ # the agent command.
11
+ #
12
+ # Parents can replace this with a custom version in their own
13
+ # playbook/scripts/helper-init.sh — kanibako will use the parent's
14
+ # version if it exists, falling back to this bundled default.
15
+ #
16
+ # Usage: helper-init.sh HELPER_NUM [COMMAND...]
17
+ # HELPER_NUM — this helper's global agent number
18
+ # COMMAND — the agent command to exec (default: claude)
19
+
20
+ set -euo pipefail
21
+
22
+ HELPER_NUM="${1:-unknown}"
23
+ shift || true
24
+
25
+ SOCKET_PATH="$HOME/.kanibako/helper.sock"
26
+
27
+ # Register with the hub via kanibako CLI (one-shot)
28
+ if [ -S "$SOCKET_PATH" ] && command -v kanibako >/dev/null 2>&1; then
29
+ kanibako helper register "$HELPER_NUM" 2>/dev/null || true
30
+ fi
31
+
32
+ # Source parent startup script from broadcast channel if present
33
+ if [ -f "$HOME/all/ro/startup.sh" ]; then
34
+ # shellcheck disable=SC1091
35
+ source "$HOME/all/ro/startup.sh"
36
+ fi
37
+
38
+ echo "Helper $HELPER_NUM initialized." >&2
39
+
40
+ # Exec the agent command (or claude if none given)
41
+ if [ $# -gt 0 ]; then
42
+ exec "$@"
43
+ else
44
+ exec claude
45
+ fi
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+ """Container-side entry point for kanibako CLI.
3
+
4
+ Bind-mounted into containers at /home/agent/.local/bin/kanibako.
5
+ The kanibako package directory is bind-mounted at /opt/kanibako/kanibako/.
6
+ """
7
+ import sys
8
+
9
+ sys.path.insert(0, "/opt/kanibako")
10
+ from kanibako.cli import main # noqa: E402
11
+
12
+ sys.exit(main() or 0)
@@ -0,0 +1,312 @@
1
+ """Settings-framework expression resolution engine (pure, additive).
2
+
3
+ This module is the single home for the settings-framework expression grammar:
4
+ precedence resolution, ``$VAR``/``~``/``@``-ref expansion, and ``host_src:
5
+ guest_dest`` bind splitting. It operates ONLY on already-parsed nested data
6
+ (passed in as :class:`LevelView` objects and a lookup callback). It performs
7
+ **no file I/O, no mounting, no global mutable state**, and imports neither
8
+ ``config.py`` nor ``paths.py``. It is intentionally format-agnostic — the
9
+ caller is responsible for parsing TOML/YAML into the simple mappings this
10
+ module consumes.
11
+
12
+ Precedence model (the design's "unset ≠ '' " distinction)
13
+ ---------------------------------------------------------
14
+ :func:`resolve_value` walks the levels ordered MOST-SPECIFIC-FIRST
15
+ (``[box, workset, crab, system]``):
16
+
17
+ * **Explicit set values beat all declared defaults**, regardless of level.
18
+ * Among set values, the **most-specific** level wins.
19
+ * An explicit ``""`` is a **terminal suppression**: it wins at its level and
20
+ does NOT fall through to a less-specific default (``terminal=True``).
21
+ * Among defaults (reached only when nothing is set), the most-specific
22
+ (highest-authority) declared default wins.
23
+ * Nothing set and no default ⇒ :data:`UNSET`.
24
+
25
+ Expansion is a separate, explicit step (:func:`expand_expr`): the caller takes
26
+ the winning raw literal from :func:`resolve_value` and expands it in the
27
+ appropriate *space* (``"host"`` or ``"guest"``).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from collections.abc import Callable, Mapping
34
+ from dataclasses import dataclass, field
35
+ from typing import Literal
36
+
37
+ from kanibako.errors import KanibakoError
38
+
39
+ GUEST_HOME = "/home/agent"
40
+ MAX_REF_DEPTH = 64
41
+
42
+ _VAR_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
43
+ _REF_NAME_RE = re.compile(r"[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*")
44
+
45
+
46
+ class SettingsError(KanibakoError):
47
+ """Raised on unknown variable, unresolvable/cyclic ``@``-ref, or depth-cap."""
48
+
49
+
50
+ class _Unset:
51
+ """Sentinel type for "no value resolved" (distinct from an explicit ``""``)."""
52
+
53
+ __slots__ = ()
54
+
55
+ def __repr__(self) -> str:
56
+ return "UNSET"
57
+
58
+
59
+ UNSET = _Unset()
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ResolveCtx:
64
+ """Context for variable expansion.
65
+
66
+ *xdg* maps XDG variable names (e.g. ``"XDG_DATA_HOME"``) to host paths.
67
+ The dataclass is frozen; do not mutate *xdg* in place.
68
+ """
69
+
70
+ crab_name: str | None
71
+ workset_name: str | None
72
+ host_home: str
73
+ xdg: dict[str, str]
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class LevelView:
78
+ """A single precedence level's explicitly-set values and declared defaults.
79
+
80
+ *name* is the level name (e.g. ``"box"``). *values* holds values the user
81
+ explicitly set at this level; *defaults* holds defaults declared at this
82
+ level.
83
+ """
84
+
85
+ name: str
86
+ values: Mapping[str, str]
87
+ defaults: Mapping[str, str] = field(default_factory=dict)
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class ResolvedValue:
92
+ """A resolved (but not yet expanded) value plus its provenance.
93
+
94
+ *value* is the raw winning literal (``@``-refs/``$vars``/``~`` intact).
95
+ *level* names the level that supplied it. *is_default* is True when the
96
+ value came from a declared default rather than an explicit set. *terminal*
97
+ is True when the winning value was an explicit ``""`` (a terminal
98
+ suppression that does not fall through to defaults).
99
+ """
100
+
101
+ value: str
102
+ level: str
103
+ is_default: bool = False
104
+ terminal: bool = False
105
+
106
+
107
+ def _unescape(s: str) -> str:
108
+ """Resolve backslash escapes consistently.
109
+
110
+ A backslash before any character yields that character literally
111
+ (``\\:`` → ``:``, ``\\\\`` → ``\\``, ``\\x`` → ``x``). A trailing lone
112
+ backslash is kept literal.
113
+ """
114
+ out: list[str] = []
115
+ i = 0
116
+ n = len(s)
117
+ while i < n:
118
+ c = s[i]
119
+ if c == "\\":
120
+ if i + 1 < n:
121
+ out.append(s[i + 1])
122
+ i += 2
123
+ continue
124
+ # Trailing lone backslash: keep literal.
125
+ out.append("\\")
126
+ i += 1
127
+ continue
128
+ out.append(c)
129
+ i += 1
130
+ return "".join(out)
131
+
132
+
133
+ def split_bind(value: str) -> tuple[str, str | None]:
134
+ """Split ``host_src:guest_dest`` into its two halves.
135
+
136
+ Scans left-to-right; a backslash escapes the next character (so ``\\:`` is
137
+ a literal colon, ``\\\\`` a literal backslash). Splits at the FIRST
138
+ UNESCAPED ``:``. With no unescaped colon, returns ``(unescaped(value),
139
+ None)`` for a plain scalar. Each returned half has its escapes resolved.
140
+
141
+ Linux container paths only — no Windows drive-letter / URI special-casing.
142
+ Use ``\\:`` to embed a literal colon in either half.
143
+ """
144
+ i = 0
145
+ n = len(value)
146
+ while i < n:
147
+ c = value[i]
148
+ if c == "\\":
149
+ # Skip the escaped character.
150
+ i += 2
151
+ continue
152
+ if c == ":":
153
+ host = _unescape(value[:i])
154
+ guest = _unescape(value[i + 1 :])
155
+ return host, guest
156
+ i += 1
157
+ return _unescape(value), None
158
+
159
+
160
+ def expand_expr(
161
+ expr: str,
162
+ *,
163
+ space: Literal["host", "guest"],
164
+ ctx: ResolveCtx,
165
+ lookup: Callable[[str, tuple[str, ...]], str],
166
+ chain: tuple[str, ...] = (),
167
+ ) -> str:
168
+ """Expand a single path/scalar expression (one bind half).
169
+
170
+ Single left-to-right scan emitting literal vs expanded segments. A
171
+ substituted value is a LEAF — it is not re-scanned (prevents
172
+ double-expansion / loops).
173
+
174
+ Grammar:
175
+
176
+ * **Escapes:** ``\\@``→``@``, ``\\$``→``$``, ``\\\\``→``\\``; a backslash
177
+ before any other char yields that char literally.
178
+ * **``~``:** ONLY when it is the FIRST character of *expr*. Expands to
179
+ ``ctx.host_home`` (``space=="host"``) or :data:`GUEST_HOME`
180
+ (``space=="guest"``). A ``~`` elsewhere is literal.
181
+ * **``$VAR`` / ``${VAR}``:** name = ``[A-Za-z_][A-Za-z0-9_]*``. ``CRAB`` →
182
+ ``ctx.crab_name``, ``WORKSET`` → ``ctx.workset_name``, ``XDG_*`` →
183
+ ``ctx.xdg[name]``. Unknown names, or known names whose context value is
184
+ ``None``/missing, raise :class:`SettingsError`.
185
+ * **``@``-ref:** ``@`` then a dotted name ``[A-Za-z0-9_]+(\\.[...])*``. The
186
+ ref name ends at the first char outside that set. Cycle-guarded against
187
+ *chain* and capped at :data:`MAX_REF_DEPTH`. Substitutes
188
+ ``lookup(ref_name, chain + (ref_name,))``; the result is a leaf.
189
+ """
190
+ out: list[str] = []
191
+ i = 0
192
+ n = len(expr)
193
+
194
+ # Leading ~ → home (only at position 0).
195
+ if n > 0 and expr[0] == "~":
196
+ out.append(ctx.host_home if space == "host" else GUEST_HOME)
197
+ i = 1
198
+
199
+ while i < n:
200
+ c = expr[i]
201
+ if c == "\\":
202
+ if i + 1 < n:
203
+ out.append(expr[i + 1])
204
+ i += 2
205
+ continue
206
+ out.append("\\")
207
+ i += 1
208
+ continue
209
+ if c == "$":
210
+ seg, i = _expand_var(expr, i, ctx)
211
+ out.append(seg)
212
+ continue
213
+ if c == "@":
214
+ seg, i = _expand_ref(expr, i, lookup, chain)
215
+ out.append(seg)
216
+ continue
217
+ out.append(c)
218
+ i += 1
219
+
220
+ return "".join(out)
221
+
222
+
223
+ def _expand_var(expr: str, i: int, ctx: ResolveCtx) -> tuple[str, int]:
224
+ """Expand a ``$VAR`` or ``${VAR}`` starting at index *i* (the ``$``)."""
225
+ n = len(expr)
226
+ braced = i + 1 < n and expr[i + 1] == "{"
227
+ name_start = i + 2 if braced else i + 1
228
+ m = _VAR_NAME_RE.match(expr, name_start)
229
+ if m is None:
230
+ raise SettingsError(f"Malformed variable reference at: {expr[i:]!r}")
231
+ name = m.group(0)
232
+ end = m.end()
233
+ if braced:
234
+ if end >= n or expr[end] != "}":
235
+ raise SettingsError(f"Unterminated ${{...}} reference: {expr[i:]!r}")
236
+ end += 1
237
+ return _resolve_var(name, ctx), end
238
+
239
+
240
+ def _resolve_var(name: str, ctx: ResolveCtx) -> str:
241
+ """Resolve a variable name against the context namespace."""
242
+ if name == "CRAB":
243
+ if ctx.crab_name is None:
244
+ raise SettingsError("Variable $CRAB is not set in this context.")
245
+ return ctx.crab_name
246
+ if name == "WORKSET":
247
+ if ctx.workset_name is None:
248
+ raise SettingsError("Variable $WORKSET is not set in this context.")
249
+ return ctx.workset_name
250
+ if name.startswith("XDG_"):
251
+ if name not in ctx.xdg:
252
+ raise SettingsError(f"Variable ${name} is not set in this context.")
253
+ return ctx.xdg[name]
254
+ raise SettingsError(f"Unknown variable: ${name}")
255
+
256
+
257
+ def _expand_ref(
258
+ expr: str,
259
+ i: int,
260
+ lookup: Callable[[str, tuple[str, ...]], str],
261
+ chain: tuple[str, ...],
262
+ ) -> tuple[str, int]:
263
+ """Expand an ``@ref`` starting at index *i* (the ``@``)."""
264
+ m = _REF_NAME_RE.match(expr, i + 1)
265
+ if m is None:
266
+ raise SettingsError(f"Malformed @-reference at: {expr[i:]!r}")
267
+ ref_name = m.group(0)
268
+ end = m.end()
269
+ if ref_name in chain:
270
+ cycle = " -> ".join((*chain, ref_name))
271
+ raise SettingsError(f"Cyclic @-reference: {cycle}")
272
+ if len(chain) >= MAX_REF_DEPTH:
273
+ raise SettingsError(
274
+ f"@-reference depth cap ({MAX_REF_DEPTH}) exceeded resolving "
275
+ f"'{ref_name}'."
276
+ )
277
+ return lookup(ref_name, (*chain, ref_name)), end
278
+
279
+
280
+ def resolve_value(
281
+ key: str,
282
+ *,
283
+ levels: list[LevelView],
284
+ ctx: ResolveCtx,
285
+ lookup: Callable[[str, tuple[str, ...]], str],
286
+ ) -> ResolvedValue | _Unset:
287
+ """Resolve *key* by precedence over *levels* (most-specific first).
288
+
289
+ Returns the raw winning literal (unexpanded) with provenance, or
290
+ :data:`UNSET`. Does NOT expand — the caller expands the result via
291
+ :func:`expand_expr` with the appropriate *space*. *ctx*/*lookup* are
292
+ accepted for signature stability; the pure precedence logic does not use
293
+ them.
294
+ """
295
+ del ctx, lookup # accepted for signature stability; unused here.
296
+
297
+ # Pass 1: explicit set values, most-specific first.
298
+ for level in levels:
299
+ if key in level.values:
300
+ val = level.values[key]
301
+ if val == "":
302
+ return ResolvedValue(value="", level=level.name, terminal=True)
303
+ return ResolvedValue(value=val, level=level.name)
304
+
305
+ # Pass 2: declared defaults, most-specific first.
306
+ for level in levels:
307
+ if key in level.defaults:
308
+ return ResolvedValue(
309
+ value=level.defaults[key], level=level.name, is_default=True
310
+ )
311
+
312
+ return UNSET