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.
- kanibako/__init__.py +3 -0
- kanibako/__main__.py +6 -0
- kanibako/_atomic.py +62 -0
- kanibako/auth_browser.py +296 -0
- kanibako/auth_parser.py +51 -0
- kanibako/baseline.py +124 -0
- kanibako/browser_sidecar.py +183 -0
- kanibako/browser_state.py +103 -0
- kanibako/bun_sea.py +144 -0
- kanibako/cli.py +346 -0
- kanibako/commands/__init__.py +0 -0
- kanibako/commands/archive.py +228 -0
- kanibako/commands/baseline_cmd.py +235 -0
- kanibako/commands/box/__init__.py +25 -0
- kanibako/commands/box/_duplicate.py +493 -0
- kanibako/commands/box/_lifecycle.py +1396 -0
- kanibako/commands/box/_parser.py +1114 -0
- kanibako/commands/clean.py +166 -0
- kanibako/commands/crab_cmd.py +480 -0
- kanibako/commands/diagnose.py +497 -0
- kanibako/commands/fork_cmd.py +51 -0
- kanibako/commands/helper_cmd.py +669 -0
- kanibako/commands/image.py +1280 -0
- kanibako/commands/install.py +151 -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 +1877 -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 +881 -0
- kanibako/config.py +584 -0
- kanibako/config_interface.py +575 -0
- kanibako/config_io.py +40 -0
- kanibako/container.py +539 -0
- kanibako/containerfiles.py +58 -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/containers/tmux.conf +12 -0
- kanibako/crabs.py +89 -0
- kanibako/data/__init__.py +0 -0
- kanibako/data/image-baseline.yaml +29 -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 +548 -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 +1559 -0
- kanibako/plugins/__init__.py +10 -0
- kanibako/registry.py +71 -0
- kanibako/rig_bundle.py +121 -0
- kanibako/rig_meta.py +93 -0
- kanibako/rig_registry.py +134 -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/shells.py +186 -0
- kanibako/snapshots.py +281 -0
- kanibako/targets/__init__.py +173 -0
- kanibako/targets/base.py +297 -0
- kanibako/targets/no_agent.py +58 -0
- kanibako/templates.py +60 -0
- kanibako/templates_image.py +225 -0
- kanibako/tweakcc.py +140 -0
- kanibako/tweakcc_cache.py +171 -0
- kanibako/utils.py +136 -0
- kanibako/workset.py +669 -0
- kanibako_cli-1.5.0.dist-info/METADATA +15 -0
- kanibako_cli-1.5.0.dist-info/RECORD +91 -0
- kanibako_cli-1.5.0.dist-info/WHEEL +5 -0
- kanibako_cli-1.5.0.dist-info/entry_points.txt +5 -0
- kanibako_cli-1.5.0.dist-info/licenses/LICENSE.md +594 -0
- kanibako_cli-1.5.0.dist-info/top_level.txt +1 -0
kanibako/__init__.py
ADDED
kanibako/__main__.py
ADDED
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)
|
kanibako/auth_browser.py
ADDED
|
@@ -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
|
kanibako/auth_parser.py
ADDED
|
@@ -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
|
+
)
|