magehand 0.2.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.
magehand/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """magehand: the developer CLI for building apps on the homelab."""
magehand/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from magehand.cli import main
2
+
3
+ main()
magehand/cli.py ADDED
@@ -0,0 +1,1107 @@
1
+ """magehand: build homelab apps from anywhere, the same way everywhere.
2
+
3
+ The developer CLI for apps on the homelab, used by the owner (Claude Code on
4
+ the MacBook) and by OpenClaw's coding workers:
5
+
6
+ magehand guide [topic ["section"]]
7
+ the platform guide for apps (live from homelab-apps)
8
+ magehand guide search WORDS the guide sections that answer a question
9
+ magehand app [name] this app's blocks, env vars and URLs
10
+ magehand check [name] [--code DIR] [--apps-dir DIR] [--manifests-only]
11
+ the platform's rules for this app's code and
12
+ deployment (run before every push; CI runs it too)
13
+ magehand login sign in to OpenBao through authentik (passkey)
14
+ magehand dev up|down|status local containers for the app's postgres/redis blocks
15
+ magehand run [--no-proxy] -- CMD...
16
+ run CMD with the env the app's pod gets; secrets
17
+ come from OpenBao and local containers, never disk
18
+ magehand run --container [--no-build] [-- CMD...]
19
+ build the repo's Dockerfile and run the image as
20
+ its pod runs (read-only, non-root, no capabilities)
21
+ magehand setup [--undo] once per machine: GitHub, Docker, *.lab names, sign-in,
22
+ the homelab-app skill for coding agents
23
+ magehand skill [--install] that skill (when Claude Code and Codex use magehand)
24
+ magehand doctor check GitHub access, homelab reach, login, Docker
25
+ magehand version this version (upgrade: uv tool upgrade magehand)
26
+
27
+ Private by design: it talks only to GitHub and the private *.lab network,
28
+ never to a public homelab endpoint. Everyday commands assume they can
29
+ connect; `magehand setup` checks that once per machine and, if needed, points
30
+ this Mac's *.lab lookups at the homelab's DNS. Its OpenBao role `dev` can
31
+ only read the LiteLLM keys of agent apps (homelab
32
+ platform/openbao/policies/dev-read.hcl).
33
+ """
34
+ import base64
35
+ import http.client
36
+ import http.server
37
+ import json
38
+ import os
39
+ import pathlib
40
+ import re
41
+ import secrets
42
+ import shutil
43
+ import signal
44
+ import socket
45
+ import subprocess # nosec B404 # runs docker, git, gh and the user's own command
46
+ import sys
47
+ import threading
48
+ import time
49
+ import urllib.error
50
+ import urllib.parse
51
+ import urllib.request
52
+ import webbrowser
53
+ from importlib.metadata import PackageNotFoundError, version as package_version
54
+
55
+ import yaml
56
+
57
+ HOMELAB_REPO = 'davidlarrimore/homelab'
58
+ APPS_REPO = 'davidlarrimore/homelab-apps'
59
+ OPENBAO = os.environ.get('MAGEHAND_OPENBAO', 'https://openbao.lab.davidlarrimore.com')
60
+ LAB = 'lab.davidlarrimore.com'
61
+ HOME = pathlib.Path.home()
62
+ STATE = pathlib.Path(os.environ.get('XDG_STATE_HOME', HOME / '.local/state')) / 'magehand'
63
+ CACHE = pathlib.Path(os.environ.get('XDG_CACHE_HOME', HOME / '.cache')) / 'magehand'
64
+ KEYCHAIN_SERVICE = 'magehand'
65
+ CALLBACK = 'http://localhost:8250/oidc/callback'
66
+ PROXY_PORT = int(os.environ.get('MAGEHAND_PROXY_PORT', '8080'))
67
+ # Block type -> the Secret its chart hands over (platform/building-blocks/*).
68
+ NOT_SET_UP = "this machine isn't set up for the homelab: run `magehand setup`"
69
+ RESOLVER = pathlib.Path('/etc/resolver') / LAB
70
+ SECRET_NAME = {'postgres': '{}-connection', 'redis': '{}-connection', 'llm': '{}-connection', 'secret': '{}'}
71
+ # A homelab-apps checkout to read instead of GitHub (CI has one; `magehand check --apps-dir`).
72
+ APPS_DIR = os.environ.get('MAGEHAND_APPS_DIR')
73
+
74
+
75
+ def die(msg):
76
+ sys.exit(f'magehand: {msg}')
77
+
78
+
79
+ # --- GitHub (read-only: the guide, app manifests, block images) ---------------
80
+
81
+ def github_token():
82
+ token = os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN')
83
+ if token:
84
+ return token
85
+ if shutil.which('gh'):
86
+ out = subprocess.run(['gh', 'auth', 'token'], capture_output=True, text=True) # nosec B603 B607
87
+ if out.returncode == 0 and out.stdout.strip():
88
+ return out.stdout.strip()
89
+ die('no GitHub access: run `gh auth login` (or set GITHUB_TOKEN)')
90
+
91
+
92
+ def github_file(repo, path, required=True):
93
+ """A file on main, cached for offline use; None if it doesn't exist."""
94
+ if repo == APPS_REPO and APPS_DIR:
95
+ local = pathlib.Path(APPS_DIR) / path
96
+ if local.is_file():
97
+ return local.read_text()
98
+ if required:
99
+ die(f'{local} not found')
100
+ return None
101
+ cached = CACHE / repo / path
102
+ req = urllib.request.Request(
103
+ f'https://api.github.com/repos/{repo}/contents/{urllib.parse.quote(path)}?ref=main',
104
+ headers={'Authorization': f'Bearer {github_token()}', 'Accept': 'application/vnd.github.raw',
105
+ 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'magehand'})
106
+ try:
107
+ with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # fixed https URL
108
+ text = resp.read().decode()
109
+ except urllib.error.HTTPError as error:
110
+ if error.code == 404:
111
+ if required:
112
+ die(f'{repo}: {path} not found on main')
113
+ return None
114
+ text = None
115
+ except OSError:
116
+ text = None
117
+ if text is None:
118
+ if cached.is_file():
119
+ print(f'magehand: GitHub unreachable, using the cached {path}', file=sys.stderr)
120
+ return cached.read_text()
121
+ die(f'cannot read {repo}/{path} from GitHub and nothing is cached')
122
+ cached.parent.mkdir(parents=True, exist_ok=True)
123
+ cached.write_text(text)
124
+ return text
125
+
126
+
127
+ def github_dir(repo, path):
128
+ """Names in a directory on main ([] if it doesn't exist)."""
129
+ if repo == APPS_REPO and APPS_DIR:
130
+ local = pathlib.Path(APPS_DIR) / path
131
+ return sorted(child.name for child in local.iterdir()) if local.is_dir() else []
132
+ req = urllib.request.Request(
133
+ f'https://api.github.com/repos/{repo}/contents/{urllib.parse.quote(path)}?ref=main',
134
+ headers={'Authorization': f'Bearer {github_token()}', 'Accept': 'application/vnd.github+json',
135
+ 'User-Agent': 'magehand'})
136
+ try:
137
+ with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # fixed https URL
138
+ return sorted(item['name'] for item in json.load(resp) if isinstance(item, dict))
139
+ except (urllib.error.HTTPError, OSError, ValueError):
140
+ return []
141
+
142
+
143
+ # --- The app ------------------------------------------------------------------
144
+
145
+ def current_app():
146
+ """The app this checkout is: an app repo is named after its app."""
147
+ out = subprocess.run(['git', 'remote', 'get-url', 'origin'], capture_output=True, text=True) # nosec B603 B607
148
+ if out.returncode != 0:
149
+ die('not in a git checkout; pass the app name')
150
+ name = out.stdout.strip().rstrip('/').rsplit('/', 1)[-1].rsplit(':', 1)[-1]
151
+ name = name[:-4] if name.endswith('.git') else name
152
+ if name in ('homelab', 'homelab-apps'):
153
+ die('this is a platform repo; pass the app name')
154
+ return name
155
+
156
+
157
+ def load_app(name):
158
+ spec = yaml.safe_load(github_file(APPS_REPO, f'apps/{name}/app.yaml')) or {}
159
+ services = spec.get('services') or {}
160
+ docs = list(yaml.safe_load_all(github_file(APPS_REPO, f'apps/{name}/base/deployment.yaml')))
161
+ deployment = next((d for d in docs if isinstance(d, dict) and d.get('kind') == 'Deployment'), None)
162
+ if not deployment:
163
+ die(f'apps/{name}/base/deployment.yaml has no Deployment')
164
+ pod = deployment['spec']['template']['spec']
165
+ return {'name': name, 'spec': spec, 'services': services, 'container': pod['containers'][0], 'pod': pod}
166
+
167
+
168
+ def block_of(app, secret_name):
169
+ """(block name, type) whose Secret this is, or (None, None)."""
170
+ for name, svc in app['services'].items():
171
+ kind = (svc or {}).get('type')
172
+ if kind in SECRET_NAME and SECRET_NAME[kind].format(name) == secret_name:
173
+ return name, kind
174
+ return None, None
175
+
176
+
177
+ def env_plan(app):
178
+ """[(VAR, 'value', text) | (VAR, block, type, key)] from the pod spec."""
179
+ plan = []
180
+ for item in app['container'].get('env') or []:
181
+ if 'value' in item:
182
+ plan.append((item['name'], 'value', str(item['value'])))
183
+ continue
184
+ ref = ((item.get('valueFrom') or {}).get('secretKeyRef')) or {}
185
+ block, kind = block_of(app, ref.get('name'))
186
+ plan.append((item['name'], block, kind, ref.get('key')) if block
187
+ else (item['name'], 'unknown', ref.get('name') or '?'))
188
+ return plan
189
+
190
+
191
+ def app_port(app):
192
+ for item in app['container'].get('env') or []:
193
+ if item.get('name') == 'PORT' and 'value' in item:
194
+ return int(item['value'])
195
+ ports = app['container'].get('ports') or []
196
+ return int(ports[0]['containerPort']) if ports else 8000
197
+
198
+
199
+ # --- OpenBao (role dev: read-only LiteLLM keys) ---------------------------------
200
+
201
+ def bao(method, path, token=None, body=None):
202
+ headers = {'Content-Type': 'application/json'}
203
+ if token:
204
+ headers['X-Vault-Token'] = token
205
+ req = urllib.request.Request(f'{OPENBAO}/v1/{path}', method=method, headers=headers,
206
+ data=json.dumps(body).encode() if body is not None else None)
207
+ try:
208
+ with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # OpenBao URL (https, *.lab)
209
+ raw = resp.read()
210
+ return resp.status, (json.loads(raw) if raw.strip() else {})
211
+ except urllib.error.HTTPError as error:
212
+ return error.code, None
213
+ except OSError:
214
+ die(f'cannot reach {OPENBAO}: {NOT_SET_UP}')
215
+
216
+
217
+ def store_token(token, identity):
218
+ if sys.platform == 'darwin':
219
+ subprocess.run(['security', 'add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, # nosec B603 B607
220
+ '-a', 'openbao-token', '-w', token], check=True, capture_output=True)
221
+ else:
222
+ STATE.mkdir(parents=True, exist_ok=True)
223
+ path = STATE / 'openbao-token'
224
+ path.touch(mode=0o600)
225
+ path.write_text(token)
226
+ STATE.mkdir(parents=True, exist_ok=True)
227
+ (STATE / 'identity.json').write_text(json.dumps(identity))
228
+
229
+
230
+ def saved_token():
231
+ if os.environ.get('MAGEHAND_BAO_TOKEN'):
232
+ return os.environ['MAGEHAND_BAO_TOKEN']
233
+ if sys.platform == 'darwin':
234
+ out = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, # nosec B603 B607
235
+ '-a', 'openbao-token', '-w'], capture_output=True, text=True)
236
+ return out.stdout.strip() if out.returncode == 0 else None
237
+ path = STATE / 'openbao-token'
238
+ return path.read_text().strip() if path.is_file() else None
239
+
240
+
241
+ def valid_token():
242
+ token = saved_token()
243
+ if token and bao('GET', 'auth/token/lookup-self', token)[0] == 200:
244
+ return token
245
+ die('not signed in, or the token expired: run `magehand login`')
246
+
247
+
248
+ def identity():
249
+ path = STATE / 'identity.json'
250
+ return json.loads(path.read_text()) if path.is_file() else {}
251
+
252
+
253
+ def cmd_login(_args):
254
+ status, body = bao('POST', 'auth/oidc/oidc/auth_url', body={'role': 'dev', 'redirect_uri': CALLBACK})
255
+ url = ((body or {}).get('data') or {}).get('auth_url')
256
+ if status != 200 or not url:
257
+ die(f'OpenBao gave no login URL (HTTP {status}); is the `dev` OIDC role set up? (scripts/setup-openbao.py)')
258
+ params = {}
259
+
260
+ class Callback(http.server.BaseHTTPRequestHandler):
261
+ def do_GET(self):
262
+ parsed = urllib.parse.urlparse(self.path)
263
+ if parsed.path != '/oidc/callback':
264
+ self.send_response(404)
265
+ self.end_headers()
266
+ return
267
+ params.update(urllib.parse.parse_qsl(parsed.query))
268
+ self.send_response(200)
269
+ self.send_header('Content-Type', 'text/plain')
270
+ self.end_headers()
271
+ self.wfile.write(b'magehand: signed in. Return to the terminal.')
272
+
273
+ def log_message(self, *args):
274
+ pass
275
+
276
+ with http.server.HTTPServer(('localhost', 8250), Callback) as server:
277
+ server.timeout = 5
278
+ print('Sign in through authentik in your browser (passkey)...')
279
+ webbrowser.open(url)
280
+ deadline = time.monotonic() + 300
281
+ while 'code' not in params and time.monotonic() < deadline:
282
+ server.handle_request()
283
+ if 'code' not in params:
284
+ die('sign-in timed out or was refused')
285
+ query = urllib.parse.urlencode({k: params[k] for k in ('state', 'code') if k in params})
286
+ status, body = bao('GET', f'auth/oidc/oidc/callback?{query}')
287
+ if status != 200:
288
+ die(f'OpenBao refused the sign-in (HTTP {status})')
289
+ auth = body['auth']
290
+ meta = auth.get('metadata') or {}
291
+ email = meta.get('email') or auth.get('display_name', '').removeprefix('oidc-')
292
+ who = {'email': email, 'uid': auth.get('entity_id', ''), 'username': email.split('@')[0],
293
+ 'name': meta.get('name') or email.split('@')[0], 'groups': 'homelab-admins'}
294
+ store_token(auth['client_token'], who)
295
+ hours = auth.get('lease_duration', 3600) // 3600
296
+ print(f'Signed in as {email or "?"} (policy {", ".join(auth.get("policies", []))}, {hours}h).')
297
+
298
+
299
+ # --- guide and app --------------------------------------------------------------
300
+
301
+ STOP_WORDS = {'the', 'and', 'for', 'how', 'can', 'what', 'with', 'app', 'apps', 'does', 'into', 'from', 'this',
302
+ 'that', 'use', 'get', 'add', 'make', 'need', 'want', 'should', 'when', 'why', 'are', 'not', 'you'}
303
+
304
+
305
+ def sections(text):
306
+ """[(heading, body)] of a Markdown page, split at its ## and ### headings (the intro is "")."""
307
+ out, heading, lines = [], '', []
308
+ for line in text.splitlines():
309
+ if re.match(r'#{2,3} ', line):
310
+ out.append((heading, '\n'.join(lines).strip()))
311
+ heading, lines = line.lstrip('#').strip(), []
312
+ else:
313
+ lines.append(line)
314
+ out.append((heading, '\n'.join(lines).strip()))
315
+ return [(h, b) for h, b in out if b]
316
+
317
+
318
+ def search_sections(pages, query, limit=3):
319
+ """The best-matching sections [(score, page, heading, body)] for a free-text query."""
320
+ terms = [t for t in re.findall(r'[a-z0-9_]+', query.lower()) if len(t) > 2 and t not in STOP_WORDS]
321
+ scored = []
322
+ for page, text in pages.items():
323
+ for heading, body in sections(text):
324
+ head, low = heading.lower(), body.lower()
325
+ hits = [t for t in terms if t in head or t in low]
326
+ if hits:
327
+ score = len(hits) * 10 + sum(3 * head.count(t) + min(low.count(t), 5) for t in hits)
328
+ scored.append((score, page, heading, body))
329
+ return sorted(scored, key=lambda s: -s[0])[:limit]
330
+
331
+
332
+ def guide_pages(topics):
333
+ pages = {t: github_file(APPS_REPO, f'docs/platform/{t}.md') for t in topics}
334
+ pages['homelab-apps AGENTS.md'] = github_file(APPS_REPO, 'AGENTS.md')
335
+ return pages
336
+
337
+
338
+ def cmd_guide(args):
339
+ topics = [n[:-3] for n in github_dir(APPS_REPO, 'docs/platform') if n.endswith('.md')]
340
+ if not topics: # before docs/platform exists
341
+ print(github_file(APPS_REPO, 'AGENTS.md'))
342
+ return
343
+ topic = args[0] if args else 'README'
344
+ if topic == 'search':
345
+ if len(args) < 2:
346
+ die('usage: magehand guide search WORDS (e.g. magehand guide search web search)')
347
+ hits = search_sections(guide_pages(topics), ' '.join(args[1:]))
348
+ if not hits:
349
+ die('nothing in the guide matches; try other words, `magehand guide` for the topics, or ask the owner '
350
+ '(and say in your PR what you looked for)')
351
+ for _, page, heading, body in hits:
352
+ print(f'=== {page}' + (f', "{heading}"' if heading else '') + ' ===')
353
+ lines = body.splitlines()
354
+ print('\n'.join(lines[:60]) + (f'\n[... {len(lines) - 60} more lines: magehand guide {page} '
355
+ f'"{heading}"]' if len(lines) > 60 else ''))
356
+ print()
357
+ return
358
+ if topic == 'catalog':
359
+ print(github_file(APPS_REPO, 'catalog.yaml'))
360
+ return
361
+ if topic not in topics:
362
+ die(f'no topic {topic!r}; topics: {", ".join(topics)}, catalog; or magehand guide search WORDS')
363
+ text = github_file(APPS_REPO, f'docs/platform/{topic}.md')
364
+ if len(args) > 1: # one section: magehand guide blocks "Web search"
365
+ want = ' '.join(args[1:]).lower()
366
+ found = [(h, b) for h, b in sections(text) if h.lower() == want] or \
367
+ [(h, b) for h, b in sections(text) if want in h.lower()]
368
+ if not found:
369
+ die(f'no section {want!r} in {topic}; sections: {", ".join(h for h, _ in sections(text) if h)}')
370
+ print(f'## {found[0][0]}\n\n{found[0][1]}')
371
+ return
372
+ print(text)
373
+ if not args:
374
+ print(f'\nMore: magehand guide <topic> ["section"] ({", ".join(t for t in topics if t != "README")}, '
375
+ 'catalog), or magehand guide search WORDS')
376
+
377
+
378
+ def cmd_app(args):
379
+ app = load_app(args[0] if args else current_app())
380
+ name = app['name']
381
+ source = (app['spec'].get('source') or {}).get('repo')
382
+ print(f'{name} (code: {"davidlarrimore/" + source if source else "homelab-apps apps/" + name + "/image"};'
383
+ f' deployment: homelab-apps apps/{name})')
384
+ print(f' dev: https://{name}-dev.{LAB}')
385
+ for preview in github_dir(APPS_REPO, f'previews/{name}'):
386
+ if preview.endswith('.yaml'):
387
+ print(f' preview: https://{name}-dev-{preview[:-5]}.{LAB}')
388
+ print('Blocks (app.yaml services:):')
389
+ if not app['services']:
390
+ print(' none; request one with a PR to homelab-apps apps/%s/app.yaml (`magehand guide blocks`)' % name)
391
+ for block, svc in app['services'].items():
392
+ opts = ', '.join(f'{k}={v}' for k, v in (svc or {}).items() if k != 'type')
393
+ print(f' {block}: {(svc or {}).get("type")}{" (" + opts + ")" if opts else ""}')
394
+ print(f'Env (the pod spec; locally: magehand run, app port {app_port(app)}):')
395
+ for entry in env_plan(app):
396
+ if entry[1] == 'value':
397
+ print(f' {entry[0]} = {entry[2]}')
398
+ elif entry[1] == 'unknown':
399
+ print(f' {entry[0]} <- Secret {entry[2]} (not a block; magehand run leaves it unset)')
400
+ else:
401
+ print(f' {entry[0]} <- block {entry[1]} ({entry[2]}) key {entry[3]}')
402
+
403
+
404
+ # --- local containers -------------------------------------------------------------
405
+
406
+ def docker(*args, check=True):
407
+ if not shutil.which('docker'):
408
+ die('docker is required for local blocks (Docker Desktop, OrbStack or colima)')
409
+ return subprocess.run(['docker', *args], capture_output=True, text=True, check=check) # nosec B603 B607
410
+
411
+
412
+ def local_state(app_name):
413
+ path = STATE / f'{app_name}.json'
414
+ return json.loads(path.read_text()) if path.is_file() else {}
415
+
416
+
417
+ def save_local_state(app_name, data):
418
+ STATE.mkdir(parents=True, exist_ok=True)
419
+ path = STATE / f'{app_name}.json'
420
+ path.touch(mode=0o600)
421
+ path.write_text(json.dumps(data, indent=1))
422
+
423
+
424
+ def block_image(kind, svc):
425
+ values = yaml.safe_load(github_file(HOMELAB_REPO, f'platform/building-blocks/{kind}/values.yaml'))
426
+ if kind == 'postgres' and (svc or {}).get('vector') in (True, 'true'):
427
+ return values['pgvector']['image']
428
+ return values['image']
429
+
430
+
431
+ def cmd_dev(args):
432
+ action = args[0] if args else 'status'
433
+ app = load_app(args[1] if len(args) > 1 else current_app())
434
+ state = local_state(app['name'])
435
+ network = f'magehand-{app["name"]}'
436
+ if action == 'up': # blocks and `run --container` share it, like a namespace
437
+ docker('network', 'create', network, check=False)
438
+ for block, svc in app['services'].items():
439
+ kind = (svc or {}).get('type')
440
+ container = f'magehand-{app["name"]}-{block}'
441
+ if kind == 'secret' and action == 'up':
442
+ state.setdefault(block, {'value': secrets.token_urlsafe(36)[:48]})
443
+ if kind not in ('postgres', 'redis'):
444
+ continue
445
+ if action == 'down':
446
+ docker('rm', '-f', '-v', container, check=False)
447
+ state.pop(block, None)
448
+ print(f'{block}: removed')
449
+ continue
450
+ running = docker('inspect', '-f', '{{.State.Running}}', container, check=False).stdout.strip() == 'true'
451
+ if action == 'status':
452
+ print(f'{block} ({kind}): {"running" if running else "not running"}')
453
+ continue
454
+ if not running:
455
+ password = state.get(block, {}).get('password') or secrets.token_urlsafe(24)
456
+ docker('rm', '-f', container, check=False)
457
+ if kind == 'postgres':
458
+ docker('run', '-d', '--name', container, '--network', network, '-p', '127.0.0.1::5432',
459
+ '-e', 'POSTGRES_USER=app',
460
+ '-e', 'POSTGRES_DB=app', '-e', f'POSTGRES_PASSWORD={password}', block_image(kind, svc))
461
+ else:
462
+ docker('run', '-d', '--name', container, '--network', network, '-p', '127.0.0.1::6379',
463
+ block_image(kind, svc),
464
+ 'valkey-server', '--requirepass', password, '--maxmemory', '200mb',
465
+ '--maxmemory-policy', 'allkeys-lru', '--appendonly', 'yes')
466
+ state[block] = {'password': password}
467
+ inner = '5432' if kind == 'postgres' else '6379'
468
+ port = docker('port', container, inner).stdout.strip().splitlines()[0].rsplit(':', 1)[-1]
469
+ state[block]['port'] = port
470
+ print(f'{block} ({kind}): 127.0.0.1:{port}')
471
+ if action == 'up':
472
+ save_local_state(app['name'], state)
473
+ elif action == 'down':
474
+ (STATE / f'{app["name"]}.json').unlink(missing_ok=True)
475
+ docker('network', 'rm', network, check=False)
476
+ elif action != 'status':
477
+ die('usage: magehand dev up|down|status [app]')
478
+
479
+
480
+ def local_connection(kind, block, local, app_name=None):
481
+ """A block's Secret keys for a local run: on the host (published port), or
482
+ with app_name, from a container on the app's Docker network."""
483
+ if block not in local:
484
+ die(f'block {block} ({kind}) has no local container: run `magehand dev up`')
485
+ conn = local[block]
486
+ if kind == 'secret':
487
+ return {'value': conn['value']}
488
+ pw = conn['password']
489
+ inner = '5432' if kind == 'postgres' else '6379'
490
+ host, port = (f'magehand-{app_name}-{block}', inner) if app_name else ('127.0.0.1', conn['port'])
491
+ if kind == 'postgres':
492
+ return {'host': host, 'port': port, 'username': 'app', 'database': 'app', 'password': pw,
493
+ 'uri': f'postgresql://app:{pw}@{host}:{port}/app'}
494
+ return {'host': host, 'port': port, 'password': pw, 'uri': f'redis://:{pw}@{host}:{port}/0'}
495
+
496
+
497
+ # --- run: the secrets adapter ---------------------------------------------------------
498
+
499
+ def dotenv(path):
500
+ """KEY=VALUE lines from the repo's committed .magehand.env (local, non-secret overrides)."""
501
+ values = {}
502
+ if path.is_file():
503
+ for line in path.read_text().splitlines():
504
+ line = line.strip()
505
+ if line and not line.startswith('#') and '=' in line:
506
+ key, value = line.split('=', 1)
507
+ values[key.strip()] = os.path.expandvars(value.strip().strip('"\''))
508
+ return values
509
+
510
+
511
+ def resolve_env(app, container=False):
512
+ """The pod's env, resolved for this machine. On the host, precedence is:
513
+ manifest values < .magehand.env < the caller's environment < secrets
514
+ (OpenBao, local blocks). In a container it is exactly the pod's: manifest
515
+ values and secrets, with blocks reached over the app's Docker network."""
516
+ plan = env_plan(app)
517
+ env = {e[0]: e[2] for e in plan if e[1] == 'value'}
518
+ if not container:
519
+ env.update(dotenv(pathlib.Path('.magehand.env')))
520
+ env.update({k: v for k, v in os.environ.items() if k in env})
521
+ local, fetched, token = local_state(app['name']), {}, None
522
+ for entry in plan:
523
+ if entry[1] in ('value', 'unknown'):
524
+ continue
525
+ var, block, kind, key = entry
526
+ if block not in fetched:
527
+ if kind == 'llm':
528
+ token = token or valid_token()
529
+ path = f'apps/data/{app["name"]}-dev/llm-{block}'
530
+ status, body = bao('GET', path, token)
531
+ if status != 200:
532
+ die(f'cannot read {path} (HTTP {status}); has the broker created the key? (`magehand app`)')
533
+ fetched[block] = body['data']['data']
534
+ else:
535
+ fetched[block] = local_connection(kind, block, local, app['name'] if container else None)
536
+ if key not in fetched[block]:
537
+ die(f'{var}: block {block} has no key {key!r} (keys: {", ".join(sorted(fetched[block]))})')
538
+ env[var] = str(fetched[block][key])
539
+ return env
540
+
541
+
542
+ def start_proxy(app_port_, who):
543
+ """127.0.0.1:PROXY_PORT -> the app, adding the X-authentik-* headers Traefik would."""
544
+ headers = {'X-authentik-uid': who.get('uid', 'local'), 'X-authentik-email': who.get('email', ''),
545
+ 'X-authentik-name': who.get('name', 'Local developer'),
546
+ 'X-authentik-username': who.get('username', 'local'), 'X-authentik-groups': who.get('groups', '')}
547
+
548
+ class Proxy(http.server.BaseHTTPRequestHandler):
549
+ protocol_version = 'HTTP/1.1'
550
+
551
+ def forward(self):
552
+ body = self.rfile.read(int(self.headers.get('Content-Length') or 0))
553
+ out = {k: v for k, v in self.headers.items()
554
+ if not k.lower().startswith('x-authentik-') and k.lower() not in ('connection', 'host')}
555
+ out.update(headers)
556
+ out['Host'] = self.headers.get('Host', f'127.0.0.1:{PROXY_PORT}')
557
+ conn = http.client.HTTPConnection('127.0.0.1', app_port_, timeout=300)
558
+ try:
559
+ conn.request(self.command, self.path, body=body or None, headers=out)
560
+ resp = conn.getresponse()
561
+ data = resp.read()
562
+ except OSError:
563
+ self.send_error(502, 'app not reachable yet')
564
+ return
565
+ finally:
566
+ conn.close()
567
+ self.send_response(resp.status, resp.reason)
568
+ for k, v in resp.getheaders():
569
+ if k.lower() not in ('transfer-encoding', 'connection', 'content-length'):
570
+ self.send_header(k, v)
571
+ self.send_header('Content-Length', str(len(data)))
572
+ self.end_headers()
573
+ self.wfile.write(data)
574
+
575
+ do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = forward
576
+
577
+ def log_message(self, *args):
578
+ pass
579
+
580
+ server = http.server.ThreadingHTTPServer(('127.0.0.1', PROXY_PORT), Proxy)
581
+ threading.Thread(target=server.serve_forever, daemon=True).start()
582
+ return server
583
+
584
+
585
+ LAB_SUFFIX = '.' + LAB
586
+
587
+
588
+ def lab_hosts(env):
589
+ """--add-host entries for the *.lab names in env values. Inside a container,
590
+ public DNS's 127.0.0.1 would be the container: use what the host resolves
591
+ (the Studio's LAN address on the MacBook) or, on the Studio, the host itself."""
592
+ hosts = set()
593
+ for value in env.values():
594
+ host = urllib.parse.urlparse(value).hostname if '://' in value else None
595
+ if host and host.endswith(LAB_SUFFIX):
596
+ hosts.add(host)
597
+ out = []
598
+ for host in sorted(hosts):
599
+ try:
600
+ address = socket.gethostbyname(host)
601
+ except OSError:
602
+ die(f'cannot resolve {host}: {NOT_SET_UP}')
603
+ out.append(f'{host}:{"host-gateway" if address.startswith("127.") else address}')
604
+ return out
605
+
606
+
607
+ def container_args(app, env, image, port, hosts, extra):
608
+ """docker run arguments for the app as its pod runs: read-only, non-root,
609
+ no capabilities. Secrets are passed by name only (-e NAME): docker reads
610
+ the values from its own environment, so they are never in argv or on disk."""
611
+ pod_sc = app['pod'].get('securityContext') or {}
612
+ user = f'{pod_sc.get("runAsUser", 65532)}:{pod_sc.get("runAsGroup", 65532)}'
613
+ args = ['run', '--rm', '--name', f'magehand-{app["name"]}-app', '--network', f'magehand-{app["name"]}',
614
+ '-p', f'127.0.0.1:{port}:{port}', '--read-only', '--tmpfs', '/tmp', '--user', user, # nosec B108 # the container's own tmpfs
615
+ '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges']
616
+ if sys.stdin.isatty():
617
+ args.append('-it')
618
+ for host in hosts:
619
+ args += ['--add-host', host]
620
+ for name in sorted(env):
621
+ args += ['-e', name]
622
+ command = extra or (app['container'].get('command') or []) + (app['container'].get('args') or [])
623
+ if command and not extra:
624
+ args += ['--entrypoint', command[0], image, *command[1:]]
625
+ else:
626
+ args += [image, *command]
627
+ return args
628
+
629
+
630
+ def cmd_run(args):
631
+ proxy = '--no-proxy' not in args
632
+ container = '--container' in args
633
+ build = '--no-build' not in args
634
+ args = [a for a in args if a not in ('--no-proxy', '--container', '--no-build')]
635
+ name = None
636
+ if args and args[0] == '--app':
637
+ name, args = args[1], args[2:]
638
+ if args and args[0] == '--':
639
+ args = args[1:]
640
+ if not args and not container:
641
+ die('usage: magehand run [--no-proxy] [--app NAME] -- CMD... or magehand run --container [--no-build]')
642
+ app = load_app(name or current_app())
643
+ env = resolve_env(app, container=container)
644
+ print(f'magehand: {app["name"]} env: {", ".join(sorted(env))}', file=sys.stderr)
645
+ if proxy:
646
+ port = int(env.get('PORT') or app_port(app))
647
+ start_proxy(port, identity())
648
+ print(f'magehand: open http://127.0.0.1:{PROXY_PORT} (signed in as '
649
+ f'{identity().get("email") or "local"}; the app itself listens on {port})', file=sys.stderr)
650
+ if container:
651
+ image = f'magehand/{app["name"]}:local'
652
+ if build:
653
+ print(f'magehand: docker build -t {image} .', file=sys.stderr)
654
+ if subprocess.run(['docker', 'build', '-t', image, '.']).returncode: # nosec B603 B607
655
+ die('docker build failed')
656
+ docker('network', 'create', f'magehand-{app["name"]}', check=False)
657
+ docker('rm', '-f', f'magehand-{app["name"]}-app', check=False)
658
+ port = int(env.get('PORT') or app_port(app))
659
+ args = ['docker', *container_args(app, env, image, port, lab_hosts(env), args)]
660
+ child = subprocess.Popen(args, env={**os.environ, **env}) # nosec B603 # the user's own command
661
+ for sig in (signal.SIGINT, signal.SIGTERM):
662
+ signal.signal(sig, lambda s, _f: child.send_signal(s))
663
+ sys.exit(child.wait())
664
+
665
+
666
+ # --- doctor and update ---------------------------------------------------------------
667
+
668
+ def cmd_doctor(_args):
669
+ ok = True
670
+
671
+ def check(label, passed, hint=''):
672
+ nonlocal ok
673
+ ok = ok and passed
674
+ print(f'{"ok " if passed else "FAIL"} {label}{"" if passed else " -> " + hint}')
675
+
676
+ try:
677
+ github_token()
678
+ check('GitHub access', github_file(APPS_REPO, 'catalog.yaml', required=False) is not None)
679
+ except SystemExit:
680
+ check('GitHub access', False, '`gh auth login`')
681
+ try:
682
+ reach = urllib.request.urlopen(f'{OPENBAO}/v1/sys/health', timeout=5).status == 200 # nosec B310
683
+ except (OSError, urllib.error.HTTPError):
684
+ reach = False
685
+ check(f'reach {OPENBAO}', reach, NOT_SET_UP)
686
+ token = saved_token()
687
+ check('signed in to OpenBao', bool(reach and token and bao('GET', 'auth/token/lookup-self', token)[0] == 200),
688
+ '`magehand login`')
689
+ check('docker (for postgres/redis blocks)', bool(shutil.which('docker')), 'install Docker Desktop or OrbStack')
690
+ sys.exit(0 if ok else 1)
691
+
692
+
693
+ # --- check: the platform's rules for an app's code and deployment -------------------
694
+ # Run before every push (the homelab-app skill says so) and in CI (homelab-apps
695
+ # build-app.yaml). Each rule is a mistake an app can make about the platform;
696
+ # every finding names the guide section that explains it. Errors fail (exit 1),
697
+ # warnings are for the author to fix or explain in the PR.
698
+
699
+ # The keys each block's Secret holds, when catalog.yaml doesn't list them.
700
+ BLOCK_KEYS = {
701
+ 'postgres': {'keys': ['host', 'port', 'username', 'password', 'database', 'uri']},
702
+ 'redis': {'keys': ['host', 'port', 'password', 'uri']},
703
+ 'secret': {'keys': ['value']},
704
+ 'llm': {'keys': ['api_key', 'base_url', 'models', 'search_url'], 'key_options': {'search_url': 'search'}},
705
+ }
706
+ CODE_SUFFIXES = ('.py', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.go', '.rb', '.php', '.java', '.kt', '.rs')
707
+ SKIP_DIRS = {'.git', 'node_modules', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'vendor', 'target',
708
+ 'tests', 'test', '__tests__', 'spec', 'e2e', 'ui-fixtures', 'fixtures'}
709
+ ENV_READ = re.compile(r'''(?:os\.environ\.get|os\.getenv|environ\.get|os\.Getenv|Deno\.env\.get|ENV\.fetch)\(\s*["']([A-Z][A-Z0-9_]*)["']'''
710
+ r'''|(?:os\.environ|process\.env|ENV)\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]'''
711
+ r'''|process\.env\.([A-Z][A-Z0-9_]*)''')
712
+ # Set by the runtime or the image, not the pod spec.
713
+ RUNTIME_ENV = {'HOME', 'PATH', 'HOSTNAME', 'USER', 'PWD', 'SHELL', 'LANG', 'LC_ALL', 'TZ', 'TERM', 'TMPDIR',
714
+ 'NODE_ENV', 'PYTHONPATH', 'PYTHONUNBUFFERED', 'CI', 'DEBUG'}
715
+ PROVIDER_HOSTS = ('api.openai.com', 'api.anthropic.com', 'generativelanguage.googleapis.com', 'api.mistral.ai',
716
+ 'api.groq.com', 'openrouter.ai', 'api.cohere.com', 'api.cohere.ai', 'api.together.xyz',
717
+ 'api.perplexity.ai', 'api.deepseek.com', 'api.x.ai', 'api.exa.ai', 'api.tavily.com', 'serpapi.com',
718
+ 'api.search.brave.com', 'api.bing.microsoft.com', 'customsearch.googleapis.com')
719
+ PROVIDER_KEY = re.compile(r'^(OPENAI|ANTHROPIC|GEMINI|GOOGLE_AI|MISTRAL|GROQ|OPENROUTER|COHERE|TOGETHER|PERPLEXITY|'
720
+ r'DEEPSEEK|XAI|EXA|TAVILY|SERPAPI|BRAVE)_[A-Z_]*KEY$')
721
+ OWN_AUTH = re.compile(r'''^\s*(?:import|from)\s+(passlib|bcrypt|argon2|flask_login|flask_security|authlib|jose)\b'''
722
+ r'''|require\(\s*["'](bcrypt|bcryptjs|passport[\w-]*|next-auth|jsonwebtoken|express-session)["']\s*\)'''
723
+ r'''|from\s+["'](bcrypt|bcryptjs|passport[\w-]*|next-auth|@auth/[\w-]+|jsonwebtoken|express-session)["']''',
724
+ re.MULTILINE)
725
+ LOCAL_DB = re.compile(r'''sqlite3\.connect\(\s*(?!["']:memory:)|better-sqlite3|new\s+sqlite3\.Database\(|gorm\.Open\(\s*sqlite''')
726
+ LAB_URL = re.compile(r'https?://[a-z0-9.-]+\.lab\.davidlarrimore\.com')
727
+
728
+
729
+ def finding(level, where, text, see):
730
+ return {'level': level, 'where': where, 'text': text, 'see': see}
731
+
732
+
733
+ def block_spec(catalog, kind):
734
+ """{'keys': [...], 'key_options': {key: option}} for a block type: catalog.yaml's, else built in."""
735
+ spec = ((catalog or {}).get('blocks') or {}).get(kind) or {}
736
+ return {'keys': spec.get('keys') or BLOCK_KEYS.get(kind, {}).get('keys') or [],
737
+ 'key_options': spec.get('key_options') or BLOCK_KEYS.get(kind, {}).get('key_options') or {}}
738
+
739
+
740
+ def check_manifests(app, catalog):
741
+ """Rules for homelab-apps apps/<app>/: the blocks requested and the pod's env."""
742
+ found = []
743
+ where = f"homelab-apps apps/{app['name']}"
744
+ blocks = (catalog or {}).get('blocks') or {}
745
+ models = {m.get('id') for m in (catalog or {}).get('models') or [] if isinstance(m, dict)}
746
+ for name, svc in app['services'].items():
747
+ svc = svc or {}
748
+ kind = svc.get('type')
749
+ if blocks and kind not in blocks:
750
+ found.append(finding('error', f'{where}/app.yaml', f'block {name}: unknown type {kind!r}; types: '
751
+ f'{", ".join(sorted(blocks))}', 'blocks'))
752
+ continue
753
+ options = blocks.get(kind) or {}
754
+ if svc.get('size') and options.get('sizes') and svc['size'] not in options['sizes']:
755
+ found.append(finding('error', f'{where}/app.yaml', f'block {name}: size {svc["size"]!r} not offered '
756
+ f'({", ".join(options["sizes"])})', 'blocks'))
757
+ if kind == 'llm':
758
+ for model in svc.get('models') or []:
759
+ if models and model not in models:
760
+ found.append(finding('error', f'{where}/app.yaml', f'block {name}: model {model!r} is not in '
761
+ 'the catalog (magehand guide catalog)', 'blocks "Models"'))
762
+ if 'search' in svc and not isinstance(svc['search'], bool):
763
+ found.append(finding('error', f'{where}/app.yaml', f'block {name}: search must be true or false',
764
+ 'blocks "Web search"'))
765
+ container = app['container']
766
+ image = container.get('image') or ''
767
+ if not re.fullmatch(r'ghcr\.io/davidlarrimore/homelab-apps/' + re.escape(app['name']) + r'(:[\w.-]+)?@sha256:[0-9a-f]{64}', image):
768
+ found.append(finding('error', f'{where}/base/deployment.yaml', f'image {image or "(none)"}: an app runs only '
769
+ f'its own image, pinned by digest (ghcr.io/davidlarrimore/homelab-apps/{app["name"]}@sha256:...)',
770
+ 'README "How an app is put together"'))
771
+ for item in container.get('env') or []:
772
+ ref = ((item.get('valueFrom') or {}).get('secretKeyRef')) or {}
773
+ if not ref:
774
+ continue
775
+ var, secret, key = item.get('name'), ref.get('name'), ref.get('key')
776
+ block, kind = block_of(app, secret)
777
+ if not block and secret in app.get('provided', ()):
778
+ continue # the app's own ExternalSecret (homelab-apps AGENTS.md "Secrets")
779
+ if not block:
780
+ found.append(finding('error', f'{where}/base/deployment.yaml', f'env {var}: Secret {secret!r} is neither '
781
+ 'a block\'s nor made by an ExternalSecret in the app\'s manifests, so the pod would '
782
+ 'wait for it forever. Request a block in app.yaml (type secret for a generated value)',
783
+ 'blocks'))
784
+ continue
785
+ if ref.get('optional'):
786
+ found.append(finding('error', f'{where}/base/deployment.yaml', f'env {var}: optional: true on a block key. '
787
+ 'The pod would start with an empty value and keep it for its whole life; use a plain '
788
+ 'secretKeyRef (the pod waits until the block is ready)', 'blocks'))
789
+ spec = block_spec(catalog, kind)
790
+ if spec['keys'] and key not in spec['keys']:
791
+ found.append(finding('error', f'{where}/base/deployment.yaml', f'env {var}: block {block} ({kind}) has no '
792
+ f'key {key!r}; keys: {", ".join(spec["keys"])}', 'blocks'))
793
+ option = spec['key_options'].get(key)
794
+ if option and (app['services'].get(block) or {}).get(option) is not True:
795
+ found.append(finding('error', f'{where}/base/deployment.yaml', f'env {var}: key {key} exists only with '
796
+ f'`{option}: true` on block {block}; without it the pod waits forever',
797
+ 'blocks "Web search"' if option == 'search' else 'blocks'))
798
+ for source in container.get('envFrom') or []:
799
+ found.append(finding('warning', f'{where}/base/deployment.yaml', f'envFrom {source}: map each variable with '
800
+ 'env + secretKeyRef instead, so `magehand app` and `magehand run` can see it', 'blocks'))
801
+ return found
802
+
803
+
804
+ def code_files(root):
805
+ for path in sorted(root.rglob('*')):
806
+ rel = path.relative_to(root)
807
+ if any(part in SKIP_DIRS or part.startswith('.') for part in rel.parts[:-1]):
808
+ continue
809
+ name = rel.name
810
+ if (path.is_file() and name.endswith(CODE_SUFFIXES) and not name.startswith('test_')
811
+ and not re.search(r'(_test\.py|\.(test|spec)\.[jt]sx?|_test\.go)$', name)):
812
+ yield rel, path
813
+
814
+
815
+ def line_of(text, index):
816
+ return text.count('\n', 0, index) + 1
817
+
818
+
819
+ def check_code(root, app):
820
+ """Rules for the app's code (the repo checkout); `app` is None before it has a deployment."""
821
+ found = []
822
+ pod_env = {item.get('name') for item in (app['container'].get('env') or [])} if app else set()
823
+ for name in ('Dockerfile', 'ui-check.yaml'):
824
+ if not (root / name).is_file():
825
+ found.append(finding('error', name, 'missing: the build needs it at the repo root',
826
+ 'homelab-apps AGENTS.md "Apps with their own repo"'))
827
+ reported = set()
828
+ for rel, path in code_files(root):
829
+ try:
830
+ text = path.read_text(errors='replace')
831
+ except OSError:
832
+ continue
833
+ defaults = [] # spans of env reads, whose fallback values may name a lab URL
834
+ for match in ENV_READ.finditer(text):
835
+ end = text.find(')', match.end())
836
+ defaults.append((match.start(), end if end != -1 else match.end()))
837
+ var = next(g for g in match.groups() if g)
838
+ where = f'{rel}:{line_of(text, match.start())}'
839
+ if PROVIDER_KEY.match(var):
840
+ found.append(finding('error', where, f'reads {var}: apps never hold provider keys; AI and web search '
841
+ 'go through the llm block (base_url + api_key, search_url)', 'blocks'))
842
+ elif app and var not in pod_env and var not in RUNTIME_ENV and var not in reported:
843
+ reported.add(var)
844
+ found.append(finding('warning', where, f'reads {var}, which the pod doesn\'t set, so the code\'s '
845
+ f'default applies in the cluster. Set it in homelab-apps apps/{app["name"]}/base/'
846
+ 'deployment.yaml env (or drop it); local-only values go in .magehand.env',
847
+ 'README "What the platform provides"'))
848
+ for host in PROVIDER_HOSTS:
849
+ for match in re.finditer(re.escape(host), text):
850
+ found.append(finding('error', f'{rel}:{line_of(text, match.start())}', f'calls {host} directly: '
851
+ 'AI and web search go through the llm block (LiteLLM: base_url, search_url)',
852
+ 'blocks'))
853
+ for match in LAB_URL.finditer(text):
854
+ if any(start <= match.start() <= end for start, end in defaults):
855
+ continue
856
+ found.append(finding('warning', f'{rel}:{line_of(text, match.start())}', f'hardcoded {match.group(0)}: '
857
+ 'read URLs from the env the pod gets (a block\'s base_url/search_url); they differ '
858
+ 'locally and in production', 'blocks'))
859
+ for match in OWN_AUTH.finditer(text):
860
+ lib = next(g for g in match.groups() if g)
861
+ found.append(finding('warning', f'{rel}:{line_of(text, match.start())}', f'{lib}: authentik signs users '
862
+ 'in before a request reaches the app; read the X-authentik-* headers instead of '
863
+ 'building login, sessions or password storage',
864
+ 'homelab-apps AGENTS.md "Knowing who the user is"'))
865
+ for match in LOCAL_DB.finditer(text):
866
+ found.append(finding('warning', f'{rel}:{line_of(text, match.start())}', 'a database file: the pod\'s '
867
+ 'root filesystem is read-only and replaced on every deploy; keep data in a postgres '
868
+ 'block, or on a mounted volume (PVC) if it must be a file',
869
+ 'homelab-apps AGENTS.md "Storage and resources"'))
870
+ return found
871
+
872
+
873
+ def provided_secrets(name):
874
+ """Secrets the app's own manifests make (ExternalSecrets from OpenBao, plain Secrets) in base/ and dev/."""
875
+ names = set()
876
+ for part in ('base', 'dev'):
877
+ for file in github_dir(APPS_REPO, f'apps/{name}/{part}'):
878
+ if not file.endswith(('.yaml', '.yml')):
879
+ continue
880
+ try:
881
+ docs = list(yaml.safe_load_all(github_file(APPS_REPO, f'apps/{name}/{part}/{file}') or ''))
882
+ except yaml.YAMLError:
883
+ continue
884
+ for doc in docs:
885
+ if isinstance(doc, dict) and doc.get('kind') == 'ExternalSecret':
886
+ names.add(((doc.get('spec') or {}).get('target') or {}).get('name')
887
+ or (doc.get('metadata') or {}).get('name'))
888
+ elif isinstance(doc, dict) and doc.get('kind') == 'Secret':
889
+ names.add((doc.get('metadata') or {}).get('name'))
890
+ return names - {None}
891
+
892
+
893
+ def try_load_app(name):
894
+ """load_app (with the Secrets its manifests make), or None when homelab-apps has no deployment for it yet."""
895
+ if github_file(APPS_REPO, f'apps/{name}/app.yaml', required=False) is None:
896
+ return None
897
+ app = load_app(name)
898
+ app['provided'] = provided_secrets(name)
899
+ return app
900
+
901
+
902
+ def cmd_check(args):
903
+ global APPS_DIR
904
+ opts = {'--code': '.', '--apps-dir': None}
905
+ names, manifests_only = [], False
906
+ rest = list(args)
907
+ while rest:
908
+ arg = rest.pop(0)
909
+ if arg in opts and rest:
910
+ opts[arg] = rest.pop(0)
911
+ elif arg == '--manifests-only':
912
+ manifests_only = True
913
+ elif arg.startswith('-'):
914
+ die(f'check: unknown option {arg}; usage: magehand check [NAME] [--code DIR] [--apps-dir DIR] '
915
+ '[--manifests-only]')
916
+ else:
917
+ names.append(arg)
918
+ if opts['--apps-dir']:
919
+ APPS_DIR = opts['--apps-dir']
920
+ name = names[0] if names else current_app()
921
+ catalog = yaml.safe_load(github_file(APPS_REPO, 'catalog.yaml', required=False) or '') or {}
922
+ app = try_load_app(name)
923
+ root = pathlib.Path(opts['--code'])
924
+ found = check_manifests(app, catalog) if app else []
925
+ if not manifests_only:
926
+ found += check_code(root, app)
927
+ print(f'magehand check {name}: code {"(skipped)" if manifests_only else root.resolve()}, deployment '
928
+ + (f'homelab-apps apps/{name}' if app else 'none yet (code rules only)'))
929
+ for f in sorted(found, key=lambda f: (f['level'] != 'error', f['where'])):
930
+ see = f['see'] if f['see'].startswith('homelab-apps') else f"magehand guide {f['see']}"
931
+ print(f"{f['level']:7} {f['where']}: {f['text']}\n -> {see}")
932
+ errors = sum(f['level'] == 'error' for f in found)
933
+ print(f'{len(found)} finding(s), {errors} error(s)' if found else 'no findings')
934
+ sys.exit(1 if errors else 0)
935
+
936
+
937
+ # --- the skill: when coding agents should ask the platform ------------------------
938
+
939
+ SKILL_NAME = 'homelab-app'
940
+ SKILL = """---
941
+ name: homelab-app
942
+ description: Use when writing, changing, reviewing or debugging the code or deployment of a homelab app (a repo made from homelab-app-template, with ui-check.yaml and an AGENTS.md pointing to homelab-apps, or homelab-apps apps/<app>/), or when unsure how the homelab gives an app something (database, cache, AI models, web search, secrets, sign-in, env vars, local runs, deploys). Not for tasks that don't touch app code.
943
+ ---
944
+ # Building a homelab app
945
+
946
+ The homelab decides how an app gets databases, AI, web search, secrets,
947
+ sign-in and deploys. Don't rely on habits from other platforms or guess:
948
+ ask the platform, with magehand (installed with `uv tool install magehand`).
949
+
950
+ 1. Before designing or changing behavior: `magehand app` (in the app's repo).
951
+ It lists the app's blocks, every env var its pod gets and where each comes
952
+ from, and its URLs. Code reads exactly those variables.
953
+ 2. Any "how do I ... on the homelab" question, before answering or coding:
954
+ `magehand guide search <words>` (e.g. `web search`, `database`,
955
+ `who is the user`, `env var`, `production`). Read the sections it prints
956
+ and follow them; `magehand guide` lists the topics.
957
+ 3. Before every push: `magehand check`. Fix every error. Fix each warning, or
958
+ say in the PR why it's fine. Each finding names the guide section to read.
959
+ CI runs the same check.
960
+ 4. Run it as the cluster does: `magehand dev up`, then
961
+ `magehand run -- <command>` (or `magehand run --container`).
962
+ 5. If the guide doesn't answer, say so in the PR or issue (what you searched
963
+ for) instead of inventing a mechanism; the owner adds it to the guide.
964
+
965
+ Facts that override habits: no direct calls to AI or search providers (the
966
+ llm block's base_url and search_url); no own login or passwords (authentik
967
+ adds X-authentik-* headers); the root filesystem is read-only (state goes in
968
+ a block); a block key is never `optional: true`; merging code deploys dev by
969
+ itself (never pin images by hand).
970
+ """
971
+
972
+
973
+ def skill_dirs():
974
+ """Where coding agents on this machine read skills: Claude Code always, Codex if it is installed."""
975
+ dirs = [pathlib.Path(os.environ.get('CLAUDE_CONFIG_DIR') or HOME / '.claude') / 'skills']
976
+ codex = pathlib.Path(os.environ.get('CODEX_HOME') or HOME / '.codex')
977
+ if codex.is_dir():
978
+ dirs.append(codex / 'skills')
979
+ return dirs
980
+
981
+
982
+ def install_skill():
983
+ """Writes the skill (refreshed on every run, so an upgrade of magehand updates it); returns the paths."""
984
+ paths = []
985
+ for base in skill_dirs():
986
+ path = base / SKILL_NAME / 'SKILL.md'
987
+ path.parent.mkdir(parents=True, exist_ok=True)
988
+ path.write_text(SKILL)
989
+ paths.append(path)
990
+ return paths
991
+
992
+
993
+ def cmd_skill(args):
994
+ if args[:1] == ['--install']:
995
+ for path in install_skill():
996
+ print(f'installed {path}')
997
+ return
998
+ print(SKILL)
999
+
1000
+
1001
+ # --- setup: once per machine ---------------------------------------------------------
1002
+
1003
+ def lab_address(host):
1004
+ try:
1005
+ return socket.gethostbyname(host)
1006
+ except OSError:
1007
+ return None
1008
+
1009
+
1010
+ def dns_answer(server, host):
1011
+ """What `server` answers for `host` (A record), or None. Uses dig (macOS)."""
1012
+ if not shutil.which('dig'):
1013
+ return None
1014
+ out = subprocess.run(['dig', '+short', '+time=3', '+tries=1', f'@{server}', host], # nosec B603 B607
1015
+ capture_output=True, text=True)
1016
+ lines = [line for line in out.stdout.split() if line and line[0].isdigit()]
1017
+ return lines[0] if out.returncode == 0 and lines else None
1018
+
1019
+
1020
+ def reachable():
1021
+ try:
1022
+ return urllib.request.urlopen(f'{OPENBAO}/v1/sys/health', timeout=5).status == 200 # nosec B310
1023
+ except (OSError, urllib.error.HTTPError):
1024
+ return False
1025
+
1026
+
1027
+ def sudo_write_resolver(server):
1028
+ """/etc/resolver/<lab domain>: macOS asks `server` for *.lab names only."""
1029
+ print(f'Pointing *.{LAB} lookups (only those) at {server}; macOS asks for your password once.')
1030
+ subprocess.run(['sudo', 'mkdir', '-p', str(RESOLVER.parent)], check=True) # nosec B603 B607
1031
+ subprocess.run(['sudo', 'tee', str(RESOLVER)], input=f'nameserver {server}\n', text=True, # nosec B603 B607
1032
+ stdout=subprocess.DEVNULL, check=True)
1033
+ subprocess.run(['sudo', 'killall', '-HUP', 'mDNSResponder'], check=False) # nosec B603 B607
1034
+
1035
+
1036
+ def cmd_setup(args):
1037
+ host = urllib.parse.urlparse(OPENBAO).hostname
1038
+ if '--undo' in args:
1039
+ if RESOLVER.exists():
1040
+ subprocess.run(['sudo', 'rm', '-f', str(RESOLVER)], check=True) # nosec B603 B607
1041
+ print(f'removed {RESOLVER}')
1042
+ return
1043
+ print('1/5 GitHub')
1044
+ try:
1045
+ github_token()
1046
+ print(' ok')
1047
+ except SystemExit:
1048
+ die('sign in to GitHub first: `gh auth login`, then run `magehand setup` again')
1049
+ print('2/5 Docker (only for apps with database blocks)')
1050
+ print(' ok' if shutil.which('docker') else ' not found: install Docker Desktop or OrbStack when you need it')
1051
+ print(f'3/5 the homelab network (*.{LAB})')
1052
+ if reachable():
1053
+ print(f' ok: {host} -> {lab_address(host)}')
1054
+ elif sys.platform != 'darwin':
1055
+ die(f'{host} is not reachable and only macOS is set up automatically')
1056
+ else:
1057
+ current = lab_address(host)
1058
+ print(f' {host} resolves to {current or "nothing"} here, which is not the homelab.')
1059
+ server = args[args.index('--dns') + 1] if '--dns' in args else \
1060
+ input(' Homelab DNS address (your home gateway, e.g. 192.168.1.1): ').strip()
1061
+ answer = dns_answer(server, host)
1062
+ if not answer or answer.startswith('127.'):
1063
+ die(f'{server} did not answer for {host} with a homelab address ({answer or "no answer"}); '
1064
+ 'is it the right address, and can this Mac reach it right now?')
1065
+ sudo_write_resolver(server)
1066
+ for _ in range(10):
1067
+ if reachable():
1068
+ break
1069
+ time.sleep(1)
1070
+ else:
1071
+ die(f'{host} now resolves via {server} but https still fails; is this Mac on a network that reaches it?')
1072
+ print(f' ok: {host} -> {lab_address(host)} (undo: magehand setup --undo)')
1073
+ print('4/5 sign in')
1074
+ token = saved_token()
1075
+ if token and bao('GET', 'auth/token/lookup-self', token)[0] == 200:
1076
+ print(' ok (already signed in)')
1077
+ else:
1078
+ cmd_login([])
1079
+ print('5/5 the homelab-app skill for coding agents (Claude Code, Codex)')
1080
+ for path in install_skill():
1081
+ print(f' ok: {path}')
1082
+ print('Done. Try `magehand app <name>` or, in an app repo, `magehand run -- <command>`.')
1083
+
1084
+
1085
+ def cmd_version(_args):
1086
+ try:
1087
+ print(package_version('magehand'))
1088
+ except PackageNotFoundError:
1089
+ print('unknown (not installed as a package)')
1090
+ print('upgrade: uv tool upgrade magehand')
1091
+
1092
+
1093
+ COMMANDS = {'guide': cmd_guide, 'app': cmd_app, 'check': cmd_check, 'login': cmd_login, 'dev': cmd_dev,
1094
+ 'run': cmd_run, 'doctor': cmd_doctor, 'setup': cmd_setup, 'skill': cmd_skill, 'version': cmd_version}
1095
+
1096
+
1097
+ def main():
1098
+ if len(sys.argv) > 1 and sys.argv[1] in ('--version', '-V'):
1099
+ sys.argv[1] = 'version'
1100
+ if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
1101
+ print(__doc__.split('Private by design')[0].strip())
1102
+ sys.exit(0 if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help', 'help') else 2)
1103
+ COMMANDS[sys.argv[1]](sys.argv[2:])
1104
+
1105
+
1106
+ if __name__ == '__main__':
1107
+ main()
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.5
2
+ Name: magehand
3
+ Version: 0.2.0
4
+ Summary: The developer CLI for building apps on davidlarrimore's homelab
5
+ Project-URL: Source, https://github.com/davidlarrimore/magehand
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: pyyaml>=6.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # magehand
17
+
18
+ The developer CLI for building apps on [davidlarrimore](https://github.com/davidlarrimore)'s
19
+ homelab, the same way on the owner's MacBook (Claude Code) and in OpenClaw's
20
+ VM (its coding workers). It reads platform state and runs apps locally. It
21
+ never deploys: changes go through pull requests.
22
+
23
+ It is public because it holds nothing sensitive. Every command needs access
24
+ that only the owner has: the private homelab repos on GitHub, the private
25
+ `*.lab` network, and an authentik passkey for
26
+ OpenBao.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ brew install uv # once
32
+ uv tool install magehand
33
+ magehand setup # once per machine
34
+ ```
35
+
36
+ `magehand setup` checks GitHub (`gh auth login` first) and Docker (Docker
37
+ Desktop or OrbStack, for database blocks), makes sure the homelab's `*.lab`
38
+ names resolve (if they don't, it asks for the homelab's DNS address, your home
39
+ gateway, and points only `*.lab` lookups at it; `magehand setup --undo`
40
+ reverses that), then signs you in and installs the `homelab-app` skill for Claude Code (and Codex). Every other command assumes this is done.
41
+ Upgrade with `uv tool upgrade magehand`.
42
+
43
+ ## Commands
44
+
45
+ | Command | What it does |
46
+ | --- | --- |
47
+ | `magehand guide [topic ["section"]]` | The app platform guide, live from homelab-apps `docs/platform/`; `magehand guide catalog` for blocks and models |
48
+ | `magehand guide search WORDS` | The guide sections (homelab-apps `docs/platform/` and `AGENTS.md`) that best match a question, e.g. `magehand guide search web search` |
49
+ | `magehand app [name]` | The app's blocks, every env var its pod gets and where it comes from, its URLs. In an app repo the name is the repo's |
50
+ | `magehand check [name] [--code DIR] [--apps-dir DIR] [--manifests-only]` | The platform's rules for the app's code and its homelab-apps deployment: block keys and options, `optional: true` keys, Secrets that aren't blocks, the image, env the code reads but the pod doesn't set, direct AI/search provider calls, own login, database files. Each finding names the guide section; exit 1 on errors. Run before every push; CI (homelab-apps `build-app.yaml`) runs it too, with `--apps-dir` |
51
+ | `magehand login` | Sign in to OpenBao through authentik (passkey) with role `dev`: read-only access to agent apps' LiteLLM keys, 1h (8h max). The token is kept in the macOS Keychain |
52
+ | `magehand dev up\|down\|status` | Docker containers for the app's `postgres`/`redis` blocks (the blocks' own pinned images) on a per-app network, and local values for `secret` blocks |
53
+ | `magehand run [--no-proxy] -- CMD` | Runs CMD with the env the app's pod gets: values from its Deployment, the LLM key from OpenBao, databases from `dev up`. Precedence: manifest < the repo's `.magehand.env` (committed, non-secret overrides such as `WEB_DIR=./web`) < your shell < secrets. A proxy on `127.0.0.1:8080` adds the `X-authentik-*` headers Traefik would, as you |
54
+ | `magehand run --container [--no-build] [-- CMD]` | Builds the repo's Dockerfile and runs the image as its pod runs: read-only, `/tmp` tmpfs, the pod's user, no capabilities, exactly the pod's env. Secrets go in as `-e NAME` only, never in the image, argv or on disk |
55
+ | `magehand setup [--undo] [--dns ADDRESS]` | Once per machine: GitHub, Docker, `*.lab` name resolution (the only step that may ask for your password), sign-in |
56
+ | `magehand skill [--install]` | The `homelab-app` skill: tells Claude Code and Codex to use `app`, `guide search` and `check` while working on app code. `setup` installs it (`$CLAUDE_CONFIG_DIR` or `~/.claude`, and `$CODEX_HOME` or `~/.codex` if Codex is installed) |
57
+ | `magehand doctor` | Checks GitHub access, that `openbao.lab` is reachable, the sign-in and Docker |
58
+ | `magehand version` | The installed version |
59
+
60
+ ## Development
61
+
62
+ ```sh
63
+ uv venv && uv pip install -e .
64
+ .venv/bin/python -m unittest discover -s tests
65
+ ```
66
+
67
+ Python 3.9+ (the Mac's system Python works). Stdlib plus PyYAML. Releases:
68
+ bump `version` in `pyproject.toml`, merge, then tag `vX.Y.Z`. The release
69
+ workflow builds and publishes to PyPI through trusted publishing (no stored
70
+ token). OpenClaw's VM installs a pinned version from homelab
71
+ `platform/openclaw/vm/versions.env`.
72
+
73
+ Never commit secrets, tokens, IP addresses or email addresses: this repo is
74
+ public.
@@ -0,0 +1,8 @@
1
+ magehand/__init__.py,sha256=3wkVZkTHDEnDYaUSKAAM5MD_1xc-1pNnMsvX3cQsiFw,68
2
+ magehand/__main__.py,sha256=l8JtBc57AqQxlAyQDlLABPUF3kWk8vcf1I3yfK6-XBE,38
3
+ magehand/cli.py,sha256=49SYgpGXUMRA78bE4_BD7_84tZJ71VroDZouOilKvBA,53864
4
+ magehand-0.2.0.dist-info/METADATA,sha256=MsD5O_mJUl_TnQfxL8XttYENL2cebMg1WNpvlvPg1QY,4747
5
+ magehand-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ magehand-0.2.0.dist-info/entry_points.txt,sha256=L60-tMFLrKQ1udzWoZs25GU_6uc0VwzqRxyUuu0CUD8,47
7
+ magehand-0.2.0.dist-info/licenses/LICENSE,sha256=5TMwVQu83KQhhv7Xw4PD37oxVkw-U3IyT78IXKrBb7U,1072
8
+ magehand-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ magehand = magehand.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Larrimore
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.