kanibako-cli 1.5.0__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 (91) hide show
  1. kanibako/__init__.py +3 -0
  2. kanibako/__main__.py +6 -0
  3. kanibako/_atomic.py +62 -0
  4. kanibako/auth_browser.py +296 -0
  5. kanibako/auth_parser.py +51 -0
  6. kanibako/baseline.py +124 -0
  7. kanibako/browser_sidecar.py +183 -0
  8. kanibako/browser_state.py +103 -0
  9. kanibako/bun_sea.py +144 -0
  10. kanibako/cli.py +346 -0
  11. kanibako/commands/__init__.py +0 -0
  12. kanibako/commands/archive.py +228 -0
  13. kanibako/commands/baseline_cmd.py +235 -0
  14. kanibako/commands/box/__init__.py +25 -0
  15. kanibako/commands/box/_duplicate.py +493 -0
  16. kanibako/commands/box/_lifecycle.py +1396 -0
  17. kanibako/commands/box/_parser.py +1114 -0
  18. kanibako/commands/clean.py +166 -0
  19. kanibako/commands/crab_cmd.py +480 -0
  20. kanibako/commands/diagnose.py +497 -0
  21. kanibako/commands/fork_cmd.py +51 -0
  22. kanibako/commands/helper_cmd.py +669 -0
  23. kanibako/commands/image.py +1280 -0
  24. kanibako/commands/install.py +151 -0
  25. kanibako/commands/refresh_credentials.py +67 -0
  26. kanibako/commands/restore.py +298 -0
  27. kanibako/commands/setup_cmd.py +89 -0
  28. kanibako/commands/start.py +1877 -0
  29. kanibako/commands/stop.py +116 -0
  30. kanibako/commands/system_cmd.py +224 -0
  31. kanibako/commands/upgrade.py +161 -0
  32. kanibako/commands/vault_cmd.py +199 -0
  33. kanibako/commands/workset_cmd.py +881 -0
  34. kanibako/config.py +584 -0
  35. kanibako/config_interface.py +575 -0
  36. kanibako/config_io.py +40 -0
  37. kanibako/container.py +539 -0
  38. kanibako/containerfiles.py +58 -0
  39. kanibako/containers/Containerfile.template-android +55 -0
  40. kanibako/containers/Containerfile.template-dotnet +29 -0
  41. kanibako/containers/Containerfile.template-js +43 -0
  42. kanibako/containers/Containerfile.template-jvm +27 -0
  43. kanibako/containers/Containerfile.template-systems +46 -0
  44. kanibako/containers/__init__.py +0 -0
  45. kanibako/containers/tmux.conf +12 -0
  46. kanibako/crabs.py +89 -0
  47. kanibako/data/__init__.py +0 -0
  48. kanibako/data/image-baseline.yaml +29 -0
  49. kanibako/errors.py +33 -0
  50. kanibako/freshness.py +67 -0
  51. kanibako/git.py +114 -0
  52. kanibako/helper_client.py +132 -0
  53. kanibako/helper_listener.py +548 -0
  54. kanibako/helpers.py +339 -0
  55. kanibako/hygiene.py +296 -0
  56. kanibako/image_sharing.py +133 -0
  57. kanibako/instructions.py +160 -0
  58. kanibako/log.py +31 -0
  59. kanibako/names.py +248 -0
  60. kanibako/paths.py +1559 -0
  61. kanibako/plugins/__init__.py +10 -0
  62. kanibako/registry.py +71 -0
  63. kanibako/rig_bundle.py +121 -0
  64. kanibako/rig_meta.py +93 -0
  65. kanibako/rig_registry.py +134 -0
  66. kanibako/rig_resolve.py +182 -0
  67. kanibako/rig_source.py +245 -0
  68. kanibako/scripts/__init__.py +0 -0
  69. kanibako/scripts/helper-init.sh +45 -0
  70. kanibako/scripts/kanibako-entry +12 -0
  71. kanibako/settings_resolve.py +312 -0
  72. kanibako/settings_seeds.py +154 -0
  73. kanibako/settings_shares.py +154 -0
  74. kanibako/shellenv.py +75 -0
  75. kanibako/shells.py +186 -0
  76. kanibako/snapshots.py +281 -0
  77. kanibako/targets/__init__.py +173 -0
  78. kanibako/targets/base.py +297 -0
  79. kanibako/targets/no_agent.py +58 -0
  80. kanibako/templates.py +60 -0
  81. kanibako/templates_image.py +225 -0
  82. kanibako/tweakcc.py +140 -0
  83. kanibako/tweakcc_cache.py +171 -0
  84. kanibako/utils.py +136 -0
  85. kanibako/workset.py +669 -0
  86. kanibako_cli-1.5.0.dist-info/METADATA +15 -0
  87. kanibako_cli-1.5.0.dist-info/RECORD +91 -0
  88. kanibako_cli-1.5.0.dist-info/WHEEL +5 -0
  89. kanibako_cli-1.5.0.dist-info/entry_points.txt +5 -0
  90. kanibako_cli-1.5.0.dist-info/licenses/LICENSE.md +594 -0
  91. kanibako_cli-1.5.0.dist-info/top_level.txt +1 -0
kanibako/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """kanibako: Run AI coding agents in rootless containers with per-project isolation."""
2
+
3
+ __version__ = "1.5.0"
kanibako/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running kanibako as `python -m kanibako`."""
2
+
3
+ from kanibako.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
kanibako/_atomic.py ADDED
@@ -0,0 +1,62 @@
1
+ """Atomic file writes for persistent state.
2
+
3
+ A crash mid-write with a plain ``path.write_text(...)`` leaves a torn/truncated
4
+ file that the next run fails to parse. Writing to a temp file in the *same
5
+ directory* and then ``os.replace``-ing it over the target is atomic on POSIX
6
+ (``rename(2)`` within one filesystem), so a reader sees either the old file or
7
+ the new one — never a half-written one.
8
+
9
+ These helpers are dependency-free (only the stdlib) so any module can route its
10
+ registry/state writes through them without risking an import cycle.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+
22
+ def atomic_write_text(path: Path, data: str) -> None:
23
+ """Write *data* to *path* atomically.
24
+
25
+ The bytes land in a temp file in ``path.parent`` (so ``os.replace`` stays on
26
+ one filesystem), are flushed and ``fsync``-ed, then renamed over *path*. On
27
+ any failure the temp file is removed and the original *path* is left intact.
28
+ Parent directories are created if needed.
29
+ """
30
+ path = Path(path)
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+
33
+ encoded = data.encode("utf-8")
34
+ # Create the temp file in the SAME directory as the target so os.replace is
35
+ # a same-filesystem rename (atomic). delete=False so we control the rename.
36
+ fd, tmp_name = tempfile.mkstemp(
37
+ prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent),
38
+ )
39
+ tmp_path = Path(tmp_name)
40
+ try:
41
+ with os.fdopen(fd, "wb") as fh:
42
+ fh.write(encoded)
43
+ fh.flush()
44
+ os.fsync(fh.fileno())
45
+ os.replace(tmp_path, path)
46
+ except BaseException:
47
+ # Leave the original file untouched; clean up the temp on any failure.
48
+ try:
49
+ tmp_path.unlink()
50
+ except OSError:
51
+ pass
52
+ raise
53
+
54
+
55
+ def atomic_write_yaml(path: Path, data: object, **dump_kwargs: object) -> None:
56
+ """Serialize *data* to YAML and write it to *path* atomically.
57
+
58
+ A thin convenience over :func:`atomic_write_text` for the common case of a
59
+ YAML document. Extra keyword arguments are forwarded to ``yaml.safe_dump``.
60
+ """
61
+ text = yaml.safe_dump(data, **dump_kwargs)
62
+ atomic_write_text(path, text)
@@ -0,0 +1,296 @@
1
+ """Automated OAuth refresh via headless browser.
2
+
3
+ Uses Playwright (optional dependency) to navigate the Claude Code OAuth
4
+ authorization page and click "Authorize" when the IdP session is still
5
+ valid. Falls back to manual login when the session is stale.
6
+
7
+ Requires: ``pip install playwright && playwright install chromium``
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from kanibako.browser_state import (
17
+ from_playwright_context,
18
+ load_state,
19
+ save_state,
20
+ to_playwright_context,
21
+ )
22
+ from kanibako.log import get_logger
23
+
24
+ logger = get_logger("auth_browser")
25
+
26
+ _AUTHORIZE_TIMEOUT_MS = 30_000
27
+ _NAVIGATION_TIMEOUT_MS = 30_000
28
+
29
+ # Lazy-loaded Playwright symbols. Populated by _check_playwright() so that
30
+ # tests can patch them at the module level without actually importing Playwright.
31
+ sync_playwright: Any = None
32
+ PWTimeout: type[Exception] = Exception # fallback type for except clauses
33
+
34
+
35
+ @dataclass
36
+ class AuthResult:
37
+ """Result of an automated OAuth refresh attempt."""
38
+
39
+ success: bool
40
+ key: str | None = None
41
+ error: str | None = None
42
+
43
+
44
+ def _check_playwright() -> bool:
45
+ """Check if Playwright is available and populate module-level symbols."""
46
+ global sync_playwright, PWTimeout # noqa: PLW0603
47
+ try:
48
+ from playwright.sync_api import ( # type: ignore[import-not-found]
49
+ sync_playwright as _sp,
50
+ TimeoutError as _te,
51
+ )
52
+ sync_playwright = _sp
53
+ PWTimeout = _te
54
+ return True
55
+ except ImportError:
56
+ return False
57
+
58
+
59
+ def refresh_auth(
60
+ url: str,
61
+ data_path: Path,
62
+ *,
63
+ headless: bool = True,
64
+ ) -> AuthResult:
65
+ """Attempt automated OAuth re-authorization via headless browser.
66
+
67
+ 1. Load stored browser state (cookies from previous sessions)
68
+ 2. Navigate to the OAuth URL
69
+ 3. If authorize button is visible → click it → extract key
70
+ 4. If IdP login form is shown → abort (manual login required)
71
+ 5. Save updated browser state on success
72
+
73
+ Returns :class:`AuthResult` with success status and optional key.
74
+ """
75
+ if not _check_playwright():
76
+ return AuthResult(
77
+ success=False,
78
+ error="Playwright not installed. Run: pip install playwright && playwright install chromium",
79
+ )
80
+
81
+ state = load_state(data_path)
82
+ storage_state = to_playwright_context(state) if state.cookies else None
83
+
84
+ try:
85
+ with sync_playwright() as pw:
86
+ browser = pw.chromium.launch(headless=headless)
87
+ try:
88
+ context = browser.new_context(
89
+ storage_state=storage_state,
90
+ ) if storage_state else browser.new_context()
91
+
92
+ page = context.new_page()
93
+ page.set_default_timeout(_NAVIGATION_TIMEOUT_MS)
94
+
95
+ logger.debug("Navigating to OAuth URL: %s", url)
96
+ page.goto(url, wait_until="networkidle")
97
+
98
+ # Detect page state
99
+ result = _handle_auth_page(page)
100
+
101
+ if result.success:
102
+ # Save updated browser context
103
+ ctx_data = context.storage_state()
104
+ new_state = from_playwright_context(ctx_data)
105
+ save_state(data_path, new_state)
106
+ logger.info("OAuth refresh succeeded")
107
+
108
+ context.close()
109
+ return result
110
+
111
+ finally:
112
+ browser.close()
113
+
114
+ except PWTimeout:
115
+ return AuthResult(success=False, error="OAuth page timed out")
116
+ except Exception as exc:
117
+ logger.warning("Browser automation failed: %s", exc)
118
+ return AuthResult(success=False, error=str(exc))
119
+
120
+
121
+ def _handle_auth_page(page) -> AuthResult:
122
+ """Detect and handle the OAuth authorization page.
123
+
124
+ Looks for an authorize button or a login form. If the IdP session
125
+ is still valid, the authorize button should be visible. If not,
126
+ a login form (Google, GitHub, etc.) will be shown instead.
127
+ """
128
+
129
+ # Check for authorize/approve button (Anthropic consent screen)
130
+ authorize_selectors = [
131
+ 'button:has-text("Authorize")',
132
+ 'button:has-text("Allow")',
133
+ 'button:has-text("Approve")',
134
+ 'input[type="submit"][value*="Authorize"]',
135
+ 'input[type="submit"][value*="Allow"]',
136
+ ]
137
+
138
+ for selector in authorize_selectors:
139
+ try:
140
+ button = page.wait_for_selector(selector, timeout=3000)
141
+ if button and button.is_visible():
142
+ logger.debug("Found authorize button: %s", selector)
143
+ button.click()
144
+
145
+ # Wait for redirect after authorization
146
+ page.wait_for_load_state("networkidle")
147
+
148
+ # Try to extract the authorization key from the page
149
+ key = _extract_key(page)
150
+ return AuthResult(success=True, key=key)
151
+ except PWTimeout:
152
+ continue
153
+
154
+ # Check for IdP login form (Google, GitHub, etc.)
155
+ login_indicators = [
156
+ 'input[type="email"]',
157
+ 'input[type="password"]',
158
+ '#identifierId', # Google
159
+ '#login_field', # GitHub
160
+ ]
161
+
162
+ for selector in login_indicators:
163
+ try:
164
+ el = page.wait_for_selector(selector, timeout=2000)
165
+ if el and el.is_visible():
166
+ return AuthResult(
167
+ success=False,
168
+ error="IdP session expired — manual login required",
169
+ )
170
+ except PWTimeout:
171
+ continue
172
+
173
+ # Neither authorize nor login found
174
+ page_text = page.text_content("body") or ""
175
+ logger.debug("Unrecognized page state. Body preview: %s", page_text[:200])
176
+ return AuthResult(
177
+ success=False,
178
+ error="Unrecognized OAuth page — manual login required",
179
+ )
180
+
181
+
182
+ def auto_refresh_auth(
183
+ claude_path: str,
184
+ data_path: Path,
185
+ *,
186
+ headless: bool = True,
187
+ login_timeout: float = 60,
188
+ ) -> AuthResult:
189
+ """Orchestrate fully automated OAuth: start login, parse URL, automate browser.
190
+
191
+ 1. Start ``claude auth login`` capturing stdout
192
+ 2. Parse the OAuth URL from the output
193
+ 3. Use :func:`refresh_auth` to navigate with stored cookies
194
+ 4. If the browser clicks "Authorize", the redirect completes the login
195
+ 5. Wait for ``claude auth login`` to finish
196
+
197
+ Returns :class:`AuthResult` indicating success or failure.
198
+ """
199
+ import subprocess
200
+ import threading
201
+
202
+ from kanibako.auth_parser import parse_auth_output
203
+
204
+ if not _check_playwright():
205
+ return AuthResult(
206
+ success=False,
207
+ error="Playwright not installed",
208
+ )
209
+
210
+ # Start claude auth login, capturing output to find the URL.
211
+ try:
212
+ proc = subprocess.Popen(
213
+ [claude_path, "auth", "login"],
214
+ stdout=subprocess.PIPE,
215
+ stderr=subprocess.STDOUT,
216
+ stdin=subprocess.PIPE,
217
+ text=True,
218
+ )
219
+ except (FileNotFoundError, OSError) as exc:
220
+ return AuthResult(success=False, error=f"Failed to start auth: {exc}")
221
+
222
+ # Read output lines until we find an OAuth URL or the process exits.
223
+ output_lines: list[str] = []
224
+ url: str | None = None
225
+ code: str | None = None
226
+
227
+ def _read_output() -> None:
228
+ nonlocal url, code
229
+ assert proc.stdout is not None
230
+ for line in proc.stdout:
231
+ output_lines.append(line)
232
+ if url is None:
233
+ prompt = parse_auth_output("".join(output_lines))
234
+ if prompt:
235
+ url = prompt.url
236
+ code = prompt.code
237
+ return # Got what we need
238
+
239
+ reader = threading.Thread(target=_read_output, daemon=True)
240
+ reader.start()
241
+ reader.join(timeout=15)
242
+
243
+ if not url:
244
+ # No URL found — kill process and bail.
245
+ proc.kill()
246
+ proc.wait()
247
+ return AuthResult(success=False, error="No OAuth URL found in auth output")
248
+
249
+ logger.info("Auto-auth: navigating to %s", url)
250
+ result = refresh_auth(url, data_path, headless=headless)
251
+
252
+ if result.success:
253
+ # If we got a key and the process is waiting for input, feed it.
254
+ key = result.key or code
255
+ if key and proc.poll() is None and proc.stdin:
256
+ try:
257
+ proc.stdin.write(key + "\n")
258
+ proc.stdin.flush()
259
+ except OSError:
260
+ pass
261
+
262
+ # Wait for claude auth login to complete.
263
+ try:
264
+ proc.wait(timeout=login_timeout)
265
+ except subprocess.TimeoutExpired:
266
+ proc.kill()
267
+ proc.wait()
268
+ else:
269
+ proc.kill()
270
+ proc.wait()
271
+
272
+ return result
273
+
274
+
275
+ def _extract_key(page) -> str | None:
276
+ """Try to extract the authorization key from the post-authorize page."""
277
+ # Look for common patterns: displayed code, input field with key, etc.
278
+ key_selectors = [
279
+ 'code',
280
+ '.authorization-code',
281
+ 'input[readonly]',
282
+ 'pre',
283
+ ]
284
+
285
+ for selector in key_selectors:
286
+ try:
287
+ el = page.wait_for_selector(selector, timeout=3000)
288
+ if el:
289
+ text = el.text_content() or el.get_attribute("value") or ""
290
+ text = text.strip()
291
+ if text and len(text) < 200: # reasonable key length
292
+ return text
293
+ except Exception:
294
+ continue
295
+
296
+ return None
@@ -0,0 +1,51 @@
1
+ """Parse Claude Code auth command output to extract OAuth URLs and codes.
2
+
3
+ Used by the automated OAuth refresh flow to extract the authorization URL
4
+ from ``claude auth login`` output and feed back the authorization code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass
14
+ class AuthPrompt:
15
+ """Parsed auth prompt from ``claude auth login`` output."""
16
+
17
+ url: str
18
+ code: str | None = None # verification code (if displayed)
19
+
20
+
21
+ # URL pattern: look for anthropic or console.anthropic URLs
22
+ _URL_RE = re.compile(
23
+ r"(https?://(?:console\.anthropic\.com|claude\.ai)[^\s\"'<>]+)",
24
+ )
25
+
26
+ # Verification code: typically 4-8 character alphanumeric.
27
+ # Matches patterns like "code: ABCD1234", "code is: XY12AB", "key = WXYZ99"
28
+ # Requires a colon or equals as separator to avoid false positives.
29
+ _CODE_RE = re.compile(
30
+ r"(?:verification\s+code|code|key)\s*(?:is)?[:=]\s*([A-Z0-9]{4,8})\b",
31
+ re.IGNORECASE,
32
+ )
33
+
34
+
35
+ def parse_auth_output(output: str) -> AuthPrompt | None:
36
+ """Extract OAuth URL and optional code from claude auth login output.
37
+
38
+ Returns *None* if no recognizable URL is found.
39
+ """
40
+ url_match = _URL_RE.search(output)
41
+ if not url_match:
42
+ return None
43
+
44
+ url = url_match.group(1)
45
+
46
+ code: str | None = None
47
+ code_match = _CODE_RE.search(output)
48
+ if code_match:
49
+ code = code_match.group(1)
50
+
51
+ return AuthPrompt(url=url, code=code)
kanibako/baseline.py ADDED
@@ -0,0 +1,124 @@
1
+ """Image-baseline manifest: the universal in-box runtime tool contract.
2
+
3
+ The baseline is a mapping of ``apt-package-name -> [executables]``. The shipped
4
+ default lives in :mod:`kanibako.data` (``image-baseline.yaml``); site and user
5
+ overlays are merged on top **additively** (the scoped-shares spirit in
6
+ :mod:`kanibako.settings_shares`): later layers add new packages or override an
7
+ existing package's executable list. Precedence, least- to most-specific:
8
+
9
+ built-in default < /etc/kanibako/image-baseline.yaml < ~/.config/kanibako/image-baseline.yaml
10
+
11
+ The package NAME is what ``apt-get install`` consumes (auto-install assumes
12
+ apt/debian); the EXECUTABLE values are what ``command -v`` probes (verify is
13
+ package-manager-agnostic). They differ on purpose (e.g. ``ripgrep`` -> ``rg``).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib.resources
19
+ import sys
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+
23
+ import yaml
24
+
25
+ from kanibako.paths import xdg
26
+
27
+ # Filename used both for the shipped default (in kanibako.data) and the overlays.
28
+ BASELINE_FILENAME = "image-baseline.yaml"
29
+
30
+
31
+ def _read_doc(path: Path) -> dict[str, list[str]]:
32
+ """Parse a baseline YAML file into ``{package: [executables]}``.
33
+
34
+ Tolerates a missing/empty file (returns ``{}``). Normalizes each value to
35
+ a list of strings; a bare string value becomes a single-element list.
36
+ """
37
+ if not path.is_file():
38
+ return {}
39
+ raw = yaml.safe_load(path.read_text()) or {}
40
+ if not isinstance(raw, dict):
41
+ return {}
42
+ result: dict[str, list[str]] = {}
43
+ for pkg, exes in raw.items():
44
+ if exes is None:
45
+ result[str(pkg)] = []
46
+ elif isinstance(exes, str):
47
+ result[str(pkg)] = [exes]
48
+ else:
49
+ result[str(pkg)] = [str(e) for e in exes]
50
+ return result
51
+
52
+
53
+ def _shipped_default() -> dict[str, list[str]]:
54
+ """Read the bundled default baseline shipped as package data."""
55
+ ref = importlib.resources.files("kanibako.data").joinpath(BASELINE_FILENAME)
56
+ return _read_doc(Path(str(ref)))
57
+
58
+
59
+ def _overlay_paths() -> list[Path]:
60
+ """Overlay locations, in additive merge order (machine then user)."""
61
+ config_home = xdg("XDG_CONFIG_HOME", ".config")
62
+ return [
63
+ Path("/etc/kanibako") / BASELINE_FILENAME,
64
+ config_home / "kanibako" / BASELINE_FILENAME,
65
+ ]
66
+
67
+
68
+ def load_baseline() -> dict[str, list[str]]:
69
+ """Return the merged baseline ``{package: [executables]}``.
70
+
71
+ Starts from the shipped default, then additively merges the machine
72
+ (``/etc``) and user (``~/.config``) overlays if present: a later layer adds
73
+ a new package or replaces an existing package's executable list. Missing
74
+ overlay files are treated as empty levels.
75
+ """
76
+ merged = _shipped_default()
77
+ for path in _overlay_paths():
78
+ merged.update(_read_doc(path))
79
+ return merged
80
+
81
+
82
+ def packages() -> list[str]:
83
+ """Return the baseline package names in sorted, stable order."""
84
+ return sorted(load_baseline())
85
+
86
+
87
+ def executables() -> list[tuple[str, str]]:
88
+ """Return ``(package, executable)`` pairs, sorted by package then executable."""
89
+ pairs: list[tuple[str, str]] = []
90
+ baseline = load_baseline()
91
+ for pkg in sorted(baseline):
92
+ for exe in baseline[pkg]:
93
+ pairs.append((pkg, exe))
94
+ return pairs
95
+
96
+
97
+ def verify(probe: Callable[[str], bool]) -> list[tuple[str, str]]:
98
+ """Return the ``(package, executable)`` pairs whose executable is missing.
99
+
100
+ *probe* answers "is this executable present?" (typically a ``command -v``
101
+ check inside an image). An empty result means the baseline is satisfied.
102
+ """
103
+ return [(pkg, exe) for pkg, exe in executables() if not probe(exe)]
104
+
105
+
106
+ def install_command(pkgs: list[str]) -> list[str]:
107
+ """Build the apt-get install argv for *pkgs* (debian).
108
+
109
+ Returns the full argv (``apt-get install -y --no-install-recommends ...``).
110
+ The caller decides where it runs (host vs. in-box) and on non-debian
111
+ systems should skip it (see :func:`warn_non_debian`).
112
+ """
113
+ return [
114
+ "apt-get", "install", "-y", "--no-install-recommends", *pkgs,
115
+ ]
116
+
117
+
118
+ def warn_non_debian() -> None:
119
+ """Emit a stderr warning that auto-install only supports apt/debian."""
120
+ print(
121
+ "kanibako baseline: automatic install assumes apt/debian; "
122
+ "install the baseline packages with your distro's package manager.",
123
+ file=sys.stderr,
124
+ )