#!/usr/bin/env python3 """Loadout v1 -- zero-knowledge backup for AI-tool data (macOS CLI client). The server NEVER sees plaintext data or the encryption passphrase. Everything is encrypted on this machine with AES-256-GCM using a key derived (scrypt) from a passphrase that never leaves the Mac. The account password and the backup passphrase are two different secrets. License: source-visible, (c) 2026 Clara Consulting -- loadout.works/license Dependencies: Python 3.9+ stdlib plus the "cryptography" package (only needed for `backup` and `restore`): pip3 install cryptography """ import argparse import getpass import gzip import hmac import glob import hashlib import io import json import os import re import secrets import socket import stat as statmod import shlex import subprocess import sys import tarfile import time import urllib.error import urllib.parse import urllib.request import ssl as _ssl_mod _TLS_CTX = None def _tls_context(): """A verifying TLS context that works even on Pythons shipped without a wired-up CA store (python.org installers before Install Certificates.command). Verification is never disabled -- we only supply certificates.""" global _TLS_CTX if _TLS_CTX is not None: return _TLS_CTX ctx = _ssl_mod.create_default_context() try: if ctx.cert_store_stats().get("x509_ca", 0) == 0: loaded = False for cand in ("/etc/ssl/cert.pem", "/opt/homebrew/etc/ca-certificates/cert.pem", "/usr/local/etc/ca-certificates/cert.pem"): try: ctx.load_verify_locations(cand) loaded = True break except (OSError, _ssl_mod.SSLError): continue if not loaded: try: import certifi ctx.load_verify_locations(certifi.where()) except Exception: pass except Exception: pass _TLS_CTX = ctx return ctx from datetime import datetime, timezone # --------------------------------------------------------------------------- # constants / contract # --------------------------------------------------------------------------- # The Loadout control plane. Overridable with --api / LOADOUT_API; will move # to https://api.loadout.works once the custom domain is wired. DEFAULT_API = "https://3gd5t0qss6.execute-api.us-east-1.amazonaws.com" __VERSION__ = "0.7.7" SITE_URL = "https://www.loadout.works" STATE_PATH = os.path.join(os.path.expanduser("~/.loadout"), "state.json") def _load_state(): try: with open(STATE_PATH, "r", encoding="utf-8") as fh: data = json.load(fh) return data if isinstance(data, dict) else {} except (OSError, ValueError): return {} def _save_state(state): try: os.makedirs(os.path.dirname(STATE_PATH), mode=0o700, exist_ok=True) fd = os.open(STATE_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(state, fh) except OSError: pass # state is an optimization cache; losing it only costs CPU def source_fingerprint(path): """Cheap stat-only fingerprint: 'files:bytes:newest-mtime'. Never opens files.""" if os.path.isfile(path): st = os.stat(path) return "1:%d:%d" % (st.st_size, int(st.st_mtime)) count = total = newest = 0 for root, dirs, files in os.walk(path, onerror=lambda e: None): for f in files: try: st = os.stat(os.path.join(root, f), follow_symlinks=False) count += 1 total += st.st_size newest = max(newest, int(st.st_mtime)) except OSError: pass return "%d:%d:%d" % (count, total, newest) MAGIC = b"LDT1" # blob format: MAGIC + 12-byte nonce + AES-GCM ciphertext AAD = b"loadout-v1" # associated data bound into every blob TIMEOUT = 30 # seconds, all HTTP calls GB = 1024 ** 3 CONFIG_DIR = os.path.expanduser("~/.loadout") CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json") KEYCHAIN_SERVICE = "loadout-vault" # Registry of AI-tool data sources: (name, path, skip_if_larger_gb). # ~/.claude is included in full (the "projects" subdir too). # Model weights (e.g. ~/.ollama, ~/.cache/huggingface) are deliberately # NOT in this registry and must never be added. SOURCES = [ ("claude-code", "~/.claude", 8), ("claude-config", "~/.claude.json", 1), # v1 tars in memory, so keep this conservative; Cursor DBs reach 20GB+. ("cursor-global", "~/Library/Application Support/Cursor/User/globalStorage", 5), ("cursor-mcp", "~/.cursor/mcp.json", 1), ("windsurf-devin", "~/.codeium/windsurf", 8), ("devin-local", "~/.devin", 8), ("vscode-user", "~/Library/Application Support/Code/User", 4), ("codex-cli", "~/.codex", 4), ("antigravity", "~/Library/Application Support/Antigravity/User", 4), ("claude-desktop", "~/Library/Application Support/Claude/claude_desktop_config.json", 1), ("devin-desktop", "~/Library/Application Support/Devin/User", 4), ("lmstudio", "~/.lmstudio", 4), ("continue", "~/.continue", 4), ("aider-history", "~/.aider.chat.history.md", 1), ] # --------------------------------------------------------------------------- # errors (exit codes: 0 success, 1 user error, 2 server/network error) # --------------------------------------------------------------------------- class UserError(Exception): """Something the user can fix (bad input, not logged in, ...). Exit 1.""" class NetError(Exception): """Network / server-side failure. Exit 2.""" class ApiError(Exception): """Non-2xx JSON response from the Loadout API.""" def __init__(self, status, message): super().__init__("HTTP %d: %s" % (status, message)) self.status = status self.message = message def eprint(*args): print(*args, file=sys.stderr) def _say(text): """Incremental progress output on one line.""" sys.stdout.write(text) sys.stdout.flush() # --------------------------------------------------------------------------- # small helpers # --------------------------------------------------------------------------- def human(n): n = float(n) for unit in ("B", "KB", "MB", "GB", "TB"): if n < 1024 or unit == "TB": if unit == "B": return "%d %s" % (int(n), unit) return "%.1f %s" % (n, unit) n /= 1024.0 def parse_iso(s): """Parse an ISO-8601 timestamp; assume UTC when no zone is present.""" if s.endswith("Z"): s = s[:-1] + "+00:00" dt = datetime.fromisoformat(s) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt def fmt_local(iso_string): try: return parse_iso(iso_string).astimezone().strftime("%Y-%m-%d %H:%M") except (ValueError, TypeError): return str(iso_string) def fmt_date(iso_string): try: return parse_iso(iso_string).astimezone().strftime("%B %d, %Y") except (ValueError, TypeError): return str(iso_string) # --------------------------------------------------------------------------- # config (~/.loadout/config.json, chmod 600) # --------------------------------------------------------------------------- def load_config(): try: with open(CONFIG_PATH, "r", encoding="utf-8") as fh: return json.load(fh) except FileNotFoundError: return None except (OSError, ValueError): raise UserError("config file %s is unreadable or corrupt -- " "delete it and run `loadout login` again" % CONFIG_PATH) def save_config(cfg): os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True) # 0600 from the first byte -- the token must never be world-readable, # not even between open() and chmod(). fd = os.open(CONFIG_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(cfg, fh, indent=2) fh.write("\n") def require_config(args): cfg = load_config() if cfg is None: raise UserError("not set up on this Mac yet -- run `loadout signup` " "or `loadout login` first") if getattr(args, "api", None): cfg["api_url"] = args.api for field in ("api_url", "token", "enc_salt"): if not cfg.get(field): raise UserError("config is missing %r -- run `loadout login` again" % field) return cfg def resolve_api_url(args, cfg): url = getattr(args, "api", None) or os.environ.get("LOADOUT_API") \ or (cfg or {}).get("api_url") or DEFAULT_API if not url: raise UserError("no API URL configured -- pass --api https://." "execute-api.us-east-1.amazonaws.com (or set LOADOUT_API)") url = url.rstrip("/") parsed = urllib.parse.urlparse(url) is_local = parsed.hostname in ("localhost", "127.0.0.1") if parsed.scheme != "https" and not is_local: raise UserError("the API URL must use https:// (plain http would send " "your password in cleartext)") return url # --------------------------------------------------------------------------- # HTTP (urllib only, 30s timeouts) # --------------------------------------------------------------------------- def _absorb_refreshed_token(fresh): """Persist a server-rotated token so an unattended agent never expires. Only ever called with a token the API itself just returned over verified TLS; the value replaces the stored one in place.""" try: cfg = load_config() if cfg and cfg.get("token") and cfg["token"] != fresh: cfg["token"] = fresh save_config(cfg) except Exception: pass def http_json(method, url, body=None, token=None): headers = {"Accept": "application/json"} data = None if body is not None: data = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" if token: headers["Authorization"] = "Bearer " + token req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=TIMEOUT, context=_tls_context()) as resp: raw = resp.read() fresh = resp.headers.get("x-loadout-token") if fresh and token and fresh != token: _absorb_refreshed_token(fresh) except urllib.error.HTTPError as e: raw = e.read() try: message = json.loads(raw.decode("utf-8")).get("error") or "HTTP %d" % e.code except (ValueError, AttributeError): message = raw.decode("utf-8", "replace").strip() or "HTTP %d" % e.code raise ApiError(e.code, message) except _ssl_mod.SSLCertVerificationError: raise NetError("your Python cannot verify HTTPS certificates. Fix: " "run 'Install Certificates.command' inside your " "/Applications/Python folder, or 'pip3 install certifi', " "then try again") except (urllib.error.URLError, socket.timeout, TimeoutError, OSError) as e: raise NetError("cannot reach the Loadout API: %s" % getattr(e, "reason", e)) if not raw: return {} try: return json.loads(raw.decode("utf-8")) except ValueError: raise NetError("the server returned an invalid (non-JSON) response") def api(cfg, method, path, body=None, auth=True): token = cfg.get("token") if auth else None if auth and not token: raise UserError("not logged in -- run `loadout login`") return http_json(method, cfg["api_url"].rstrip("/") + path, body, token) def put_blob(url, blob): req = urllib.request.Request( url, data=blob, method="PUT", headers={"Content-Type": "application/octet-stream"}) # Big blobs need a big timeout: a saturated socket buffer can block one # write for minutes on slow uplinks. Scale with size (floor ~200 KB/s), # never below 2 minutes, never above an hour. upload_timeout = min(3600, max(120, len(blob) // (200 * 1024))) try: with urllib.request.urlopen(req, timeout=upload_timeout, context=_tls_context()) as resp: if resp.status not in (200, 201, 204): raise NetError("upload failed with HTTP %d" % resp.status) except urllib.error.HTTPError as e: detail = e.read()[:200].decode("utf-8", "replace") raise NetError("upload failed with HTTP %d: %s" % (e.code, detail.strip())) except _ssl_mod.SSLCertVerificationError: raise NetError("your Python cannot verify HTTPS certificates. Fix: " "run 'Install Certificates.command' inside your " "/Applications/Python folder, or 'pip3 install certifi', " "then try again") except (urllib.error.URLError, socket.timeout, TimeoutError, OSError) as e: raise NetError("upload failed: %s" % getattr(e, "reason", e)) # Presigned URLs must be https to our storage provider -- the URL string comes # from the server, so it is pinned here rather than trusted (SSRF/file:// guard). ALLOWED_DOWNLOAD_HOST_SUFFIXES = (".amazonaws.com", ".r2.cloudflarestorage.com") MAX_BLOB_BYTES = 6 * GB def clean_text(s): """Strip terminal control sequences from server-supplied strings.""" return re.sub(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]", "", str(s)) def get_bytes(url): parsed = urllib.parse.urlparse(url) host = (parsed.hostname or "").lower() if parsed.scheme != "https" or not host.endswith(ALLOWED_DOWNLOAD_HOST_SUFFIXES): raise NetError("refusing download from unexpected location -- the " "server returned a URL outside our storage provider") try: with urllib.request.urlopen(url, timeout=TIMEOUT, context=_tls_context()) as resp: length = resp.headers.get("Content-Length") if length and int(length) > MAX_BLOB_BYTES: raise NetError("download larger than the %d GB blob limit" % (MAX_BLOB_BYTES // GB)) chunks, total = [], 0 while True: chunk = resp.read(8 * 1024 * 1024) if not chunk: break total += len(chunk) if total > MAX_BLOB_BYTES: raise NetError("download exceeded the %d GB blob limit" % (MAX_BLOB_BYTES // GB)) chunks.append(chunk) return b"".join(chunks) except urllib.error.HTTPError as e: raise NetError("download failed with HTTP %d" % e.code) except _ssl_mod.SSLCertVerificationError: raise NetError("your Python cannot verify HTTPS certificates. Fix: " "run 'Install Certificates.command' inside your " "/Applications/Python folder, or 'pip3 install certifi', " "then try again") except (urllib.error.URLError, socket.timeout, TimeoutError, OSError) as e: raise NetError("download failed: %s" % getattr(e, "reason", e)) # --------------------------------------------------------------------------- # client-side crypto # --------------------------------------------------------------------------- def _load_cipher(): try: from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM return AESGCM, InvalidTag except ImportError: raise UserError( 'the "cryptography" package is required for backup/restore.\n' " install it with: pip3 install cryptography") def derive_key(passphrase, enc_salt_hex): try: salt = bytes.fromhex(enc_salt_hex) except ValueError: raise UserError("enc_salt in config is not valid hex -- run `loadout login` again") # 128 * n * r = 32 MiB working memory; raise maxmem above OpenSSL's default cap. if not hasattr(hashlib, "scrypt"): raise UserError( "this Python build lacks scrypt (Apple's /usr/bin/python3 does). " "Reinstall Loadout so it pins a full Python: " "curl -fsSL https://loadout.works/install | bash") return hashlib.scrypt(passphrase.encode("utf-8"), salt=salt, n=32768, r=8, p=1, dklen=32, maxmem=64 * 1024 * 1024) def kdf_check(key): """A fingerprint of the derived key -- not the passphrase, not reversible. Lets us tell 'same passphrase as last time' from 'a typo', so a mistyped passphrase can never quietly produce a backup nobody can restore.""" return hmac.new(key, b"loadout-kdf-check-v1", hashlib.sha256).hexdigest() def assert_same_passphrase(cfg, key, allow_change=False): """Refuse a backup encrypted with a different key than previous ones.""" seen = (cfg or {}).get("kdf_check") now = kdf_check(key) if seen and seen != now and not allow_change: raise UserError( "that is not the passphrase your other backups use. Backups made " "with a different passphrase cannot be restored with this one, so " "Loadout stopped instead of creating one you could not open. " "Try again with your usual passphrase.") if not seen: try: cfg["kdf_check"] = now save_config(cfg) except Exception: pass def encrypt_blob(key, plaintext): """AES-256-GCM with a nonce derived from (key, plaintext) -- SIV-style. A random nonce would make identical plaintext encrypt to different bytes every run, defeating content-addressed dedup. Deriving the nonce from the plaintext digest keeps nonces unique per distinct message (which is what GCM requires) while making encryption deterministic per account key. The only information this reveals to the server is 'this source is unchanged' -- which dedup discloses by design.""" AESGCM, _ = _load_cipher() digest = hashlib.sha256(plaintext).digest() nonce = hmac.new(key, b"loadout-nonce-v1" + digest, hashlib.sha256).digest()[:12] return MAGIC + nonce + AESGCM(key).encrypt(nonce, plaintext, AAD) def decrypt_blob(key, blob): AESGCM, InvalidTag = _load_cipher() if len(blob) < 4 + 12 + 16 or blob[:4] != MAGIC: raise UserError("wrong passphrase or corrupted backup") nonce, ciphertext = blob[4:16], blob[16:] try: return AESGCM(key).decrypt(nonce, ciphertext, AAD) except InvalidTag: raise UserError("wrong passphrase or corrupted backup") # --------------------------------------------------------------------------- # macOS Keychain passphrase cache # --------------------------------------------------------------------------- def keychain_get(): if os.environ.get("LOADOUT_NO_KEYCHAIN"): return None try: proc = subprocess.run( ["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], capture_output=True, text=True, timeout=15) except (OSError, subprocess.TimeoutExpired): return None if proc.returncode != 0: return None value = proc.stdout.rstrip("\n") return value or None def _security_quote(value): return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' def keychain_set(passphrase): if os.environ.get("LOADOUT_NO_KEYCHAIN"): return account = os.environ.get("USER") or getpass.getuser() # Feed the command via `security -i` stdin so the passphrase never # appears in argv (visible to any process via ps). cmd = "add-generic-password -U -a %s -s %s -w %s\n" % ( _security_quote(account), _security_quote(KEYCHAIN_SERVICE), _security_quote(passphrase), ) try: proc = subprocess.run( ["security", "-i"], input=cmd, capture_output=True, text=True, timeout=15) if proc.returncode != 0: eprint("warning: could not save the passphrase to the Keychain; " "you will be prompted again next time") except (OSError, subprocess.TimeoutExpired): eprint("warning: could not reach the macOS Keychain; " "you will be prompted again next time") def keychain_clear(): if os.environ.get("LOADOUT_NO_KEYCHAIN"): return False try: proc = subprocess.run( ["security", "delete-generic-password", "-s", KEYCHAIN_SERVICE], capture_output=True, text=True, timeout=15) return proc.returncode == 0 except (OSError, subprocess.TimeoutExpired): return False def passphrase_saved(): """Is a passphrase available without asking the user?""" try: return bool(keychain_get()) except Exception: return False def get_passphrase(allow_create): """Keychain first; else prompt. First backup prompts twice and caches.""" cached = keychain_get() if cached: return cached if allow_create: print("Choose a backup passphrase. This is NOT your account password.") print("It encrypts your data on this Mac before upload. Loadout never") print("sees it and can NEVER recover it for you -- keep it safe.") while True: p1 = getpass.getpass("passphrase: ") if len(p1) < 12: print("please use at least 12 characters -- this passphrase is " "the only thing protecting your backups") continue p2 = getpass.getpass("confirm passphrase: ") if p1 != p2: print("passphrases do not match -- try again") continue keychain_set(p1) return p1 p = getpass.getpass("backup passphrase: ") if not p: raise UserError("a passphrase is required") return p # --------------------------------------------------------------------------- # sources: sizing + in-memory tar.gz # --------------------------------------------------------------------------- def iter_sources(): for name, raw_path, cap_gb in SOURCES: yield name, os.path.expanduser(raw_path), cap_gb def path_size(path): if not os.path.isdir(path) or os.path.islink(path): try: return os.lstat(path).st_size except OSError: return 0 total = 0 for root, _dirs, files in os.walk(path, onerror=lambda e: None): for fname in files: try: st = os.lstat(os.path.join(root, fname)) except OSError: continue if statmod.S_ISREG(st.st_mode): total += st.st_size return total def _add_tree(tar, path, arcname, skipped): try: tar.add(path, arcname=arcname, recursive=False) except OSError: skipped.append(path) return if os.path.isdir(path) and not os.path.islink(path): try: entries = sorted(os.listdir(path)) except OSError: skipped.append(path) return for entry in entries: _add_tree(tar, os.path.join(path, entry), arcname + "/" + entry, skipped) def tar_source(path): """Deterministic tar.gz of `path` in memory. Returns (bytes, [unreadable]). Determinism matters: identical content must produce identical bytes so the server's content-addressed dedup can skip unchanged sources. gzip normally embeds a timestamp -- pin it to 0.""" arcbase = os.path.basename(path.rstrip("/")) or "data" skipped = [] buf = io.BytesIO() with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gz: with tarfile.open(fileobj=gz, mode="w") as tar: _add_tree(tar, path, arcbase, skipped) return buf.getvalue(), skipped # --------------------------------------------------------------------------- # safe tar extraction (restore never touches the live originals) # --------------------------------------------------------------------------- def safe_extract(tar, dest): dest_real = os.path.realpath(dest) for member in tar.getmembers(): name = member.name parts = name.replace("\\", "/").split("/") if os.path.isabs(name) or name.startswith("/") or ".." in parts: raise UserError("refusing to extract unsafe path in archive: %r" % name) if member.issym() or member.islnk(): link_parts = member.linkname.replace("\\", "/").split("/") if os.path.isabs(member.linkname) or ".." in link_parts: eprint("warning: skipping link with unsafe target: %r -> %r" % (name, member.linkname)) continue if member.isdev(): eprint("warning: skipping device node in archive: %r" % name) continue # Extract one at a time so symlinks laid down by earlier members can't # redirect later members outside dest (realpath sees them on disk). target_parent = os.path.realpath(os.path.dirname(os.path.join(dest, name))) if target_parent != dest_real and \ not target_parent.startswith(dest_real + os.sep): raise UserError("refusing to extract unsafe path in archive: %r" % name) tar.extract(member, dest) # --------------------------------------------------------------------------- # commands # --------------------------------------------------------------------------- def prompt_email(): email = input("email: ").strip().lower() if "@" not in email or "." not in email.split("@")[-1]: raise UserError("that does not look like an email address") return email def cmd_signup(args): cfg = load_config() or {} api_url = resolve_api_url(args, cfg) email = prompt_email() while True: password = getpass.getpass("password (min 8 chars): ") if len(password) < 8: print("password must be at least 8 characters") continue confirm = getpass.getpass("confirm password: ") if password != confirm: print("passwords do not match -- try again") continue break # The encryption salt is generated CLIENT-side and is opaque to the server. enc_salt = secrets.token_hex(16) resp = http_json("POST", api_url + "/v1/signup", {"email": email, "password": password, "enc_salt": enc_salt}) save_config({ "api_url": api_url, "token": resp["token"], "account_id": resp["account_id"], "email": email, "enc_salt": enc_salt, "trial_ends_at": resp.get("trial_ends_at"), }) print("welcome to Loadout, %s" % email) if resp.get("trial_ends_at"): print("your free 30-day trial ends on %s" % fmt_date(resp["trial_ends_at"])) print("next step: loadout backup") def cmd_login(args): cfg = load_config() or {} api_url = resolve_api_url(args, cfg) email = prompt_email() password = getpass.getpass("password: ") try: resp = http_json("POST", api_url + "/v1/login", {"email": email, "password": password}) except ApiError as e: if e.status == 401: raise UserError("wrong email or password") raise # Trust-on-first-use: the enc_salt was generated on THIS machine at signup. # If the server ever returns a different one, deriving a key from it would # silently produce undecryptable (or attacker-chosen) crypto -- refuse. known_salt = (cfg or {}).get("enc_salt") if known_salt and cfg.get("email") == email and resp.get("enc_salt") != known_salt: raise UserError( "SECURITY WARNING: the server returned a different encryption salt " "than the one stored on this Mac. This should never happen -- it can " "indicate a tampered or impersonated server. Not overwriting local " "config. If you truly reset your account, delete ~/.loadout/config.json.") save_config({ "api_url": api_url, "token": resp["token"], "account_id": resp["account_id"], "email": email, "enc_salt": resp["enc_salt"], "trial_ends_at": resp.get("trial_ends_at"), }) print("logged in as %s" % email) if resp.get("trial_ends_at"): print("trial ends on %s" % fmt_date(resp["trial_ends_at"])) def cmd_backup(args): cfg = require_config(args) key = derive_key(get_passphrase(allow_create=True), cfg["enc_salt"]) assert_same_passphrase(cfg, key) device = socket.gethostname() blobs = {} sources_meta = [] fingerprints = {} disabled = set(cfg.get("disabled_sources", [])) state = {} if getattr(args, "full", False) else _load_state() fp_cache = state.get("fingerprints", {}) print("scanning AI-tool data on this Mac...") for name, path, cap_gb in iter_sources(): if name in disabled: print(" %-15s skipped (disabled in dashboard)" % name) continue if not os.path.exists(path): continue # missing sources are skipped silently fp = source_fingerprint(path) fingerprints[name] = fp cached = fp_cache.get(name) if (cached and cached.get("fp") == fp and cached.get("sha256") and cached.get("size")): # Stat-identical to the last backup: reuse the recorded blob # identity without re-reading, re-tarring, or re-encrypting. # The server's dedup already holds this exact blob. sources_meta.append({"name": name, "size": int(cached["size"]), "sha256": cached["sha256"]}) _say(" %-15s %10s unchanged (fingerprint)\n" % (name, human(int(cached["size"])))) continue size = path_size(path) if size > cap_gb * GB and not args.include_huge: eprint("warning: skipping %s -- %s exceeds its %d GB limit " "(use --include-huge to force)" % (name, human(size), cap_gb)) continue _say(" %-15s %10s " % (name, human(size))) tar_bytes, skipped = tar_source(path) _say("encrypting... ") blob = encrypt_blob(key, tar_bytes) sha = hashlib.sha256(blob).hexdigest() blobs[name] = blob sources_meta.append({"name": name, "size": len(blob), "sha256": sha}) _say("ready (%s encrypted)\n" % human(len(blob))) if skipped: eprint(" note: %d unreadable item(s) in %s were left out" % (len(skipped), name)) if not sources_meta: raise UserError("nothing to back up -- no known AI-tool data found on this Mac") resp = api(cfg, "POST", "/v1/snapshots/begin", {"device": device, "sources": sources_meta}) snapshot_id = resp["snapshot_id"] upload_info = {u["name"]: u for u in resp.get("uploads", [])} total = uploaded = 0 for meta in sources_meta: name = meta["name"] info = upload_info.get(name) if info is None: raise NetError("server did not acknowledge source %r" % name) total += meta["size"] if info.get("exists"): _say(" %-15s %10s unchanged -- already backed up\n" % (name, human(meta["size"]))) continue if not info.get("url"): raise NetError("server did not return an upload URL for %r" % name) if name not in blobs: # Fingerprint said unchanged but the server lacks the blob (e.g. # cache from a different account) -- rebuild this one for real. _say(" %-15s rebuilding (server needs it)... " % name) src_path = dict((n, p) for n, p, _ in iter_sources())[name] tar_bytes, _skipped = tar_source(src_path) blobs[name] = encrypt_blob(key, tar_bytes) rebuilt_sha = hashlib.sha256(blobs[name]).hexdigest() if rebuilt_sha != meta["sha256"]: raise UserError("source %r changed during backup -- run " "`loadout backup` again" % name) _say(" %-15s %10s uploading... " % (name, human(meta["size"]))) put_blob(info["url"], blobs[name]) uploaded += meta["size"] _say("done\n") api(cfg, "POST", "/v1/snapshots/commit", {"snapshot_id": snapshot_id}) # Remember what we just protected so the next run can skip unchanged work. fresh = _load_state() cache = fresh.setdefault("fingerprints", {}) for meta in sources_meta: n = meta["name"] if n in fingerprints: cache[n] = {"fp": fingerprints[n], "sha256": meta["sha256"], "size": meta["size"]} fresh["last_backup_at"] = datetime.now(timezone.utc).isoformat() _save_state(fresh) print("snapshot complete: %s (%d source%s, %s protected, %s newly uploaded)" % (snapshot_id, len(sources_meta), "" if len(sources_meta) == 1 else "s", human(total), human(uploaded))) def cmd_snapshots(args): cfg = require_config(args) resp = api(cfg, "GET", "/v1/snapshots") snaps = resp.get("snapshots", []) if not snaps: print("no snapshots yet -- run `loadout backup` to create one") return header = ("SNAPSHOT ID", "CREATED", "DEVICE", "STATUS", "SIZE", "SOURCES") rows = [] for s in snaps: rows.append(( str(s.get("snapshot_id", "?")), fmt_local(s.get("created", "")), str(s.get("device", "")).replace(".local", "")[:18], str(s.get("status", "")), human(int(s.get("total_bytes", 0) or 0)), ",".join(s.get("sources", [])), )) widths = [max(len(header[i]), max(len(r[i]) for r in rows)) for i in range(len(header) - 1)] fmt = " ".join("%%-%ds" % w for w in widths) + " %s" print(fmt % header) for r in rows: print(fmt % r) def cmd_restore(args): cfg = require_config(args) resp = api(cfg, "POST", "/v1/restore", {"snapshot_id": args.snapshot_id}) downloads = resp.get("downloads", []) if not downloads: raise UserError("that snapshot has no restorable sources") key = derive_key(get_passphrase(allow_create=False), cfg["enc_salt"]) dest = os.path.abspath(args.dest or "loadout-restore-%s" % args.snapshot_id) os.makedirs(dest, exist_ok=True) print("restoring into %s" % dest) print("(your live tool folders are never overwritten -- copy files back " "yourself once you have checked them)") for item in downloads: name, url, want_sha = item["name"], item["url"], item["sha256"] # The name comes from the server -- never let it steer the filesystem. if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name or "") or name.startswith("."): raise NetError("server returned an unsafe source name -- aborting restore") _say(" %-15s downloading... " % name) blob = get_bytes(url) if hashlib.sha256(blob).hexdigest() != want_sha: raise NetError("checksum mismatch for %r -- aborting restore" % name) _say("decrypting... ") tar_bytes = decrypt_blob(key, blob) _say("extracting... ") subdir = os.path.join(dest, name) os.makedirs(subdir, exist_ok=True) try: with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:gz") as tar: safe_extract(tar, subdir) except tarfile.TarError as e: raise UserError("could not unpack %r: %s" % (name, e)) _say("done\n") print("restore complete: %s" % dest) def cmd_status(args): cfg = require_config(args) print("account: %s" % cfg.get("email", "(unknown)")) print("api: %s" % cfg["api_url"]) trial_line = "(unknown -- log in again to refresh)" if cfg.get("trial_ends_at"): try: ends = parse_iso(cfg["trial_ends_at"]) remaining = ends - datetime.now(timezone.utc) if remaining.total_seconds() <= 0: trial_line = "expired on %s -- your free month has ended" % \ fmt_date(cfg["trial_ends_at"]) else: days = int(remaining.total_seconds() // 86400) trial_line = ("ends today (%s)" % fmt_date(cfg["trial_ends_at"]) if days == 0 else "%d day%s left (ends %s)" % (days, "" if days == 1 else "s", fmt_date(cfg["trial_ends_at"]))) except ValueError: pass print("trial: %s" % trial_line) try: health = api(cfg, "GET", "/v1/health", auth=False) health_line = "healthy" if health.get("ok") else "responding, but not ok" except (ApiError, NetError) as e: health_line = "UNREACHABLE (%s)" % e print("api health: %s" % health_line) last_line = "none yet -- run `loadout backup`" try: snaps = api(cfg, "GET", "/v1/snapshots").get("snapshots", []) if snaps: def _created(s): return s.get("created", "") latest = max(snaps, key=_created) last_line = "%s (%s, %s)" % (fmt_local(latest.get("created", "")), latest.get("status", "?"), human(int(latest.get("total_bytes", 0) or 0))) except (ApiError, NetError): last_line = "(could not fetch snapshot list)" print("last snapshot: %s" % last_line) # --------------------------------------------------------------------------- # native Mac app: compiled locally with the user's own toolchain, so it is # never quarantined by Gatekeeper and needs no Apple Developer certificate. # About and Check for Updates live in the app menu, where Mac apps put them. # --------------------------------------------------------------------------- _APP_SWIFT = r''' import Cocoa import WebKit let args = CommandLine.arguments if args.count >= 3 && args[1] == "--render-icon" { let dir = args[2] for size in [16, 32, 64, 128, 256, 512, 1024] { let s = CGFloat(size) let img = NSImage(size: NSSize(width: s, height: s)) img.lockFocus() let r = NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: s, height: s), xRadius: s * 0.22, yRadius: s * 0.22) NSColor(red: 0.063, green: 0.082, blue: 0.075, alpha: 1).setFill() r.fill() let para = NSMutableParagraphStyle(); para.alignment = .center let font = NSFont.systemFont(ofSize: s * 0.42, weight: .heavy) let lo = NSMutableAttributedString() lo.append(NSAttributedString(string: "L", attributes: [ .font: font, .paragraphStyle: para, .foregroundColor: NSColor(red: 0.906, green: 0.925, blue: 0.910, alpha: 1)])) lo.append(NSAttributedString(string: "O", attributes: [ .font: font, .paragraphStyle: para, .foregroundColor: NSColor(red: 0.275, green: 0.749, blue: 0.647, alpha: 1)])) let ts = lo.size() lo.draw(in: NSRect(x: 0, y: (s - ts.height) / 2 - s * 0.02, width: s, height: ts.height)) img.unlockFocus() guard let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else { continue } let names = [size == 1024 ? "icon_512x512@2x.png" : "icon_\(size)x\(size).png"] + (size >= 32 && size < 1024 ? ["icon_\(size / 2)x\(size / 2)@2x.png"] : []) for n in names { try? png.write(to: URL(fileURLWithPath: dir + "/" + n)) } } exit(0) } final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var window: NSWindow! var webView: WKWebView! var server: Process? var dashURL: URL? var token = "" func cliPath() -> String { if let env = ProcessInfo.processInfo.environment["LOADOUT_CLI"], !env.isEmpty { return env } return NSHomeDirectory() + "/.loadout/bin/loadout.py" } func startServer(_ then: @escaping () -> Void) { let p = Process() var py = "/usr/bin/env" var pyArgs = ["python3"] let recorded = NSHomeDirectory() + "/.loadout/python" if let rec = try? String(contentsOfFile: recorded, encoding: .utf8) { let path = rec.trimmingCharacters(in: .whitespacesAndNewlines) if !path.isEmpty && FileManager.default.isExecutableFile(atPath: path) { py = path; pyArgs = [] } } p.executableURL = URL(fileURLWithPath: py) p.arguments = pyArgs + [cliPath(), "dashboard", "--headless"] let logDir = NSHomeDirectory() + "/.loadout/logs" try? FileManager.default.createDirectory(atPath: logDir, withIntermediateDirectories: true) let logPath = logDir + "/engine.log" if !FileManager.default.fileExists(atPath: logPath) { FileManager.default.createFile(atPath: logPath, contents: nil) } let logHandle = FileHandle(forWritingAtPath: logPath) logHandle?.seekToEndOfFile() let pipe = Pipe() p.standardOutput = pipe p.standardError = logHandle ?? FileHandle.nullDevice do { try p.run() } catch { self.fail("Could not start the Loadout engine.\n\nIs the CLI installed? Try:\ncurl -fsSL https://loadout.works/install | bash") return } server = p DispatchQueue.global().async { let data = pipe.fileHandleForReading.availableData let line = String(data: data, encoding: .utf8) ?? "" guard let range = line.range(of: "DASHBOARD_URL=") else { DispatchQueue.main.async { self.fail("The Loadout engine did not respond.") } return } let raw = String(line[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) DispatchQueue.main.async { self.dashURL = URL(string: raw + "&shell=1") if let q = URLComponents(string: raw)?.queryItems?.first(where: { $0.name == "t" })?.value { self.token = q } then() } } } func fail(_ msg: String) { let a = NSAlert(); a.messageText = "Loadout"; a.informativeText = msg a.runModal(); NSApp.terminate(nil) } func applicationDidFinishLaunching(_ note: Notification) { buildMenu() window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1080, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) window.title = "Loadout" window.minSize = NSSize(width: 760, height: 520) window.center(); window.delegate = self let conf = WKWebViewConfiguration() webView = WKWebView(frame: window.contentView!.bounds, configuration: conf) webView.autoresizingMask = [.width, .height] window.contentView!.addSubview(webView) window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) startServer { if let u = self.dashURL { self.webView.load(URLRequest(url: u)) } } } func buildMenu() { let main = NSMenu() let appItem = NSMenuItem(); main.addItem(appItem) let app = NSMenu() app.addItem(withTitle: "About Loadout", action: #selector(about), keyEquivalent: "") app.addItem(withTitle: "Check for Updates\u{2026}", action: #selector(checkUpdates), keyEquivalent: "") app.addItem(NSMenuItem.separator()) app.addItem(withTitle: "Hide Loadout", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") app.addItem(NSMenuItem.separator()) app.addItem(withTitle: "Quit Loadout", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") appItem.submenu = app let editItem = NSMenuItem(); main.addItem(editItem) let edit = NSMenu(title: "Edit") edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") editItem.submenu = edit let winItem = NSMenuItem(); main.addItem(winItem) let win = NSMenu(title: "Window") win.addItem(withTitle: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m") win.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") winItem.submenu = win NSApp.mainMenu = main } @objc func about() { let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" let a = NSAlert() a.messageText = "Loadout" a.informativeText = "Version \(v)\n\nZero-knowledge backup for your AI tools.\nYour data is encrypted on this Mac; the server only ever stores ciphertext.\n\n\u{00A9} 2026 Clara Consulting \u{00B7} loadout.works" a.runModal() } @objc func checkUpdates() { guard let base = dashURL, let host = base.host, let port = base.port else { return } var req = URLRequest(url: URL(string: "http://\(host):\(port)/api/update")!) req.httpMethod = "POST" req.setValue(token, forHTTPHeaderField: "X-Loadout-Token") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = "{}".data(using: .utf8) URLSession.shared.dataTask(with: req) { data, _, _ in var msg = "Could not check for updates. Are you online?" var updated = false if let d = data, let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any] { if let err = obj["error"] as? String { msg = err } else if obj["updated"] as? Bool == true { updated = true msg = "Updated to version \(obj["latest"] as? String ?? "latest").\n\nLoadout will reload now." } else { msg = "You're up to date (version \(obj["current"] as? String ?? "?"))." } } DispatchQueue.main.async { let a = NSAlert(); a.messageText = "Check for Updates"; a.informativeText = msg a.runModal() if updated { self.restartEngine() } } }.resume() } func restartEngine() { server?.terminate(); server = nil startServer { if let u = self.dashURL { self.webView.load(URLRequest(url: u)) } } } func windowWillClose(_ n: Notification) { NSApp.terminate(nil) } func applicationWillTerminate(_ n: Notification) { server?.terminate() } } let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate app.setActivationPolicy(.regular) app.run() ''' _APP_PLIST = ''' CFBundleNameLoadout CFBundleDisplayNameLoadout CFBundleIdentifierworks.loadout.app CFBundleShortVersionString%s CFBundleVersion%s CFBundleExecutableLoadout CFBundleIconFileAppIcon CFBundlePackageTypeAPPL LSMinimumSystemVersion13.0 NSHighResolutionCapable LSApplicationCategoryTypepublic.app-category.utilities NSAppTransportSecurity NSAllowsLocalNetworking ''' def cmd_app(args): """Build and install /Applications/Loadout.app using the local toolchain.""" if not shutil.which("swiftc"): raise UserError( "building the Mac app needs Apple's Command Line Tools " "(xcode-select --install), then run `loadout app` again") work = os.path.join(CONFIG_DIR, "tmp", "app-%s" % secrets.token_hex(4)) os.makedirs(work, mode=0o700) try: src = os.path.join(work, "main.swift") with open(src, "w", encoding="utf-8") as fh: fh.write(_APP_SWIFT) binary = os.path.join(work, "Loadout") print("compiling the native app (first time takes ~30s)...") proc = subprocess.run( ["swiftc", "-O", "-framework", "Cocoa", "-framework", "WebKit", src, "-o", binary], capture_output=True, text=True, timeout=600) if proc.returncode != 0: raise UserError("app compile failed:\n%s" % proc.stderr[-800:]) iconset = os.path.join(work, "AppIcon.iconset") os.makedirs(iconset) subprocess.run([binary, "--render-icon", iconset], capture_output=True, timeout=60) icns = os.path.join(work, "AppIcon.icns") subprocess.run(["iconutil", "-c", "icns", iconset, "-o", icns], capture_output=True, timeout=60) dest_root = "/Applications" if not os.access(dest_root, os.W_OK): dest_root = os.path.expanduser("~/Applications") os.makedirs(dest_root, exist_ok=True) bundle = os.path.join(dest_root, "Loadout.app") contents = os.path.join(bundle, "Contents") if os.path.exists(bundle): shutil.rmtree(bundle) os.makedirs(os.path.join(contents, "MacOS")) os.makedirs(os.path.join(contents, "Resources")) shutil.copy2(binary, os.path.join(contents, "MacOS", "Loadout")) if os.path.exists(icns): shutil.copy2(icns, os.path.join(contents, "Resources", "AppIcon.icns")) with open(os.path.join(contents, "Info.plist"), "w") as fh: fh.write(_APP_PLIST % (__VERSION__, __VERSION__)) subprocess.run(["codesign", "--force", "-s", "-", bundle], capture_output=True, timeout=120) print("installed %s" % bundle) try: install_login_item(bundle) print("Loadout will open automatically when you log in " "(turn this off in Settings)") except Exception: pass if not getattr(args, "no_launch", False): subprocess.run(["open", bundle], timeout=30) print("Loadout is now a real Mac app -- find it in Spotlight, " "keep it in your Dock") finally: shutil.rmtree(work, ignore_errors=True) # --------------------------------------------------------------------------- # admin (operator only): account metadata, never content # --------------------------------------------------------------------------- def cmd_admin(args): key = os.environ.get("LOADOUT_ADMIN_KEY") or "" if not key: proc = subprocess.run( ["security", "find-generic-password", "-s", "loadout-admin-key", "-w"], capture_output=True, text=True, timeout=15) key = proc.stdout.strip() if proc.returncode == 0 else "" if not key: raise UserError("no admin key found -- set LOADOUT_ADMIN_KEY or store " "it in the Keychain as 'loadout-admin-key'") cfg = load_config() or {} base = resolve_api_url(args, cfg) req = urllib.request.Request(base + "/v1/admin/users", headers={"X-Admin-Key": key}) try: with urllib.request.urlopen(req, timeout=TIMEOUT, context=_tls_context()) as resp: data = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: raise NetError("admin request failed (HTTP %d) -- wrong key?" % e.code) users = data.get("users", []) print("Loadout accounts: %d\n" % data.get("user_count", len(users))) fmt = "%-34s %-11s %-11s %9s %6s %s" print(fmt % ("EMAIL", "SIGNED UP", "TRIAL ENDS", "USED", "SNAPS", "LAST BACKUP")) now = datetime.now(timezone.utc) for u in users: email = clean_text(u.get("email", "?"))[:34] used = human(int(u.get("bytes_used", 0))) last = u.get("last_backup") if last: try: mins = int((now - parse_iso(last)).total_seconds() // 60) last_h = ("%d min ago" % mins if mins < 120 else "%d h ago" % (mins // 60) if mins < 2880 else "%d days ago" % (mins // 1440)) except (ValueError, TypeError): last_h = clean_text(str(last))[:16] else: last_h = "NEVER" print(fmt % (email, str(u.get("created_at", ""))[:10], str(u.get("trial_ends_at", ""))[:10], used, str(u.get("snapshot_count", 0)), last_h)) # --------------------------------------------------------------------------- # self-update # --------------------------------------------------------------------------- def _fetch_site(path): """Fetch a file from loadout.works only (never a server-controlled URL). The update channel must survive a broken Python TLS stack, or a bad install can never receive the fix that repairs it. If urllib fails at the TLS layer we retry once through the system curl, which uses macOS's own trust store. Only ever our fixed domain; certificate verification stays on in both paths.""" url = SITE_URL + path try: with urllib.request.urlopen(url, timeout=TIMEOUT, context=_tls_context()) as resp: return resp.read(20 * 1024 * 1024) except _ssl_mod.SSLError: pass except (urllib.error.URLError, OSError) as e: if not isinstance(getattr(e, "reason", None), _ssl_mod.SSLError): raise NetError("could not reach loadout.works: %s" % getattr(e, "reason", e)) try: proc = subprocess.run( ["/usr/bin/curl", "-fsSL", "--proto", "=https", "--max-time", str(max(TIMEOUT, 120)), "--max-filesize", str(20 * 1024 * 1024), url], capture_output=True, timeout=max(TIMEOUT, 120) + 30) if proc.returncode == 0 and proc.stdout: return proc.stdout except (OSError, subprocess.TimeoutExpired): pass raise NetError("could not reach loadout.works over TLS -- your Python " "cannot verify HTTPS certificates and the curl fallback " "also failed") def _remote_version(source_text): m = re.search(r'__VERSION__\s*=\s*"([0-9]+(?:\.[0-9]+)*)"', source_text) return m.group(1) if m else None def _version_tuple(v): try: return tuple(int(x) for x in v.split(".")) except (ValueError, AttributeError): return (0,) def release_info(): """Published release metadata: {version, critical, headline, notes}. Falls back to reading the version out of the published client so an older or missing manifest never blocks updating.""" info = {} try: info = json.loads(_fetch_site("/version.json").decode("utf-8")) if not isinstance(info, dict): info = {} except Exception: info = {} if not isinstance(info.get("version"), str): text = _fetch_site("/cli").decode("utf-8", "replace") info = {"version": _remote_version(text) or "", "critical": False} return { "version": str(info.get("version") or ""), "critical": info.get("critical") is True, "headline": clean_text(str(info.get("headline") or ""))[:160], "notes": clean_text(str(info.get("notes") or ""))[:400], } def check_update(): """Return (current, latest, source_bytes) -- latest may equal current.""" data = _fetch_site("/cli") text = data.decode("utf-8", "replace") latest = _remote_version(text) if not latest: raise NetError("could not read the published version") return __VERSION__, latest, data def apply_update(source_bytes): """Atomically replace this very file after a compile check.""" target = os.path.abspath(__file__) tmp = target + ".new" with open(tmp, "wb") as fh: fh.write(source_bytes) import py_compile try: py_compile.compile(tmp, doraise=True) except py_compile.PyCompileError: os.remove(tmp) raise NetError("downloaded update failed to compile -- not installing") os.replace(tmp, target) return target def cmd_update(args): current, latest, data = check_update() if _version_tuple(latest) <= _version_tuple(current): print("loadout %s is up to date" % current) return target = apply_update(data) print("updated %s -> %s (%s)" % (current, latest, target.replace(os.path.expanduser("~"), "~"))) print("restart `loadout` to use the new version") # --------------------------------------------------------------------------- # sync-skills: pull skills from the latest backup onto this Mac # # Security properties (same as restore, plus install-time guards): # - the blob is verified against its manifest sha256 BEFORE decryption # - decryption is local, AES-256-GCM, key from the passphrase that never # leaves this machine; the server only ever relayed ciphertext # - only tar members under .claude/skills/ are extracted, one by one, with # the same traversal/symlink/device guards as restore, into a 0700 temp dir # - skill names must match [A-Za-z0-9._-]{1,64} and not start with "." # - local skills are NEVER silently overwritten: differing skills keep the # local version unless --prefer-remote, which first saves the local copy # to ~/.loadout/skill-conflicts/-/ # --------------------------------------------------------------------------- import shutil _SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") def _skills_fingerprint(root): """{skill_name: digest-of-contents} for top-level entries under root.""" out = {} if not os.path.isdir(root): return out for entry in sorted(os.listdir(root)): if entry.startswith(".") or not _SKILL_NAME_RE.match(entry): continue path = os.path.join(root, entry) h = hashlib.sha256() if os.path.isfile(path): try: h.update(b"F") h.update(open(path, "rb").read()) except OSError: continue else: for dirpath, dirnames, filenames in os.walk(path): dirnames.sort() for fn in sorted(filenames): fp = os.path.join(dirpath, fn) rel = os.path.relpath(fp, path) try: h.update(rel.encode("utf-8", "replace") + b"\x00") h.update(open(fp, "rb").read()) except OSError: continue out[entry] = h.hexdigest() return out _SKILLS_EXTRACT_BUDGET = 1 * GB # skills are text; a bigger archive is hostile def _extract_skills_only(tar, dest): """Extract only .claude/skills/** members, with restore-grade guards.""" dest_real = os.path.realpath(dest) count = 0 budget = _SKILLS_EXTRACT_BUDGET for member in tar.getmembers(): name = member.name.replace("\\", "/") parts = name.split("/") if os.path.isabs(name) or ".." in parts: raise UserError("refusing unsafe path in backup archive: %r" % name) if len(parts) < 3 or parts[0] != ".claude" or parts[1] != "skills": continue # budget applies to skills members only -- the rest of the archive # (projects, transcripts) is legitimately large and never extracted budget -= max(0, int(getattr(member, "size", 0) or 0)) if budget < 0: raise UserError("skills in this backup exceed the 1 GB safety " "budget -- refusing to extract") if member.isdev(): continue if member.issym() or member.islnk(): link_parts = member.linkname.replace("\\", "/").split("/") if os.path.isabs(member.linkname) or ".." in link_parts: eprint("warning: skipping link with unsafe target: %r" % name) continue target_parent = os.path.realpath( os.path.dirname(os.path.join(dest, name))) if target_parent != dest_real and \ not target_parent.startswith(dest_real + os.sep): raise UserError("refusing traversal in backup archive: %r" % name) tar.extract(member, dest) count += 1 return count def cmd_sync_skills(args): cfg = require_config(args) local_root = os.path.expanduser("~/.claude/skills") snaps = api(cfg, "GET", "/v1/snapshots").get("snapshots", []) cands = [s for s in snaps if s.get("status") == "complete" and "claude-code" in (s.get("sources") or [])] if not cands: raise UserError("no backup containing skills exists yet -- run " "`loadout backup` on the Mac that has your skills") snap = max(cands, key=lambda s: s.get("created") or "") print("pulling skills from snapshot %s (%s, %s)" % (clean_text(snap.get("snapshot_id", ""))[:8], clean_text(snap.get("device", "?"))[:64], clean_text(fmt_local(snap.get("created", ""))))) resp = api(cfg, "POST", "/v1/restore", {"snapshot_id": snap["snapshot_id"]}) entry = next((d for d in resp.get("downloads", []) if d.get("name") == "claude-code"), None) if entry is None: raise NetError("the snapshot's claude-code source is missing") _say(" downloading... ") blob = get_bytes(entry["url"]) if hashlib.sha256(blob).hexdigest() != str(entry.get("sha256", "")): raise NetError("checksum mismatch -- aborting sync") _say("decrypting... ") key = derive_key(get_passphrase(allow_create=False), cfg["enc_salt"]) tar_bytes = decrypt_blob(key, blob) tmp = os.path.join(CONFIG_DIR, "tmp", "skills-%s" % secrets.token_hex(4)) os.makedirs(tmp, mode=0o700, exist_ok=False) try: with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:gz") as tar: extracted = _extract_skills_only(tar, tmp) _say("done\n") if not extracted: raise UserError("that backup contains no skills " "(~/.claude/skills was empty when it was taken)") remote_root = os.path.join(tmp, ".claude", "skills") remote = _skills_fingerprint(remote_root) local = _skills_fingerprint(local_root) new = sorted(n for n in remote if n not in local) differ = sorted(n for n in remote if n in local and remote[n] != local[n]) in_sync = len([n for n in remote if local.get(n) == remote[n]]) print("plan: %d new, %d differ (local kept%s), %d already in sync" % (len(new), len(differ), "" if not args.prefer_remote else " -> remote will replace", in_sync)) if not new and not (differ and args.prefer_remote): print("nothing to install -- this Mac is up to date") return if not args.auto: answer = input("install? [y/N] ").strip().lower() if answer not in ("y", "yes"): print("cancelled -- nothing was changed") return os.makedirs(local_root, exist_ok=True) installed = 0 for name in new: src = os.path.join(remote_root, name) dst = os.path.join(local_root, name) if os.path.isdir(src): shutil.copytree(src, dst) else: shutil.copy2(src, dst) print(" + %s" % name) installed += 1 if args.prefer_remote: stamp = datetime.now().strftime("%Y%m%d-%H%M%S") keep_dir = os.path.join(CONFIG_DIR, "skill-conflicts") for name in differ: src = os.path.join(remote_root, name) dst = os.path.join(local_root, name) saved = os.path.join(keep_dir, "%s-%s" % (name, stamp)) os.makedirs(keep_dir, exist_ok=True) shutil.move(dst, saved) if os.path.isdir(src): shutil.copytree(src, dst) else: shutil.copy2(src, dst) print(" ~ %s (previous local copy saved to %s)" % (name, saved.replace(os.path.expanduser("~"), "~"))) installed += 1 elif differ: for name in differ: print(" = %s differs -- kept the local version " "(use --prefer-remote to take the backup's)" % name) print("sync complete: %d skill%s installed" % (installed, "" if installed == 1 else "s")) finally: shutil.rmtree(tmp, ignore_errors=True) # --------------------------------------------------------------------------- # schedule: automatic backups via launchd # --------------------------------------------------------------------------- LAUNCH_AGENT_LABEL = "works.loadout.backup" LOGIN_ITEM_LABEL = "works.loadout.app" def _login_item_path(): return os.path.expanduser( "~/Library/LaunchAgents/%s.plist" % LOGIN_ITEM_LABEL) def install_login_item(app_path="/Applications/Loadout.app"): """Open Loadout at login, like any Mac app that protects something. A LaunchAgent with RunAtLoad, so protection resumes after every restart without the user remembering to launch it. Removable from Settings.""" plist = _login_item_path() os.makedirs(os.path.dirname(plist), exist_ok=True) body = ( '\n' '\n' '' 'Label%s' 'ProgramArguments' '/usr/bin/open-a%s' '' 'RunAtLoad' '\n' % (LOGIN_ITEM_LABEL, app_path) ) with open(plist, "w") as fh: fh.write(body) subprocess.run(["launchctl", "unload", plist], capture_output=True, timeout=15) subprocess.run(["launchctl", "load", plist], capture_output=True, timeout=15) return plist def remove_login_item(): plist = _login_item_path() if os.path.exists(plist): subprocess.run(["launchctl", "unload", plist], capture_output=True, timeout=15) os.remove(plist) return True return False def login_item_installed(): return os.path.exists(_login_item_path()) def _launch_agent_path(): return os.path.expanduser( "~/Library/LaunchAgents/%s.plist" % LAUNCH_AGENT_LABEL) def cmd_schedule(args): plist_path = _launch_agent_path() if args.off: subprocess.run(["launchctl", "bootout", "gui/%d/%s" % (os.getuid(), LAUNCH_AGENT_LABEL)], capture_output=True) if os.path.exists(plist_path): os.remove(plist_path) print("automatic backups turned off") return cfg = require_config(args) # must be logged in before scheduling if not keychain_get(): raise UserError( "run one `loadout backup` first so your passphrase is saved in the " "Keychain -- scheduled backups run unattended and cannot prompt") interval = {"15m": 900, "hourly": 3600, "6h": 21600, "daily": 86400}[args.every] script = os.path.abspath(__file__) log_path = os.path.join(CONFIG_DIR, "scheduled-backup.log") plist = """ Label%s ProgramArguments %s %s backup StartInterval%d StandardOutPath%s StandardErrorPath%s ProcessTypeBackground """ % (LAUNCH_AGENT_LABEL, sys.executable, script, interval, log_path, log_path) os.makedirs(os.path.dirname(plist_path), exist_ok=True) with open(plist_path, "w", encoding="utf-8") as fh: fh.write(plist) subprocess.run(["launchctl", "bootout", "gui/%d/%s" % (os.getuid(), LAUNCH_AGENT_LABEL)], capture_output=True) proc = subprocess.run(["launchctl", "bootstrap", "gui/%d" % os.getuid(), plist_path], capture_output=True, text=True) if proc.returncode != 0: raise UserError("could not register the schedule with launchd: %s" % (proc.stderr.strip() or "unknown error")) print("automatic backups on: %s (log: %s)" % (args.every, log_path)) print("thanks to deduplication, unchanged data costs nothing to re-protect") # =========================================================================== # loadout dashboard — local control panel addon # =========================================================================== # Appended verbatim to the single-file Loadout CLI, just before # build_parser()/main(). Defines only new names; relies on the host file for # SOURCES, CONFIG_DIR/CONFIG_PATH, load_config/save_config/require_config, # api(), human(), fmt_local(), path_size(), UserError/NetError/ApiError, # eprint(), LAUNCH_AGENT_LABEL, _launch_agent_path(), and the host's stdlib # imports (os, sys, json, secrets, subprocess, urllib.*, hmac, argparse, ...). # # Wiring (add inside build_parser(), matching the surrounding style): # # p = sub.add_parser("dashboard", # help="open the local Loadout control panel in your browser") # p.set_defaults(func=cmd_dashboard) # # Backup integration (inside cmd_backup's per-source loop, so the dashboard's # Protection toggles take effect): # # disabled = set(cfg.get("disabled_sources", [])) # once, before the loop # if name in disabled: # inside the loop # print(" %s: skipped (disabled)" % name); continue # =========================================================================== import http.server import threading import webbrowser # -------------------------------------------------------------------------- # background source-size measurement # # path_size() walks whole directory trees, which can take a while on big # sources, so sizes are computed on a daemon thread after the server starts. # /api/state reports size: null for a source that has not been measured yet # and the UI shows "measuring…" until a number arrives. # -------------------------------------------------------------------------- _DASH_SIZE_LOCK = threading.Lock() _DASH_SIZES = {} # source name -> bytes (present == measurement finished) _DASH_MTIMES = {} # source name -> newest file mtime (epoch); same lock def _dash_measure_sizes(): """Measure every registered source; never raises (daemon thread).""" while True: for entry in SOURCES: try: name, raw_path = entry[0], entry[1] except (IndexError, TypeError): continue path = os.path.expanduser(raw_path) size, newest = 0, 0 try: if os.path.exists(path): fp = source_fingerprint(path) # one walk: count:bytes:mtime parts = fp.split(":") size, newest = int(parts[1]), int(parts[2]) except Exception: size, newest = 0, 0 with _DASH_SIZE_LOCK: _DASH_SIZES[name] = size _DASH_MTIMES[name] = newest # re-measure every 2 minutes so "changes waiting" stays fresh while # the dashboard is open; daemon thread dies with the server time.sleep(120) # -------------------------------------------------------------------------- # state assembly # -------------------------------------------------------------------------- def _dash_human(n): try: return human(n) except Exception: return str(n) def _dash_fmt_local(iso): try: return fmt_local(iso) except Exception: return iso def _dash_load_config(): """load_config(), but never raises; returns a dict or None.""" try: cfg = load_config() except Exception: return None return cfg if isinstance(cfg, dict) else None def _dash_disabled_sources(cfg): if not isinstance(cfg, dict): return [] raw = cfg.get("disabled_sources") if not isinstance(raw, list): return [] return [x for x in raw if isinstance(x, str)] def _dash_skill_names(directory): """Skill names in a directory: sub-dirs, or files minus their extension.""" names = set() try: entries = os.listdir(directory) except OSError: return names for entry in entries: if entry.startswith("."): continue if os.path.isdir(os.path.join(directory, entry)): names.add(entry) else: stem = os.path.splitext(entry)[0] if stem: names.add(stem) return names _UUIDISH_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I) def _dash_skills(): """Skills across agents (read-only v1): union of the three inventories. Devin/Windsurf auto-memories have UUID filenames -- a wall of hex is noise, so they collapse into one summary row instead of 80 rows.""" claude = _dash_skill_names(os.path.expanduser("~/.claude/skills")) cursor = _dash_skill_names(os.path.expanduser("~/.cursor/rules")) devin = _dash_skill_names(os.path.expanduser("~/.codeium/windsurf/memories")) def split_uuids(names): auto = {n for n in names if _UUIDISH_RE.match(n)} return names - auto, auto claude, claude_auto = split_uuids(claude) cursor, cursor_auto = split_uuids(cursor) devin, devin_auto = split_uuids(devin) skills = [] for name in sorted(claude | cursor | devin, key=lambda s: s.lower()): skills.append({ "name": name, "platforms": { "claude-code": name in claude, "cursor": name in cursor, "devin": name in devin, }, }) auto_total = len(claude_auto | cursor_auto | devin_auto) if auto_total: skills.append({ "name": "auto-generated agent memories (%d) -- backed up with " "their source" % auto_total, "platforms": { "claude-code": bool(claude_auto), "cursor": bool(cursor_auto), "devin": bool(devin_auto), }, }) return skills def _dash_snapshots(cfg): """Last 20 snapshots from the cloud, or {"error": ...} when unreachable.""" if cfg is None: return {"error": "not logged in"} try: resp = api(cfg, "GET", "/v1/snapshots") except (ApiError, NetError) as exc: return {"error": str(exc) or exc.__class__.__name__} except Exception as exc: return {"error": "unexpected error: %s" % (exc,)} snaps = resp.get("snapshots") if isinstance(resp, dict) else None if not isinstance(snaps, list): return {"error": "malformed response from cloud API"} snaps = sorted(snaps, key=lambda s: (s.get("created") or ""), reverse=True)[:20] out = [] for s in snaps: if not isinstance(s, dict): continue created = s.get("created") total = s.get("total_bytes") out.append({ "snapshot_id": s.get("snapshot_id"), "created": created, "created_h": _dash_fmt_local(created) if created else None, "device": s.get("device"), "status": s.get("status"), "total_bytes": total, "total_h": _dash_human(total) if isinstance(total, int) else None, "sources": s.get("sources") or [], }) return out def _dash_state(): """Everything the dashboard page needs, in one JSON-able dict.""" cfg = _dash_load_config() account = None if cfg and cfg.get("email"): account = {"email": cfg.get("email"), "trial_ends_at": cfg.get("trial_ends_at")} disabled = _dash_disabled_sources(cfg) with _DASH_SIZE_LOCK: sizes = dict(_DASH_SIZES) mtimes = dict(_DASH_MTIMES) sources = [] for entry in SOURCES: try: name, raw_path, cap_gb = entry[0], entry[1], entry[2] except (IndexError, TypeError): continue path = os.path.expanduser(raw_path) present = os.path.exists(path) size = sizes.get(name) # None => still measuring sources.append({ "name": name, "path": raw_path, "present": present, "size": size, "size_h": _dash_human(size) if isinstance(size, int) else None, "newest_mtime": mtimes.get(name), "enabled": name not in disabled, "cap_gb": cap_gb, }) schedule_on = False schedule_every = None try: p = _launch_agent_path() schedule_on = os.path.exists(p) if schedule_on: with open(p, errors="ignore") as fh: txt = fh.read() import re as _re m = _re.search(r"StartInterval\s*(\d+)", txt) if m: schedule_every = {900: "15m", 3600: "hourly", 21600: "6h", 86400: "daily"}.get(int(m.group(1))) except Exception: pass return { "account": account, "sources": sources, "skills": _dash_skills(), "schedule": {"on": schedule_on, "every": schedule_every}, "version": __VERSION__, "last_backup_at": _load_state().get("last_backup_at"), "update_available": _load_state().get("update_available"), "auto_update_check": (load_config() or {}).get("auto_update_check", True) is not False, "open_at_login": login_item_installed(), "passphrase_saved": passphrase_saved(), "snapshots": _dash_snapshots(cfg), "disabled_sources": disabled, "equip": equip_state(), } # -------------------------------------------------------------------------- # POST actions # -------------------------------------------------------------------------- def _dash_post_source(data): """{name, enabled} -> persist cfg["disabled_sources"]. Returns (code, obj).""" name = data.get("name") enabled = data.get("enabled") if not isinstance(name, str) or not isinstance(enabled, bool): return 400, {"error": 'expected {"name": , "enabled": }'} if name not in set(entry[0] for entry in SOURCES): return 400, {"error": "unknown source: %s" % name} cfg = _dash_load_config() if cfg is None: cfg = {} disabled = _dash_disabled_sources(cfg) if enabled: disabled = [x for x in disabled if x != name] elif name not in disabled: disabled.append(name) cfg["disabled_sources"] = disabled try: save_config(cfg) except Exception as exc: return 500, {"error": "could not save config: %s" % (exc,)} return 200, {"ok": True, "disabled_sources": disabled} _DASH_FREQUENCIES = ("15m", "hourly", "6h", "daily") def _dash_post_schedule(data): """{on, every} -> shell out to the CLI's own schedule command.""" on = data.get("on") every = data.get("every", "daily") if not isinstance(on, bool): return 400, {"error": 'expected {"on": , "every": "hourly"|"6h"|"daily"}'} if on and every not in _DASH_FREQUENCIES: return 400, {"error": "every must be one of: %s" % ", ".join(_DASH_FREQUENCIES)} # __file__ is unreliable once this addon is appended/installed; sys.argv[0] # is the script the user actually ran. script = os.path.abspath(sys.argv[0]) argv = [sys.executable, script] argv += ["schedule", "--every", every] if on else ["schedule", "--off"] try: proc = subprocess.run(argv, capture_output=True, text=True, timeout=120) except subprocess.TimeoutExpired: return 200, {"ok": False, "output": "schedule command timed out"} except OSError as exc: return 200, {"ok": False, "output": "could not run CLI: %s" % (exc,)} schedule_on = False try: schedule_on = os.path.exists(_launch_agent_path()) except Exception: pass return 200, { "ok": proc.returncode == 0, "output": ((proc.stdout or "") + (proc.stderr or "")).strip(), "on": schedule_on, } # -------------------------------------------------------------------------- # HTTP handler # -------------------------------------------------------------------------- class _DashboardHandler(http.server.BaseHTTPRequestHandler): """Local-only dashboard handler. A request never crashes its thread, and nothing (including the session token) is written to the request log.""" server_version = "loadout" sys_version = "" # -- logging: deliberately silent. The default log line includes the # request path, and page loads carry the session token as ?t=. def log_message(self, fmt, *args): pass # -- auth --------------------------------------------------------------- def _authorized(self, parsed): expect = getattr(self.server, "dash_token", None) if not expect: return False header = self.headers.get("X-Loadout-Token") or "" if header and hmac.compare_digest(header, expect): return True for cand in urllib.parse.parse_qs(parsed.query).get("t", []): if cand and hmac.compare_digest(cand, expect): return True return False # -- response helpers --------------------------------------------------- def _send(self, code, payload, ctype): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(payload))) self.send_header("Cache-Control", "no-store") self.send_header("Referrer-Policy", "no-referrer") self.send_header("X-Content-Type-Options", "nosniff") self.end_headers() self.wfile.write(payload) def _send_json(self, code, obj): self._send(code, json.dumps(obj).encode("utf-8"), "application/json; charset=utf-8") # -- request body ------------------------------------------------------- def _read_json_body(self): try: length = int(self.headers.get("Content-Length") or 0) except (TypeError, ValueError): return None if length <= 0 or length > 1048576: return None try: data = json.loads(self.rfile.read(length).decode("utf-8")) except (ValueError, UnicodeDecodeError, OSError): return None return data if isinstance(data, dict) else None # -- routing ------------------------------------------------------------ def do_GET(self): self._safely(self._route_get) def do_POST(self): self._safely(self._route_post) def _safely(self, route): try: route() except Exception: try: self._send_json(500, {"error": "internal server error"}) except Exception: pass # client hung up mid-response; nothing to do def _route_get(self): parsed = urllib.parse.urlsplit(self.path) if not self._authorized(parsed): self._send_json(403, {"error": "missing or invalid session token"}) return if parsed.path == "/": self._send(200, _DASHBOARD_HTML.encode("utf-8"), "text/html; charset=utf-8") elif parsed.path == "/api/state": self._send_json(200, _dash_state()) else: self._send_json(404, {"error": "not found"}) def _route_post(self): parsed = urllib.parse.urlsplit(self.path) if not self._authorized(parsed): self._send_json(403, {"error": "missing or invalid session token"}) return data = self._read_json_body() if data is None: self._send_json(400, {"error": "expected a JSON object body"}) return if parsed.path == "/api/source": code, obj = _dash_post_source(data) elif parsed.path == "/api/schedule": code, obj = _dash_post_schedule(data) elif parsed.path == "/api/signup": code, obj = ui_action_signup(data) elif parsed.path == "/api/login": code, obj = ui_action_login(data) elif parsed.path == "/api/logout": code, obj = ui_action_logout(data) elif parsed.path == "/api/backup": code, obj = ui_action_backup(data) elif parsed.path == "/api/restore": code, obj = ui_action_restore(data) elif parsed.path == "/api/sync": code, obj = ui_action_sync(data) elif parsed.path == "/api/job": code, obj = ui_action_job(data) elif parsed.path == "/api/update": code, obj = ui_action_update(data) elif parsed.path == "/api/equip": code, obj = ui_action_equip(data) elif parsed.path == "/api/sessions": code, obj = ui_action_sessions(data) elif parsed.path == "/api/export-session": code, obj = ui_action_export_session(data) elif parsed.path == "/api/claude-projects": code, obj = ui_action_claude_projects(data) elif parsed.path == "/api/import-session": code, obj = ui_action_import_session(data) elif parsed.path == "/api/platforms": code, obj = ui_action_platforms(data) elif parsed.path == "/api/auto-update": code, obj = ui_action_auto_update(data) elif parsed.path == "/api/open-at-login": code, obj = ui_action_open_at_login(data) elif parsed.path == "/api/forget-passphrase": code, obj = ui_action_forget_passphrase(data) elif parsed.path == "/api/update-check": code, obj = ui_action_update_check(data) elif parsed.path == "/api/restart-app": code, obj = ui_action_restart_app(data) else: code, obj = 404, {"error": "not found"} self._send_json(code, obj) # -------------------------------------------------------------------------- # command # -------------------------------------------------------------------------- def cmd_dashboard(args): """Serve the local control panel on 127.0.0.1 until Ctrl-C.""" with _DASH_SIZE_LOCK: _DASH_SIZES.clear() token = secrets.token_urlsafe(32) try: server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _DashboardHandler) except OSError as exc: raise UserError("could not start dashboard server: %s" % exc) server.daemon_threads = True server.dash_token = token port = server.server_address[1] url = "http://127.0.0.1:%d/?t=%s" % (port, token) threading.Thread(target=_dash_measure_sizes, name="loadout-dashboard-sizer", daemon=True).start() def _daily_update_check(): try: cfg = load_config() or {} if cfg.get("auto_update_check") is False: return st = _load_state() last = st.get("last_update_check") if last: try: age = (datetime.now(timezone.utc) - parse_iso(last)).total_seconds() if age < 86400: return except (ValueError, TypeError): pass current, latest, _blob = check_update() st = _load_state() st["last_update_check"] = datetime.now(timezone.utc).isoformat() st["update_available"] = ( latest if _version_tuple(latest) > _version_tuple(current) else None) _save_state(st) except Exception: pass # update checks must never break the dashboard threading.Thread(target=_daily_update_check, name="loadout-update-check", daemon=True).start() if getattr(args, "headless", False): # Machine-readable handshake for the native app shell. print("DASHBOARD_URL=%s" % url, flush=True) else: print("Loadout dashboard running at:") print("") print(" %s" % url) print("") print("The link carries a one-time session token and only works from this") print("Mac while this process is running. Press Ctrl-C to stop.") try: webbrowser.open(url) except Exception: pass # headless / no default browser; the printed URL still works try: server.serve_forever(poll_interval=0.5) except KeyboardInterrupt: pass finally: try: server.server_close() except Exception: pass print("") print("dashboard stopped") return 0 # -------------------------------------------------------------------------- # the page (single file, no external assets) # -------------------------------------------------------------------------- _DASHBOARD_HTML = """ Loadout

Backup

Everything is encrypted on this Mac before it leaves. The server only ever holds ciphertext.

""" # =========================================================================== # loadout dashboard — UI ACTIONS addon # =========================================================================== # Appended verbatim to the single-file Loadout CLI, just before build_parser(). # Turns the local (127.0.0.1, token-gated) dashboard into the PRIMARY UI: users # click buttons instead of typing commands. Defines ONLY new names (all prefixed # ui_ / _ui_ / _UI_) and reuses host names already defined above in this file: # # load_config, save_config, require_config, resolve_api_url, http_json, api, # get_bytes, derive_key, encrypt_blob, decrypt_blob, put_blob, tar_source, # safe_extract, clean_text, keychain_set, keychain_get, human, path_size, # iter_sources, SOURCES, CONFIG_DIR, GB, DEFAULT_API, socket, shutil, datetime, # urllib.parse, UserError, NetError, ApiError, eprint, # _skills_fingerprint, _extract_skills_only, _SKILL_NAME_RE # # =========================================================================== import hashlib import io import os import secrets import tarfile import threading import traceback import uuid # --------------------------------------------------------------------------- # small internal helpers # --------------------------------------------------------------------------- # ASCII source-name allowlist, matching the host's r"[A-Za-z0-9._-]{1,64}". _UI_NAME_CHARS = frozenset( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-") _UI_ALNUM = frozenset( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") def _ui_clean(value): """clean_text() a string; pass non-strings (None, ints) through untouched.""" return clean_text(value) if isinstance(value, str) else value def _ui_safe_source_name(name): """Mirror the host restore guard: 1..64 chars of [A-Za-z0-9._-], no dot lead.""" if not isinstance(name, str) or not (1 <= len(name) <= 64): return False if name.startswith("."): return False return all(ch in _UI_NAME_CHARS for ch in name) def _ui_short_id(snapshot_id): """A filesystem-safe <=8 char tag drawn from a server snapshot id.""" short = "".join(ch for ch in str(snapshot_id) if ch in _UI_ALNUM)[:8] return short or secrets.token_hex(4) def _ui_require(resp, key): """Pull a required field from a server response or raise NetError (-> 502).""" value = resp.get(key) if isinstance(resp, dict) else None if value in (None, ""): raise NetError("the server response is missing %r" % key) return value def _ui_friendly_error(exc): """A short, user-facing message for a known error type.""" if isinstance(exc, ApiError): return exc.message return str(exc) def _ui_map_error(exc): """Map an exception to (http_code, {"error": msg}) for the handler hooks. UserError -> 400, NetError/ApiError -> 502, anything else -> 500 with a generic message (the traceback is logged server-side, never returned).""" if isinstance(exc, UserError): return 400, {"error": clean_text(str(exc))} if isinstance(exc, (NetError, ApiError)): return 502, {"error": clean_text(_ui_friendly_error(exc))} try: eprint("loadout dashboard: unexpected error in UI action:\n%s" % traceback.format_exc()) except Exception: pass return 500, {"error": "internal error"} def _ui_valid_api_url(url): """Return a cleaned https/localhost URL, or None if it is unacceptable.""" if not isinstance(url, str) or not url: return None cleaned = url.rstrip("/") parsed = urllib.parse.urlparse(cleaned) host = (parsed.hostname or "") is_local = host in ("localhost", "127.0.0.1") if parsed.scheme == "https" or is_local: return cleaned return None def _ui_auth_api_url(data): """API base for signup/login. Precedence (per the addon contract): resolve_api_url(None, load_config() or {}) # env/config/default OR a client-supplied data["api_url"] # validated https/localhost OR DEFAULT_API resolve_api_url() normally already returns a valid URL, so the data-supplied value is only a fallback and is scheme-checked before use.""" try: cfg = load_config() or {} except Exception: cfg = {} if not isinstance(cfg, dict): cfg = {} try: url = resolve_api_url(None, cfg) except Exception: url = None if not url: url = _ui_valid_api_url(data.get("api_url") if isinstance(data, dict) else None) if not url: url = DEFAULT_API return url.rstrip("/") # =========================================================================== # 1. CALLABLE CORES (no prompting, no printing; return dicts, raise on error) # =========================================================================== def ui_signup(api_url, email, password): """Create an account and persist config. Returns {email, trial_ends_at}. The encryption salt is generated CLIENT-side (opaque to the server) exactly as cmd_signup does; password must be >= 8 characters.""" if not isinstance(email, str) or not email.strip(): raise UserError("an email address is required") email = email.strip().lower() if "@" not in email or "." not in email.split("@")[-1]: raise UserError("that does not look like an email address") if not isinstance(password, str) or len(password) < 8: raise UserError("password must be at least 8 characters") enc_salt = secrets.token_hex(16) resp = http_json("POST", api_url + "/v1/signup", {"email": email, "password": password, "enc_salt": enc_salt}) token = _ui_require(resp, "token") account_id = _ui_require(resp, "account_id") save_config({ "api_url": api_url, "token": token, "account_id": account_id, "email": email, "enc_salt": enc_salt, "trial_ends_at": resp.get("trial_ends_at"), }) return {"email": email, "trial_ends_at": _ui_clean(resp.get("trial_ends_at"))} def ui_login(api_url, email, password): """Log in and persist config, with the host's trust-on-first-use salt guard. If this Mac already stored an enc_salt for the same email and the server returns a different one, refuse (a swapped salt would silently corrupt the crypto). Returns {email, trial_ends_at}.""" if not isinstance(email, str) or not email.strip(): raise UserError("an email address is required") email = email.strip().lower() if not isinstance(password, str) or not password: raise UserError("a password is required") resp = http_json("POST", api_url + "/v1/login", {"email": email, "password": password}) if not isinstance(resp, dict): raise NetError("the server returned an unexpected (non-object) response") try: cfg = load_config() or {} except Exception: cfg = {} if not isinstance(cfg, dict): cfg = {} known_salt = cfg.get("enc_salt") if known_salt and cfg.get("email") == email \ and resp.get("enc_salt") != known_salt: raise UserError( "SECURITY WARNING: the server returned a different encryption salt " "than the one stored on this Mac. This should never happen -- it can " "indicate a tampered or impersonated server. Not overwriting local " "config. If you truly reset your account, delete ~/.loadout/config.json.") token = _ui_require(resp, "token") account_id = _ui_require(resp, "account_id") enc_salt = _ui_require(resp, "enc_salt") save_config({ "api_url": api_url, "token": token, "account_id": account_id, "email": email, "enc_salt": enc_salt, "trial_ends_at": resp.get("trial_ends_at"), }) return {"email": email, "trial_ends_at": _ui_clean(resp.get("trial_ends_at"))} def ui_logout(): """Drop only the session token, keeping api_url (and enc_salt/email so a later login's TOFU check still works). Returns {ok: True}.""" try: cfg = load_config() or {} except Exception: cfg = {} if not isinstance(cfg, dict): cfg = {} cfg.pop("token", None) save_config(cfg) return {"ok": True} # =========================================================================== # 2. JOB RUNNER (long actions run off the HTTP thread; poll via ui_job_status) # =========================================================================== _UI_JOBS = {} # job_id -> {kind, status, lines, result, error} _UI_JOBS_LOCK = threading.Lock() _UI_JOBS_MAX = 64 # keep the registry from growing without bound _UI_JOB_MAX_LINES = 2000 # per-job log cap (defensive) def _ui_prune_jobs_locked(): """Drop the oldest finished jobs once the registry is over budget. Must be called while holding _UI_JOBS_LOCK. dict preserves insertion order, so iterating keys yields oldest-first; only done/error jobs are removed so a still-running job is never discarded.""" if len(_UI_JOBS) <= _UI_JOBS_MAX: return for jid in list(_UI_JOBS.keys()): if len(_UI_JOBS) <= _UI_JOBS_MAX: break if _UI_JOBS[jid].get("status") in ("done", "error"): del _UI_JOBS[jid] def _ui_start_job(kind, target_fn): """Run target_fn(log) on a daemon thread; return a job_id immediately. target_fn receives a log(msg) callback (each message is clean_text'd and appended as a line) and returns a JSON-able result dict on success. Known errors (UserError/NetError/ApiError) become a friendly 'error' status; anything else becomes 'unexpected error' with the traceback logged only server-side -- never leaked to the client.""" job_id = uuid.uuid4().hex with _UI_JOBS_LOCK: _ui_prune_jobs_locked() _UI_JOBS[job_id] = { "kind": kind, "status": "running", "lines": [], "result": None, "error": None, "progress": None, } def _progress(done_bytes, total_bytes, label=""): """Report overall upload progress so the UI can show what is left.""" with _UI_JOBS_LOCK: job = _UI_JOBS.get(job_id) if job is not None: job["progress"] = {"done": int(done_bytes), "total": int(total_bytes), "label": clean_text(label)[:80]} def _log(msg): line = clean_text(msg) with _UI_JOBS_LOCK: job = _UI_JOBS.get(job_id) if job is not None and len(job["lines"]) < _UI_JOB_MAX_LINES: job["lines"].append(line) def _finish(status, result=None, error=None): # Record the terminal state and opportunistically trim old finished # jobs. The oldest finished jobs are dropped first, so the job that just # finished (the newest) survives for the client that is polling it. with _UI_JOBS_LOCK: job = _UI_JOBS.get(job_id) if job is not None: job["result"] = result job["error"] = error job["status"] = status _ui_prune_jobs_locked() def _worker(): try: _log.progress = _progress result = target_fn(_log) if not isinstance(result, dict): result = {} _finish("done", result=result) except (UserError, NetError, ApiError) as exc: _finish("error", error=clean_text(_ui_friendly_error(exc))) except Exception: try: eprint("loadout dashboard: unexpected error in %s job:\n%s" % (kind, traceback.format_exc())) except Exception: pass _finish("error", error="unexpected error") threading.Thread(target=_worker, name="loadout-ui-job-%s" % kind, daemon=True).start() return job_id def ui_job_status(job_id): """Return a snapshot {status, lines, result, error} for a job, or None.""" if not isinstance(job_id, str): return None with _UI_JOBS_LOCK: job = _UI_JOBS.get(job_id) if job is None: return None return { "status": job["status"], "progress": job.get("progress"), "lines": list(job["lines"]), "result": job["result"], "error": job["error"], } # =========================================================================== # 3. JOB BODIES (each returns a run(log) suitable for _ui_start_job) # =========================================================================== def ui_backup_job(passphrase): """Encrypt-and-upload a snapshot, mirroring cmd_backup. Honors the dashboard's Protection toggles (disabled_sources) and the same per-source size caps cmd_backup applies by default (v1 tars in memory, so an over-cap source is skipped rather than risk exhausting RAM). On the first successful backup -- or whenever the passphrase changes -- the passphrase is cached in the Keychain so scheduled (unattended) backups can run.""" def run(log): cfg = require_config(None) secret = passphrase or keychain_get() if not secret: raise UserError("enter your backup passphrase to continue") key = derive_key(secret, cfg["enc_salt"]) assert_same_passphrase(cfg, key) # Remember it so later backups -- and the schedule -- run unattended. try: if passphrase and not keychain_get(): keychain_set(passphrase) except Exception: pass device = socket.gethostname() disabled = set(cfg.get("disabled_sources") or []) blobs = {} sources_meta = [] log("scanning AI-tool data on this Mac...") for name, path, cap_gb in iter_sources(): if name in disabled: log(" %s: skipped (disabled in dashboard)" % name) continue if not os.path.exists(path): continue # missing sources are skipped silently size = path_size(path) if size > cap_gb * GB: log(" %s: skipped -- %s exceeds its %d GB limit" % (name, human(size), cap_gb)) continue log(" %s (%s): archiving..." % (name, human(size))) tar_bytes, skipped = tar_source(path) blob = encrypt_blob(key, tar_bytes) sha = hashlib.sha256(blob).hexdigest() blobs[name] = blob sources_meta.append({"name": name, "size": len(blob), "sha256": sha}) log(" %s: encrypted (%s)" % (name, human(len(blob)))) if skipped: log(" %s: note -- %d unreadable item(s) left out" % (name, len(skipped))) if not sources_meta: raise UserError( "nothing to back up -- no known AI-tool data found on this Mac") resp = api(cfg, "POST", "/v1/snapshots/begin", {"device": device, "sources": sources_meta}) snapshot_id = resp.get("snapshot_id") if isinstance(resp, dict) else None if not snapshot_id: raise NetError("the server did not return a snapshot id") upload_info = {u.get("name"): u for u in (resp.get("uploads") or []) if isinstance(u, dict)} total = uploaded = 0 to_upload = sum(m["size"] for m in sources_meta if not (upload_info.get(m["name"]) or {}).get("exists")) sent = 0 report = getattr(log, "progress", None) if report: report(0, to_upload, "starting upload") for meta in sources_meta: name = meta["name"] info = upload_info.get(name) if info is None: raise NetError("server did not acknowledge source %r" % name) total += meta["size"] if info.get("exists"): log(" %s: unchanged -- already backed up" % name) continue if not info.get("url"): raise NetError("server did not return an upload URL for %r" % name) log(" %s: uploading %s..." % (name, human(meta["size"]))) if report: report(sent, to_upload, name) put_blob(info["url"], blobs[name]) uploaded += meta["size"] sent += meta["size"] if report: report(sent, to_upload, name) log(" %s: uploaded" % name) api(cfg, "POST", "/v1/snapshots/commit", {"snapshot_id": snapshot_id}) log("snapshot complete: %s" % snapshot_id) log("%s protected, %s newly uploaded" % (human(total), human(uploaded))) # Mirror cmd_backup's bookkeeping so the in-sync status and the # fingerprint cache reflect UI-initiated backups too. try: paths = dict((n, p) for n, p, _c in iter_sources()) fresh = _load_state() cache = fresh.setdefault("fingerprints", {}) for meta in sources_meta: p = paths.get(meta["name"]) if p and os.path.exists(p): cache[meta["name"]] = { "fp": source_fingerprint(p), "sha256": meta["sha256"], "size": meta["size"]} fresh["last_backup_at"] = datetime.now(timezone.utc).isoformat() _save_state(fresh) except Exception: pass # bookkeeping must never fail a completed backup # Cache the passphrase for unattended scheduled backups. Only write when # it is new/changed; a keychain hiccup must not fail a done backup. try: if keychain_get() != passphrase: keychain_set(passphrase) except Exception: pass return { "snapshot_id": _ui_clean(snapshot_id), "sources": len(sources_meta), "uploaded_h": human(uploaded), } return run def ui_restore_job(passphrase, snapshot_id, dest=None): """Download, verify (sha256), decrypt and safely extract a snapshot. Mirrors cmd_restore. The live tool folders are never touched: each source is extracted into dest//. dest defaults to ~/loadout-restore- in the user's home. Returns {dest, sources}.""" def run(log): cfg = require_config(None) resp = api(cfg, "POST", "/v1/restore", {"snapshot_id": snapshot_id}) downloads = resp.get("downloads") if isinstance(resp, dict) else None if not downloads: raise UserError("that snapshot has no restorable sources") key = derive_key(passphrase, cfg["enc_salt"]) if isinstance(dest, str) and dest.strip(): target = os.path.abspath(os.path.expanduser(dest.strip())) else: target = os.path.expanduser( "~/loadout-restore-%s" % _ui_short_id(snapshot_id)) os.makedirs(target, exist_ok=True) log("restoring into %s" % target) log("(your live tool folders are never overwritten -- copy files back " "yourself once you have checked them)") restored = 0 for item in downloads: if not isinstance(item, dict): continue name = item.get("name") url = item.get("url") want_sha = item.get("sha256") # The name comes from the server -- never let it steer the filesystem. if not _ui_safe_source_name(name): raise NetError( "server returned an unsafe source name -- aborting restore") if not isinstance(url, str) or not url: raise NetError("server did not return a download URL for %r" % name) log(" %s: downloading..." % name) blob = get_bytes(url) if hashlib.sha256(blob).hexdigest() != str(want_sha): raise NetError("checksum mismatch for %r -- aborting restore" % name) log(" %s: decrypting..." % name) tar_bytes = decrypt_blob(key, blob) subdir = os.path.join(target, name) os.makedirs(subdir, exist_ok=True) log(" %s: extracting..." % name) try: with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:gz") as tar: safe_extract(tar, subdir) except tarfile.TarError as exc: raise UserError("could not unpack %r: %s" % (name, exc)) restored += 1 log(" %s: done" % name) log("restore complete: %s" % target) return {"dest": target, "sources": restored} return run def ui_sync_job(passphrase, prefer_remote=False): """Install skills from the newest complete backup that contains claude-code. Reuses the host's skills-sync building blocks (_extract_skills_only with restore-grade guards into a 0700 temp dir, and _skills_fingerprint to diff). NEW skills are installed; a skill that DIFFERS keeps the local copy unless prefer_remote, which first moves the local copy aside to CONFIG_DIR/skill-conflicts/-/ before overwriting. Returns {installed, new, differ}.""" def run(log): cfg = require_config(None) local_root = os.path.expanduser("~/.claude/skills") snaps = api(cfg, "GET", "/v1/snapshots") snaps = snaps.get("snapshots") if isinstance(snaps, dict) else None cands = [s for s in (snaps or []) if isinstance(s, dict) and s.get("status") == "complete" and "claude-code" in (s.get("sources") or [])] if not cands: raise UserError( "no backup containing skills exists yet -- run a backup on the " "Mac that has your skills") snap = max(cands, key=lambda s: s.get("created") or "") log("pulling skills from snapshot %s (%s)" % (clean_text(str(snap.get("snapshot_id", "")))[:8], clean_text(str(snap.get("device", "?")))[:64])) resp = api(cfg, "POST", "/v1/restore", {"snapshot_id": snap["snapshot_id"]}) entry = next((d for d in (resp.get("downloads") or []) if isinstance(d, dict) and d.get("name") == "claude-code"), None) if isinstance(resp, dict) else None if entry is None: raise NetError("the snapshot's claude-code source is missing") url = entry.get("url") if not isinstance(url, str) or not url: raise NetError("server did not return a download URL for the skills source") log("downloading...") blob = get_bytes(url) if hashlib.sha256(blob).hexdigest() != str(entry.get("sha256", "")): raise NetError("checksum mismatch -- aborting sync") log("decrypting...") key = derive_key(passphrase, cfg["enc_salt"]) tar_bytes = decrypt_blob(key, blob) tmp = os.path.join(CONFIG_DIR, "tmp", "skills-%s" % secrets.token_hex(4)) os.makedirs(tmp, mode=0o700, exist_ok=False) try: with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:gz") as tar: extracted = _extract_skills_only(tar, tmp) if not extracted: raise UserError( "that backup contains no skills " "(~/.claude/skills was empty when it was taken)") remote_root = os.path.join(tmp, ".claude", "skills") remote = _skills_fingerprint(remote_root) local = _skills_fingerprint(local_root) new = sorted(n for n in remote if n not in local) differ = sorted(n for n in remote if n in local and remote[n] != local[n]) log("plan: %d new, %d differ%s" % ( len(new), len(differ), " (remote will replace)" if prefer_remote else " (local kept)")) os.makedirs(local_root, exist_ok=True) installed = 0 for name in new: src = os.path.join(remote_root, name) dst = os.path.join(local_root, name) if os.path.isdir(src): shutil.copytree(src, dst) else: shutil.copy2(src, dst) installed += 1 log(" + %s" % clean_text(name)) if prefer_remote and differ: stamp = datetime.now().strftime("%Y%m%d-%H%M%S") keep_dir = os.path.join(CONFIG_DIR, "skill-conflicts") for name in differ: src = os.path.join(remote_root, name) dst = os.path.join(local_root, name) saved = os.path.join(keep_dir, "%s-%s" % (name, stamp)) os.makedirs(keep_dir, exist_ok=True) if os.path.exists(dst): shutil.move(dst, saved) if os.path.isdir(src): shutil.copytree(src, dst) else: shutil.copy2(src, dst) installed += 1 log(" ~ %s (previous local copy saved aside)" % clean_text(name)) elif differ: for name in differ: log(" = %s differs -- kept the local version " "(enable 'prefer remote' to take the backup's)" % clean_text(name)) log("sync complete: %d skill%s installed" % (installed, "" if installed == 1 else "s")) return { "installed": installed, "new": [clean_text(n) for n in new], "differ": [clean_text(n) for n in differ], } finally: shutil.rmtree(tmp, ignore_errors=True) return run # =========================================================================== # 4. HANDLER HOOKS (dispatched from _route_post; return (code, obj)) # =========================================================================== def _ui_backup_passphrase(data): """Passphrase for a backup. Empty is allowed when one is already saved on this Mac -- the engine falls back to the Keychain, so a routine backup is one click. A newly typed passphrase must still be at least 12 characters.""" passphrase = data.get("passphrase") if isinstance(data, dict) else None if not passphrase and passphrase_saved(): return "" if not isinstance(passphrase, str): raise UserError("a backup passphrase is required") if len(passphrase) < 12: raise UserError("the backup passphrase must be at least 12 characters") return passphrase def _ui_existing_passphrase(data): """Passphrase for restore/sync: required and non-empty (length is whatever the user chose at backup time, so only emptiness is rejected here).""" passphrase = data.get("passphrase") if isinstance(data, dict) else None if not isinstance(passphrase, str) or not passphrase: raise UserError("a backup passphrase is required") return passphrase def ui_action_signup(data): try: email = data.get("email") if isinstance(data, dict) else None password = data.get("password") if isinstance(data, dict) else None if not isinstance(email, str) or not isinstance(password, str): raise UserError("email and password are required") api_url = _ui_auth_api_url(data) return 200, ui_signup(api_url, email, password) except Exception as exc: return _ui_map_error(exc) def ui_action_login(data): try: email = data.get("email") if isinstance(data, dict) else None password = data.get("password") if isinstance(data, dict) else None if not isinstance(email, str) or not isinstance(password, str): raise UserError("email and password are required") api_url = _ui_auth_api_url(data) return 200, ui_login(api_url, email, password) except Exception as exc: return _ui_map_error(exc) def ui_action_logout(data): try: return 200, ui_logout() except Exception as exc: return _ui_map_error(exc) def ui_action_backup(data): try: passphrase = _ui_backup_passphrase(data) job_id = _ui_start_job("backup", ui_backup_job(passphrase)) return 200, {"job_id": job_id} except Exception as exc: return _ui_map_error(exc) def ui_action_restore(data): try: passphrase = _ui_existing_passphrase(data) snapshot_id = data.get("snapshot_id") if isinstance(data, dict) else None if not isinstance(snapshot_id, str) or not snapshot_id.strip(): raise UserError("a snapshot id is required to restore") snapshot_id = snapshot_id.strip() dest = data.get("dest") if isinstance(data, dict) else None if dest is not None and not isinstance(dest, str): dest = None job_id = _ui_start_job( "restore", ui_restore_job(passphrase, snapshot_id, dest)) return 200, {"job_id": job_id} except Exception as exc: return _ui_map_error(exc) def ui_action_sync(data): try: passphrase = _ui_existing_passphrase(data) prefer_remote = bool(data.get("prefer_remote")) if isinstance(data, dict) else False job_id = _ui_start_job("sync", ui_sync_job(passphrase, prefer_remote)) return 200, {"job_id": job_id} except Exception as exc: return _ui_map_error(exc) def ui_action_job(data): job_id = data.get("job_id") if isinstance(data, dict) else None if not isinstance(job_id, str) or not job_id: return 400, {"error": "a job_id is required"} status = ui_job_status(job_id) if status is None: return 404, {"error": "unknown job"} return 200, status def ui_action_update(data): """Check loadout.works for a newer build; install it if found.""" try: current, latest, blob = check_update() if _version_tuple(latest) <= _version_tuple(current): return 200, {"current": current, "latest": latest, "updated": False} apply_update(blob) return 200, {"current": current, "latest": latest, "updated": True, "note": "restart `loadout` to use the new version"} except (NetError, ApiError) as exc: return 502, {"error": clean_text(str(exc))} except Exception: return 500, {"error": "internal error"} # ============================================================================= # EQUIP engine (Loadout) # ----------------------------------------------------------------------------- # Per-skill, per-platform cross-agent translation: a Claude Code skill installed # once under ~/.claude/skills can be "equipped" on other AI tools as a # translated copy. # # This block is appended into the single-file CLI before build_parser(). It # only defines new names prefixed equip_/_equip_ (plus the host entry points # cmd_equip and ui_action_equip) and uses only host names that already exist: # clean_text, eprint, UserError, _SKILL_NAME_RE and the stdlib modules os, re, # io, json (already imported by the host). # # SAFETY INVARIANT: every file Loadout writes begins its managed section with # _EQUIP_MARKER. Loadout only ever overwrites or deletes a file whose first # 200 bytes contain that marker; anything else is left alone and reported. # Content of skills is never printed or logged - only names, paths and status. # ============================================================================= _EQUIP_MARKER = "" _EQUIP_MARKER_WINDOW = 200 # bytes scanned when deciding "is this ours?" _EQUIP_DESC_MAX_BYTES = 96 # keeps the cursor marker inside the 200-byte window _EQUIP_FILE_PREFIX = "loadout--" _EQUIP_MAX_SKILL_BYTES = 5 * 1024 * 1024 # refuse pathological skill bodies def _equip_skills_dir(): """Canonical Claude Code skills directory (resolved lazily for testability).""" return os.path.expanduser(os.path.join("~", ".claude", "skills")) def equip_platforms(): """Supported equip targets as (platform, writable) pairs. writable=False means the platform is recognized but only supports per-project configuration, so a global equip is not possible. """ return [("cursor", True), ("devin", True), ("antigravity", False)] def _equip_target_path(platform, name): """Absolute path of the translated copy for (platform, skill), else None.""" fname = _EQUIP_FILE_PREFIX + name if platform == "cursor": return os.path.expanduser( os.path.join("~", ".cursor", "rules", fname + ".mdc")) if platform == "devin": return os.path.expanduser( os.path.join("~", ".codeium", "windsurf", "memories", fname + ".md")) return None def _equip_skill_names(): """Sorted names of installed Claude Code skills (dir or single-file form).""" root = _equip_skills_dir() try: entries = os.listdir(root) except OSError: return [] names = set() for entry in entries: full = os.path.join(root, entry) if os.path.isdir(full): if _SKILL_NAME_RE.match(entry) and os.path.isfile(os.path.join(full, "SKILL.md")): names.add(entry) elif entry.endswith(".md") and os.path.isfile(full): stem = entry[:-3] if _SKILL_NAME_RE.match(stem): names.add(stem) return sorted(names) def _equip_split_frontmatter(text): """Split off a leading YAML frontmatter block. Returns (body, description) where description is the frontmatter's "description:" value ("" if absent). If there is no well-formed leading frontmatter block, the whole text is the body. """ description = "" if text.startswith(""): text = text[1:] if not text.startswith("---"): return text, description lines = text.splitlines(True) if not lines or lines[0].strip() != "---": return text, description end = None for i in range(1, len(lines)): if lines[i].strip() in ("---", "..."): end = i break if end is None: return text, description # unterminated: treat everything as body for line in lines[1:end]: m = re.match(r"^description\s*:\s*(.*?)\s*$", line) if m: val = m.group(1) if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'): val = val[1:-1] description = val break return "".join(lines[end + 1:]), description def _equip_clean_description(desc): """Collapse a description onto one clean line (never returns None).""" if not desc or not isinstance(desc, str): return "" try: cleaned = clean_text(desc) if isinstance(cleaned, str): desc = cleaned except Exception: pass # clean_text is best-effort here; local sanitizing below suffices return re.sub(r"\s+", " ", desc).strip() def _equip_load_skill(name): """Return (body, raw_description) for a canonical skill. body is the skill markdown with any leading YAML frontmatter stripped, normalized to end with exactly one newline. Raises UserError if the skill does not exist, cannot be read, is empty, or is unreasonably large. """ root = _equip_skills_dir() src = None for cand in (os.path.join(root, name, "SKILL.md"), os.path.join(root, name + ".md")): if os.path.isfile(cand): src = cand break if src is None: raise UserError("skill '%s' not found in %s" % (name, root)) try: if os.path.getsize(src) > _EQUIP_MAX_SKILL_BYTES: raise UserError("skill '%s' is too large to equip (limit 5 MB)" % name) with io.open(src, "r", encoding="utf-8", errors="replace") as fh: raw = fh.read() except OSError as exc: raise UserError("cannot read skill '%s': %s" % (name, exc)) body, description = _equip_split_frontmatter(raw) body = body.strip("\n") if not body.strip(): raise UserError("skill '%s' has an empty body - nothing to equip" % name) return body + "\n", description def _equip_yaml_scalar(value): """Render a single-line string as a safe YAML scalar. Plain style when it is unambiguous; otherwise JSON-quoted (valid YAML double-quoted style). Cursor's MDC frontmatter is YAML, and a plain scalar containing \": \" (e.g. "Loadout skill: foo") would not parse. """ if re.match(r"^[A-Za-z0-9][A-Za-z0-9 ,.;()/_'!?-]*$", value or ""): return value return json.dumps(value if isinstance(value, str) else "") def _equip_frontmatter_description(description, name): """Description scalar for the cursor frontmatter, capped by byte length. The cap guarantees the marker line that follows the frontmatter always lands inside the first _EQUIP_MARKER_WINDOW bytes of the file, so files we write always pass our own _equip_is_managed() check. """ desc = _equip_clean_description(description) if not desc: desc = "Loadout skill: %s" % name while True: scalar = _equip_yaml_scalar(desc) if len(scalar.encode("utf-8")) <= _EQUIP_DESC_MAX_BYTES: return scalar if not desc: return '""' desc = desc[:-1].rstrip() def _equip_render(platform, name, body, description): """Render the translated file content for a writable platform.""" if platform == "cursor": scalar = _equip_frontmatter_description(description, name) return ("---\n" "description: %s\n" "alwaysApply: false\n" "---\n" "%s\n" "%s" % (scalar, _EQUIP_MARKER, body)) if platform == "devin": return "%s\n%s" % (_EQUIP_MARKER, body) raise UserError("platform '%s' has no writable equip target" % platform) def _equip_is_managed(path): """True only if path is a readable file whose first 200 bytes contain the Loadout marker. This is the sole test that authorizes overwrite/delete.""" if not path: return False try: with io.open(path, "rb") as fh: head = fh.read(_EQUIP_MARKER_WINDOW) except OSError: return False return _EQUIP_MARKER.encode("utf-8") in head def _equip_atomic_write(path, content): """Atomically write content (tmp file + rename), creating dirs 0o755.""" parent = os.path.dirname(path) if parent and not os.path.isdir(parent): os.makedirs(parent, mode=0o755, exist_ok=True) tmp = os.path.join(parent, ".%s.loadout-tmp.%d" % (os.path.basename(path), os.getpid())) try: fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) with io.open(fd, "wb") as fh: fh.write(content.encode("utf-8")) fh.flush() os.fsync(fh.fileno()) os.replace(tmp, path) except BaseException: try: os.remove(tmp) except OSError: pass raise def equip_state(): """Snapshot of the skill x platform matrix. {"skills": [{"name", "description", "equipped": {"cursor": bool, "devin": bool}}], "platforms": [{"name", "writable", "note"}]} "equipped" is derived purely from disk: a marker file exists at the target. """ platforms = equip_platforms() skills = [] for name in _equip_skill_names(): description = "" try: _body, raw_desc = _equip_load_skill(name) description = _equip_clean_description(raw_desc) except UserError: pass # still list the skill; description stays "" equipped = {} for platform, writable in platforms: if writable: equipped[platform] = _equip_is_managed(_equip_target_path(platform, name)) skills.append({"name": name, "description": description, "equipped": equipped}) return { "skills": skills, "platforms": [ {"name": p, "writable": w, "note": "" if w else "per-project only"} for p, w in platforms ], } def equip_set(skill, platform, on): """Equip (on=True) or unequip (on=False) one skill on one platform. Returns a result dict: {"ok", "skill", "platform", "on", "path", "changed", "equipped", "message"} Raises UserError for invalid input, unknown/unwritable platforms, a missing skill (when equipping), or a refusal to overwrite a file we do not own. Note: unequip does NOT require the canonical skill to still exist - after a skill is deleted from ~/.claude/skills its stragglers must still be removable. Safety comes from the marker check, not from the source. """ if not isinstance(skill, str) or not _SKILL_NAME_RE.match(skill.strip()): raise UserError("invalid skill name (letters, digits, '.', '_', '-'; max 64 chars)") skill = skill.strip() if not isinstance(platform, str) or not platform.strip(): raise UserError("missing platform") platform = platform.strip().lower() on = bool(on) writable_map = dict(equip_platforms()) if platform not in writable_map: raise UserError("unknown platform '%s' (known: %s)" % (platform, ", ".join(p for p, _w in equip_platforms()))) if platform == "antigravity": raise UserError("Antigravity reads AGENTS.md per project — per-project equip is on the roadmap") if not writable_map[platform]: raise UserError("platform '%s' only supports per-project configuration" % platform) path = _equip_target_path(platform, skill) result = {"ok": True, "skill": skill, "platform": platform, "on": on, "path": path, "changed": False, "equipped": False, "message": ""} exists = os.path.lexists(path) if os.path.islink(path): # Never write through or delete a symlink, whatever it points at. if on: raise UserError("refusing to touch %s: it is a symlink not created by Loadout" % path) result["ok"] = False result["message"] = "file was modified outside Loadout - left in place" return result if on: body, description = _equip_load_skill(skill) # validates existence if exists and not _equip_is_managed(path): raise UserError("refusing to overwrite %s: it was not created by Loadout" % path) content = _equip_render(platform, skill, body, description) result["equipped"] = True if exists: try: with io.open(path, "r", encoding="utf-8", errors="replace") as fh: if fh.read() == content: result["message"] = "already equipped (up to date)" return result except OSError: pass # unreadable but managed: fall through and rewrite try: _equip_atomic_write(path, content) except OSError as exc: raise UserError("could not write %s: %s" % (path, exc)) result["changed"] = True result["message"] = "equipped" return result # on is False: unequip if not exists: result["message"] = "not equipped" return result if not _equip_is_managed(path): result["ok"] = False result["message"] = "file was modified outside Loadout - left in place" return result try: os.remove(path) except OSError as exc: raise UserError("could not remove %s: %s" % (path, exc)) result["changed"] = True result["message"] = "unequipped" return result def _equip_parse_platforms(raw): """Parse a comma-separated platform list; validates names, keeps order.""" if not raw: return [] known = [p for p, _w in equip_platforms()] out = [] for part in str(raw).split(","): part = part.strip().lower() if not part: continue if part not in known: raise UserError("unknown platform '%s' (known: %s)" % (part, ", ".join(known))) if part not in out: out.append(part) return out def _equip_print_matrix(only=None): """Print the skill x platform matrix (names and status only, never content).""" state = equip_state() skills = state["skills"] if only is not None: skills = [s for s in skills if s["name"] == only] if not skills: raise UserError("skill '%s' not found in %s" % (only, _equip_skills_dir())) if not skills: print("No skills found in %s" % _equip_skills_dir()) return platforms = equip_platforms() name_w = max([5] + [len(s["name"]) for s in skills]) header = "%-*s %-13s" % (name_w, "SKILL", "CLAUDE-CODE") for p, _w in platforms: header += " %-13s" % p.upper() print(header.rstrip()) for s in skills: row = "%-*s %-13s" % (name_w, s["name"], "source") for p, w in platforms: if w: row += " %-13s" % ("on" if s["equipped"].get(p) else "-") else: row += " %-13s" % "per-project" print(row.rstrip()) print("") print("equip: loadout equip --on cursor,devin") print("unequip: loadout equip --off cursor,devin") print("note: Antigravity reads AGENTS.md per project — per-project equip is on the roadmap") def cmd_equip(args): """CLI entry point for 'loadout equip'. - loadout equip -> print the full equip matrix - loadout equip -> print that skill's row - loadout equip --on cursor,devin [--off ...] -> apply changes Returns 0 on success, 1 if any requested change failed. """ skill = getattr(args, "skill", None) on_arg = getattr(args, "on", None) off_arg = getattr(args, "off", None) if not skill: if on_arg or off_arg: raise UserError("usage: loadout equip --on/--off cursor,devin") _equip_print_matrix() return 0 skill = skill.strip() if not _SKILL_NAME_RE.match(skill): raise UserError("invalid skill name '%s'" % skill) on_list = _equip_parse_platforms(on_arg) off_list = _equip_parse_platforms(off_arg) if not on_list and not off_list: _equip_print_matrix(only=skill) return 0 both = [p for p in on_list if p in off_list] if both: raise UserError("platform(s) given in both --on and --off: %s" % ", ".join(both)) failures = 0 plan = [(p, True) for p in on_list] + [(p, False) for p in off_list] for platform, on in plan: try: result = equip_set(skill, platform, on) except UserError as exc: failures += 1 eprint("equip %s/%s: %s" % (skill, platform, exc)) continue detail = result.get("message") or ("on" if result.get("equipped") else "off") path = result.get("path") or "" if result.get("ok"): if result.get("changed") and path: print("%s %-11s %s (%s)" % (skill, platform + ":", detail, path)) else: print("%s %-11s %s" % (skill, platform + ":", detail)) else: failures += 1 eprint("equip %s/%s: %s%s" % (skill, platform, detail, (" (%s)" % path) if path else "")) return 1 if failures else 0 def ui_action_equip(data): """Dashboard action for POST /api/equip. data: {"skill": str, "platform": str, "on": bool} Returns (http_status, json_payload). 200 on success, 400 for bad input or refused operations, 409 when a foreign file was left in place, 500 for unexpected filesystem trouble. """ if not isinstance(data, dict): return 400, {"ok": False, "error": "expected a JSON object body"} skill = data.get("skill") platform = data.get("platform") on = data.get("on") if not isinstance(skill, str) or not skill.strip(): return 400, {"ok": False, "error": "missing field: skill"} if not isinstance(platform, str) or not platform.strip(): return 400, {"ok": False, "error": "missing field: platform"} if not isinstance(on, bool): return 400, {"ok": False, "error": "field 'on' must be true or false"} try: result = equip_set(skill, platform, on) except UserError as exc: return 400, {"ok": False, "error": str(exc)} except OSError as exc: return 500, {"ok": False, "error": "filesystem error: %s" % exc} return (200 if result.get("ok") else 409), result # --------------------------------------------------------------------------- # sessions: list + export Cursor conversations as portable markdown # Read-only against Cursor's state.vscdb; never modifies it. # --------------------------------------------------------------------------- import sqlite3 _CURSOR_DB = os.path.expanduser( "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb") _EXPORT_DIR = os.path.expanduser("~/Documents/Loadout Exports") def _cursor_connect(): if not os.path.exists(_CURSOR_DB): raise UserError("Cursor's database was not found on this Mac") return sqlite3.connect("file:%s?mode=ro" % _CURSOR_DB.replace(" ", "%20"), uri=True, timeout=10) def cursor_sessions(): """[{id, name, updated}] newest first; read-only.""" con = _cursor_connect() try: rows = con.execute( "SELECT composerId, lastUpdatedAt, value FROM composerHeaders " "WHERE isArchived = 0 ORDER BY lastUpdatedAt DESC LIMIT 500") out = [] for cid, upd, val in rows: name = "" try: name = json.loads(val).get("name") or "" except Exception: pass if not name: continue out.append({"id": cid, "name": clean_text(name)[:120], "updated": int(upd or 0)}) return out finally: con.close() def export_cursor_session(composer_id, dest_dir=None): """Write one conversation as markdown. Returns the file path.""" if not re.fullmatch(r"[0-9a-fA-F-]{36}", composer_id or ""): raise UserError("that does not look like a session id") con = _cursor_connect() try: row = con.execute("SELECT value FROM cursorDiskKV WHERE key=?", ("composerData:%s" % composer_id,)).fetchone() if not row: raise UserError("session not found in Cursor's database") d = json.loads(row[0]) name = clean_text(d.get("name") or "cursor-session") conv = d.get("fullConversationHeadersOnly") or [] parts, users, replies = [], 0, 0 for h in conv: bid = h.get("bubbleId") if not bid: continue b = con.execute("SELECT value FROM cursorDiskKV WHERE key=?", ("bubbleId:%s:%s" % (composer_id, bid),)).fetchone() if not b: continue try: bub = json.loads(b[0]) except Exception: continue text = (bub.get("text") or "").strip() if not text: continue role = bub.get("type") or h.get("type") if role == 1: users += 1 parts.append("**You:**\n\n" + text + "\n") else: replies += 1 parts.append("**Assistant:**\n\n" + text + "\n") finally: con.close() if not parts: raise UserError("that session has no text messages to export") safe = re.sub(r"[^A-Za-z0-9 ._-]", "", name).strip() or "cursor-session" dest_dir = dest_dir or _EXPORT_DIR os.makedirs(dest_dir, exist_ok=True) dest = os.path.join(dest_dir, safe[:80] + ".md") with open(dest, "w", encoding="utf-8") as fh: fh.write("# Cursor session export: %s\n\n" % name) fh.write("> Exported by Loadout. %d of your messages, %d replies. " "The original session in Cursor is untouched.\n\n---\n\n" % (users, replies)) fh.write("\n---\n\n".join(parts)) return dest def cmd_export(args): sessions = cursor_sessions() if not getattr(args, "query", None): print("Recent Cursor conversations (newest first):\n") for s in sessions[:30]: when = datetime.fromtimestamp(s["updated"] / 1000).strftime("%Y-%m-%d") print(" %s %s" % (when, s["name"])) print("\nexport one: loadout export \"\"") return q = args.query.lower() matches = [s for s in sessions if q in s["name"].lower()] if not matches: raise UserError("no Cursor conversation matches %r" % args.query) if len(matches) > 1 and not args.first: print("multiple matches -- newest exported (use a longer name to pin it):") for m in matches[:5]: print(" - %s" % m["name"]) dest = export_cursor_session(matches[0]["id"], getattr(args, "dest", None)) print("exported: %s" % dest.replace(os.path.expanduser("~"), "~")) def ui_action_sessions(data): try: plat = str((data or {}).get("platform", "cursor")) if plat == "claude": return 200, {"sessions": claude_code_sessions(), "repos": candidate_repos()} return 200, {"sessions": cursor_sessions(), "repos": candidate_repos()} except UserError as e: return 400, {"error": clean_text(str(e))} except Exception: return 500, {"error": "internal error"} def export_claude_session(session_id, dest_dir=None): """Write one Claude Code transcript out as readable Markdown.""" hits = glob.glob(os.path.expanduser( "~/.claude/projects/*/%s.jsonl" % session_id)) if not hits: raise UserError("no Claude Code conversation with that id") title, lines = "", [] with open(hits[0], errors="ignore") as fh: for line in fh: try: j = json.loads(line) except Exception: continue t = j.get("type") if t == "custom-title" and j.get("customTitle"): title = j["customTitle"] continue if t == "ai-title" and j.get("aiTitle") and not title: title = j["aiTitle"] continue if t not in ("user", "assistant"): continue content = (j.get("message") or {}).get("content") if isinstance(content, list): content = "".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text") if not isinstance(content, str) or not content.strip(): continue who = "You" if t == "user" else "Claude" lines.append("## %s\n\n%s\n" % (who, content.strip())) if not lines: raise UserError("that conversation has no text to export") title = clean_text(title or "Claude Code conversation")[:120] dest_dir = dest_dir or os.path.expanduser("~/Documents/Loadout Exports") os.makedirs(dest_dir, exist_ok=True) safe = re.sub(r"[^A-Za-z0-9 ._-]", "", title).strip() or "conversation" dest = os.path.join(dest_dir, safe + ".md") with open(dest, "w", encoding="utf-8") as fh: fh.write("# %s\n\n" % title) fh.write("\n".join(lines)) return dest def ui_action_export_session(data): try: sid = str((data or {}).get("id", "")) plat = str((data or {}).get("platform", "cursor")) dest = (export_claude_session(sid) if plat == "claude" else export_cursor_session(sid)) return 200, {"path": dest.replace(os.path.expanduser("~"), "~")} except UserError as e: return 400, {"error": clean_text(str(e))} except Exception: return 500, {"error": "internal error"} def _claude_project_dir(repo_path): slug = os.path.abspath(os.path.expanduser(repo_path)).replace("/", "-") return os.path.join(os.path.expanduser("~/.claude/projects"), slug) def _claude_version_string(): """Copy the version field from any existing transcript so imports match.""" root = os.path.expanduser("~/.claude/projects") try: for dirpath, _dirs, files in os.walk(root): for fn in files: if not fn.endswith(".jsonl"): continue try: with open(os.path.join(dirpath, fn)) as fh: for line in fh: d = json.loads(line) if d.get("type") == "user" and d.get("version"): return str(d["version"]) except Exception: continue except OSError: pass return "2.1.219" _UUID_RE = re.compile(r"[0-9a-fA-F-]{36}") def claude_active_account(): """The Claude Code account signed in right now: {email, store}. Claude Code records the signed-in identity in ~/.claude.json, and lays out its session index as /. Reading it is exact, where guessing by recent activity is not: a freshly signed-in account has an empty store and would always lose a most-recently-used ranking.""" try: with open(os.path.expanduser("~/.claude.json"), errors="ignore") as fh: oa = (json.load(fh) or {}).get("oauthAccount") or {} except (OSError, ValueError): return None acct, org = oa.get("accountUuid"), oa.get("organizationUuid") if not isinstance(acct, str) or not isinstance(org, str): return None if not _UUID_RE.fullmatch(acct) or not _UUID_RE.fullmatch(org): return None store = os.path.join( os.path.expanduser( "~/Library/Application Support/Claude/claude-code-sessions"), acct, org) return {"email": clean_text(str(oa.get("emailAddress") or ""))[:120], "store": store} def _claude_app_stores(): """Claude Code desktop app session stores (.../claude-code-sessions///).""" base = os.path.expanduser( "~/Library/Application Support/Claude/claude-code-sessions") out = [] try: for org in os.listdir(base): od = os.path.join(base, org) if not os.path.isdir(od): continue for acct in os.listdir(od): ad = os.path.join(od, acct) try: if os.path.isdir(ad) and any( f.startswith("local_") for f in os.listdir(ad)): out.append(ad) except OSError: continue except OSError: pass def _last_used(d): newest = 0.0 try: for f in os.listdir(d): if f.startswith("local_") and f.endswith(".json"): newest = max(newest, os.path.getmtime(os.path.join(d, f))) except OSError: pass try: return max(newest, os.path.getmtime(d)) if newest == 0.0 else newest except OSError: return newest out.sort(key=lambda d: -_last_used(d)) active = claude_active_account() if active: store = active["store"] # The store may not exist yet for a newly signed-in account: create it, # because that is exactly where this account's sidebar reads from. try: os.makedirs(store, exist_ok=True) except OSError: pass out = [store] + [d for d in out if os.path.abspath(d) != os.path.abspath(store)] return out def _register_with_desktop_app(cli_session_id, repo, title): """Best-effort: make an imported session visible in the desktop app sidebar. Writes ONE minimal metadata record (mirroring the app's own smallest records) pointing at the transcript. The store is the app's internal format, so this may need updating when the app changes; failure here never fails the import -- the session still opens via claude --resume.""" import uuid as _uuid stores = _claude_app_stores() if not stores: return False ms = int(time.time() * 1000) sid = "local_" + str(_uuid.uuid4()) rec = {"sessionId": sid, "cliSessionId": cli_session_id, "cwd": repo, "originCwd": repo, "createdAt": ms, "lastActivityAt": ms, "model": "claude-opus-5", "isArchived": False, "title": title, "titleSource": "user", "permissionMode": "default", "alwaysAllowedReasons": [], "sessionPermissionUpdates": []} try: with open(os.path.join(stores[0], sid + ".json"), "w") as fh: json.dump(rec, fh) return True except OSError: return False def import_cursor_session(composer_id, repo_path): """Core: write one Cursor conversation as a native Claude Code session. Additive and reversible: creates exactly ONE new .jsonl under ~/.claude/projects// and touches nothing else.""" import uuid as _uuid con = _cursor_connect() try: row = con.execute("SELECT value FROM cursorDiskKV WHERE key=?", ("composerData:%s" % composer_id,)).fetchone() if not row: raise UserError("session not found in Cursor's database") d = json.loads(row[0]) name = clean_text(d.get("name") or "Cursor conversation")[:120] msgs = [] for h in d.get("fullConversationHeadersOnly") or []: bid = h.get("bubbleId") if not bid: continue b = con.execute("SELECT value FROM cursorDiskKV WHERE key=?", ("bubbleId:%s:%s" % (composer_id, bid),)).fetchone() if not b: continue try: bub = json.loads(b[0]) except Exception: continue text = (bub.get("text") or "").strip() if not text: continue role = "user" if (bub.get("type") or h.get("type")) == 1 else "assistant" msgs.append((role, text)) finally: con.close() if not msgs: raise UserError("that conversation has no text to import") repo = os.path.abspath(os.path.expanduser(repo_path or "~")) proj = _claude_project_dir(repo) os.makedirs(proj, exist_ok=True) sid = str(_uuid.uuid4()) ver = _claude_version_string() ts = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") title = "[Imported from Cursor] " + name lines = [{"type": "custom-title", "customTitle": title, "sessionId": sid}] parent = None for i, (role, text) in enumerate(msgs): u = str(_uuid.uuid4()) common = {"parentUuid": parent, "isSidechain": False, "userType": "external", "cwd": repo, "sessionId": sid, "version": ver, "gitBranch": "HEAD", "uuid": u, "timestamp": ts, "entrypoint": "claude-desktop"} if role == "user": e = dict(common, type="user", origin={"kind": "human"}, message={"role": "user", "content": text}) else: e = dict(common, type="assistant", message={"role": "assistant", "type": "message", "id": "msg_imported_%05d" % i, "model": "imported-from-cursor", "content": [{"type": "text", "text": text}], "stop_reason": "end_turn", "stop_sequence": None, "usage": {"input_tokens": 0, "output_tokens": 0}}) lines.append(e) parent = u dest = os.path.join(proj, sid + ".jsonl") with open(dest, "w", encoding="utf-8") as fh: for l in lines: fh.write(json.dumps(l) + "\n") app_ok = _register_with_desktop_app(sid, repo, title) return {"title": title, "messages": len(msgs), "path": dest, "repo": repo, "app_registered": app_ok} def claude_projects(): """Claude Code project folders as [{path,label}] — real paths recovered from each project's own transcripts (slugs are not reversible).""" root = os.path.expanduser("~/.claude/projects") out, seen = [], set() try: dirs = sorted(os.listdir(root)) except OSError: return out for d in dirs: pdir = os.path.join(root, d) if not os.path.isdir(pdir): continue cwd = None try: for fn in sorted(os.listdir(pdir))[:8]: if not fn.endswith(".jsonl"): continue with open(os.path.join(pdir, fn), errors="ignore") as fh: for line in fh: try: j = json.loads(line) except Exception: continue if j.get("cwd"): cwd = j["cwd"] break if cwd: break except OSError: continue if cwd and cwd not in seen and os.path.isdir(cwd): seen.add(cwd) out.append({"path": cwd, "label": os.path.basename(cwd.rstrip("/")) or cwd}) out.sort(key=lambda x: x["label"].lower()) return out def ui_action_claude_projects(data): try: return 200, {"projects": claude_projects()} except Exception: return 500, {"error": "internal error"} def reregister_claude_session(session_id): """Put an existing Claude Code session back in the current account's sidebar.""" hits = glob.glob(os.path.expanduser( "~/.claude/projects/*/%s.jsonl" % session_id)) if not hits: raise UserError("no Claude Code conversation with that id") title, cwd = "", "" with open(hits[0], errors="ignore") as fh: for i, line in enumerate(fh): if i > 40: break try: j = json.loads(line) except Exception: continue if j.get("type") == "custom-title" and j.get("customTitle"): title = j["customTitle"] elif j.get("type") == "ai-title" and j.get("aiTitle") and not title: title = j["aiTitle"] if not cwd and j.get("cwd"): cwd = j["cwd"] ok = _register_with_desktop_app(session_id, cwd or os.path.expanduser("~"), clean_text(title or "Conversation")[:120]) if not ok: raise UserError("could not find the Claude Code app's session store") return {"title": title, "messages": 0, "path": hits[0], "repo": cwd, "app_registered": True, "reregistered": True} def ui_action_import_session(data): try: if str((data or {}).get("platform", "")) == "claude": r = reregister_claude_session(str((data or {}).get("id", ""))) r["path"] = r["path"].replace(os.path.expanduser("~"), "~") return 200, r r = import_cursor_session(str((data or {}).get("id", "")), str((data or {}).get("repo", ""))) r["path"] = r["path"].replace(os.path.expanduser("~"), "~") return 200, r except UserError as e: return 400, {"error": clean_text(str(e))} except Exception: return 500, {"error": "internal error"} def cmd_import(args): """Convert a Cursor conversation into a native Claude Code session.""" sessions = cursor_sessions() q = (args.query or "").lower() matches = [s for s in sessions if q in s["name"].lower()] if not matches: raise UserError("no Cursor conversation matches %r" % args.query) r = import_cursor_session(matches[0]["id"], args.repo or "~") print("imported: %s" % r["title"]) print(" %d messages -> %s" % (r["messages"], r["path"].replace(os.path.expanduser("~"), "~"))) print("open it: cd %s && claude --resume (pick it in the list)" % r["repo"]) if r.get("app_registered"): print("desktop app: quit and reopen Claude Code -- it appears in the sidebar") else: print("desktop app: not detected on this Mac (terminal only)") print("(to undo: delete that file)") def import_cursor_session(composer_id, repo): """Core of the Claude Code import; returns {title, messages, path}.""" import uuid as _uuid con = _cursor_connect() try: row = con.execute("SELECT value FROM cursorDiskKV WHERE key=?", ("composerData:%s" % composer_id,)).fetchone() if not row: raise UserError("session not found in Cursor's database") d = json.loads(row[0]) name = clean_text(d.get("name") or "cursor-session") msgs = [] for h in d.get("fullConversationHeadersOnly") or []: bid = h.get("bubbleId") if not bid: continue b = con.execute("SELECT value FROM cursorDiskKV WHERE key=?", ("bubbleId:%s:%s" % (composer_id, bid),)).fetchone() if not b: continue try: bub = json.loads(b[0]) except Exception: continue text = (bub.get("text") or "").strip() if not text: continue msgs.append(("user" if (bub.get("type") or h.get("type")) == 1 else "assistant", text)) finally: con.close() if not msgs: raise UserError("that conversation has no text to import") repo = os.path.abspath(os.path.expanduser(repo or "~")) if not os.path.isdir(repo): raise UserError("repo folder does not exist: %s" % repo) proj = _claude_project_dir(repo) os.makedirs(proj, exist_ok=True) sid = str(_uuid.uuid4()) ver = _claude_version_string() title = "[Imported from Cursor] " + name lines = [{"type": "custom-title", "customTitle": title, "sessionId": sid}] parent = None ts = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") for i, (role, text) in enumerate(msgs): u = str(_uuid.uuid4()) common = {"parentUuid": parent, "isSidechain": False, "userType": "external", "cwd": repo, "sessionId": sid, "version": ver, "gitBranch": "HEAD", "uuid": u, "timestamp": ts, "entrypoint": "claude-desktop"} if role == "user": e = dict(common, type="user", origin={"kind": "human"}, message={"role": "user", "content": text}) else: e = dict(common, type="assistant", message={"role": "assistant", "type": "message", "id": "msg_imported_%05d" % i, "model": "imported-from-cursor", "content": [{"type": "text", "text": text}], "stop_reason": "end_turn", "stop_sequence": None, "usage": {"input_tokens": 0, "output_tokens": 0}}) lines.append(e) parent = u dest = os.path.join(proj, sid + ".jsonl") with open(dest, "w", encoding="utf-8") as fh: for l in lines: fh.write(json.dumps(l) + "\n") return {"title": title, "messages": len(msgs), "path": dest.replace(os.path.expanduser("~"), "~")} def candidate_repos(): """[{name, path}] for the import picker: ~/unicorn-factory/* + home.""" out = [] base = os.path.expanduser("~/unicorn-factory") try: for entry in sorted(os.listdir(base)): p = os.path.join(base, entry) if os.path.isdir(p) and not entry.startswith("."): out.append({"name": entry, "path": p}) except OSError: pass out.append({"name": "Home (~)", "path": os.path.expanduser("~")}) return out def claude_code_sessions(): """[{id, name, updated, project}] for Claude Code's own local sessions.""" root = os.path.expanduser("~/.claude/projects") out = [] try: dirs = os.listdir(root) except OSError: return out for d in dirs: pdir = os.path.join(root, d) if not os.path.isdir(pdir): continue try: files = [f for f in os.listdir(pdir) if f.endswith(".jsonl")] except OSError: continue for fn in files: fp = os.path.join(pdir, fn) try: mtime = int(os.path.getmtime(fp) * 1000) name = "" with open(fp, errors="ignore") as fh: for i, line in enumerate(fh): if i > 40: break try: j = json.loads(line) except Exception: continue if j.get("type") == "custom-title" and j.get("customTitle"): name = j["customTitle"]; break if j.get("type") == "ai-title" and j.get("aiTitle"): name = j["aiTitle"] elif not name and j.get("type") == "user": c = (j.get("message") or {}).get("content") if isinstance(c, str): name = c[:80] if not name: continue out.append({"id": fn[:-6], "name": clean_text(name)[:120], "updated": mtime, "project": d.split("-")[-1] or d}) except OSError: continue out.sort(key=lambda s: -s["updated"]) return out[:300] def claude_code_installed(): """True only if Claude Code actually stores sessions on this Mac.""" for path in ("~/.claude/projects", "~/.claude"): if os.path.isdir(os.path.expanduser(path)): return True return False # Every tool Loadout knows how to put a conversation into, or intends to. # "writer" False means we can read/back it up but not write into it yet. _SEND_DESTINATIONS = ( ("claude", "Claude Code", True, ""), ("cursor", "Cursor", False, "Cursor keeps chats in a live database; " "writing into it safely is in progress"), ("devin", "Devin", False, "Devin's chat store is not writable yet"), ("antigravity", "Antigravity", False, "Antigravity keeps conversations per project; not writable yet"), ) def _destination_installed(key): if key == "claude": return claude_code_installed() path = {"cursor": "~/Library/Application Support/Cursor/User", "devin": "~/.codeium/windsurf", "antigravity": "~/Library/Application Support/Antigravity/User"}.get(key) return bool(path) and os.path.isdir(os.path.expanduser(path)) def send_targets_for(source_key): """Destinations we can write to AND that exist on this Mac. A destination appears only when both are true, so a user who has never installed Claude Code is never offered it.""" targets = [] for key, label, writer, why in _SEND_DESTINATIONS: if not _destination_installed(key): continue # never offer a tool this Mac does not have acct = claude_active_account() if key == "claude" else None targets.append({ "key": key, "label": label, "account": (acct or {}).get("email", ""), "ready": bool(writer), "reason": "" if writer else why, # Re-registering an existing Claude Code session puts it back in the # sidebar of whichever account is signed in now. "verb": "Show in" if (writer and source_key == key) else "Send to", "same_tool": source_key == key, }) return targets def conversation_platforms(): """Counts per platform so the UI shows cards, never uninvited dumps.""" plats = [] try: n = len(cursor_sessions()) plats.append({"key": "cursor", "label": "Cursor", "count": n, "browse": True, "export": True, "send_targets": send_targets_for("cursor")}) except Exception: pass try: cc = len(claude_code_sessions()) if cc: plats.append({"key": "claude", "label": "Claude Code", "count": cc, "browse": True, "export": True, "send_targets": send_targets_for("claude")}) except Exception: pass for key, label, path in ( ("devin", "Devin", "~/.codeium/windsurf"), ("antigravity", "Antigravity", "~/Library/Application Support/Antigravity/User")): if os.path.exists(os.path.expanduser(path)): plats.append({"key": key, "label": label, "count": None, "browse": False, "export": False, "send_targets": []}) return plats def restart_app_soon(): """Relaunch Loadout so an applied update takes effect. The engine runs as a child of the app, so a detached helper waits for us to exit, then reopens the app. Only ever targets our own parent process by pid -- never a name pattern, which could match a backup running elsewhere.""" app = "/Applications/Loadout.app" if not os.path.isdir(app): raise UserError("Loadout.app is not installed, so there is nothing to restart") ppid = os.getppid() if ppid <= 1: raise UserError("not running inside the Loadout app") # Only ever restart when our parent really is the app. Run from a terminal, # this would otherwise kill the shell that started us. try: parent = subprocess.run(["ps", "-p", str(ppid), "-o", "comm="], capture_output=True, text=True, timeout=10).stdout.strip() except Exception: parent = "" if "Loadout.app" not in parent: raise UserError("Loadout is not running as an app here -- restart it " "the way you started it") script = ( "sleep 1; kill %d 2>/dev/null; " "for i in 1 2 3 4 5 6 7 8 9 10; do " " kill -0 %d 2>/dev/null || break; sleep 0.5; " "done; " "open -a %s" % (ppid, ppid, shlex.quote(app)) ) subprocess.Popen(["/bin/sh", "-c", script], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) return True def ui_action_restart_app(data): try: # Never interrupt work in flight. with _UI_JOBS_LOCK: busy = any(j.get("status") == "running" for j in _UI_JOBS.values()) if busy: return 409, {"error": "a backup is still running -- Loadout will " "restart once it finishes"} restart_app_soon() return 200, {"restarting": True} except UserError as e: return 400, {"error": clean_text(str(e))} except Exception: return 500, {"error": "internal error"} def ui_action_update_check(data): """What the banner needs: is there a newer release, and does it matter.""" try: info = release_info() latest = info.get("version") or "" available = bool(latest) and \ _version_tuple(latest) > _version_tuple(__VERSION__) return 200, {"available": available, "current": __VERSION__, "latest": latest, "critical": available and info.get("critical") is True, "headline": info.get("headline", ""), "notes": info.get("notes", "")} except Exception: return 200, {"available": False, "current": __VERSION__} def ui_action_forget_passphrase(data): """Forget the saved passphrase so the next backup asks for a new one. Existing backups stay exactly as they are -- they remain restorable with the passphrase that made them.""" try: keychain_clear() cfg = load_config() or {} if cfg.pop("kdf_check", None) is not None: save_config(cfg) return 200, {"passphrase_saved": passphrase_saved()} except Exception: return 500, {"error": "internal error"} def ui_action_open_at_login(data): try: if bool((data or {}).get("on", True)): install_login_item() else: remove_login_item() return 200, {"on": login_item_installed()} except Exception: return 500, {"error": "internal error"} def ui_action_auto_update(data): try: cfg = load_config() or {} cfg["auto_update_check"] = bool((data or {}).get("on", True)) save_config(cfg) return 200, {"on": cfg["auto_update_check"]} except UserError as e: return 400, {"error": clean_text(str(e))} except Exception: return 500, {"error": "internal error"} def ui_action_platforms(data): try: return 200, {"platforms": conversation_platforms()} except Exception: return 500, {"error": "internal error"} # --------------------------------------------------------------------------- # entry point # --------------------------------------------------------------------------- def build_parser(): common = argparse.ArgumentParser(add_help=False) common.add_argument("--api", metavar="URL", help="Loadout API base URL (saved to config on signup/login; " "env LOADOUT_API also works)") parser = argparse.ArgumentParser( prog="loadout", description="Zero-knowledge backup for your AI-tool data. " "Your files are encrypted on this Mac; the server only " "ever stores ciphertext.") sub = parser.add_subparsers(dest="command", metavar="command") p = sub.add_parser("signup", parents=[common], help="create an account and start the free 30-day trial") p.set_defaults(func=cmd_signup) p = sub.add_parser("login", parents=[common], help="log in on this Mac") p.set_defaults(func=cmd_login) p = sub.add_parser("backup", parents=[common], help="encrypt and upload a snapshot of your AI-tool data") p.add_argument("--include-huge", action="store_true", help="include sources that exceed their per-source size limit") p.add_argument("--full", action="store_true", help="ignore the fingerprint cache and re-read every source") p.set_defaults(func=cmd_backup) p = sub.add_parser("equip", parents=[common], help="equip a Claude Code skill on other AI tools (cursor, devin)") p.add_argument("skill", nargs="?", default=None, help="skill name; omit to list the equip matrix") p.add_argument("--on", default=None, metavar="PLATFORMS", help="comma-separated platforms to equip (cursor,devin)") p.add_argument("--off", default=None, metavar="PLATFORMS", help="comma-separated platforms to unequip") p.set_defaults(func=cmd_equip) p = sub.add_parser("export", parents=[common], help="export a Cursor conversation as portable markdown") p.add_argument("query", nargs="?", help="part of the conversation's name") p.add_argument("--dest", metavar="DIR", help="destination folder " "(default: ~/Documents/Loadout Exports)") p.add_argument("--first", action="store_true", help="no multi-match note") p.set_defaults(func=cmd_export) p = sub.add_parser("import", parents=[common], help="import a Cursor conversation into Claude Code's " "session history") p.add_argument("query", help="part of the Cursor conversation's name") p.add_argument("--repo", metavar="DIR", help="repo the session should belong to in Claude Code") p.set_defaults(func=cmd_import) p = sub.add_parser("admin", parents=[common], help=argparse.SUPPRESS) p.set_defaults(func=cmd_admin) p = sub.add_parser("app", parents=[common], help="build and install the native Mac app " "(/Applications/Loadout.app)") p.add_argument("--no-launch", action="store_true", help="install without opening the app") p.set_defaults(func=cmd_app) p = sub.add_parser("update", parents=[common], help="update Loadout to the latest version") p.set_defaults(func=cmd_update) p = sub.add_parser("version", parents=[common], help="print the version") p.set_defaults(func=lambda a: print("loadout %s" % __VERSION__)) p = sub.add_parser("snapshots", parents=[common], help="list your snapshots") p.set_defaults(func=cmd_snapshots) p = sub.add_parser("restore", parents=[common], help="download, verify and decrypt a snapshot") p.add_argument("snapshot_id", metavar="SNAPID", help="snapshot id (see `loadout snapshots`)") p.add_argument("--dest", metavar="DIR", help="destination directory (default: ./loadout-restore-/)") p.set_defaults(func=cmd_restore) p = sub.add_parser("status", parents=[common], help="account, trial, last snapshot and API health") p.set_defaults(func=cmd_status) p = sub.add_parser("sync-skills", parents=[common], help="pull skills from your latest backup and install " "new ones on this Mac") p.add_argument("--auto", action="store_true", help="no confirmation prompt; installs new skills only") p.add_argument("--prefer-remote", action="store_true", help="when a skill differs, take the backup's version " "(the local copy is saved aside first)") p.set_defaults(func=cmd_sync_skills) p = sub.add_parser("dashboard", parents=[common], help="open the local Loadout control panel in your browser") p.add_argument("--headless", action="store_true", help="don't open a browser; print the URL for the app shell") p.set_defaults(func=cmd_dashboard) p = sub.add_parser("schedule", parents=[common], help="turn automatic background backups on or off") p.add_argument("--every", choices=["15m", "hourly", "6h", "daily"], default="15m", help="backup frequency (default: every 15 minutes -- cheap, " "because unchanged data is skipped by fingerprint)") p.add_argument("--off", action="store_true", help="turn automatic backups off") p.set_defaults(func=cmd_schedule) return parser def main(argv=None): parser = build_parser() args = parser.parse_args(argv) if not getattr(args, "func", None): # Bare `loadout` opens the control panel -- the UI is the product; # `loadout --help` still lists every command for terminal people. args = parser.parse_args(["dashboard"]) try: args.func(args) return 0 except UserError as e: eprint("error: %s" % e) return 1 except ApiError as e: print() # in case a progress line was mid-flight if e.status == 403 and "trial expired" in e.message.lower(): eprint("your free month of Loadout has ended -- new backups are paused.") eprint("paid plans are on the way; your existing snapshots stay restorable.") return 1 if e.status == 413: eprint("error: this backup is over your storage cap (%s)" % e.message) eprint("tip: skip large sources (drop --include-huge) and try again") return 1 if e.status == 401: eprint("error: your session has expired -- run `loadout login`") return 1 if 400 <= e.status < 500: eprint("error: %s" % e.message) return 1 eprint("server error (%d): %s" % (e.status, e.message)) return 2 except NetError as e: print() eprint("error: %s" % e) return 2 except (EOFError, KeyboardInterrupt): eprint("\ncancelled") return 1 if __name__ == "__main__": sys.exit(main())