#!/usr/bin/env bash # claud.sale: configure an existing claude CLI. Never pass an API key as an argument. set +x set -euo pipefail action=setup case "${1:-}" in --help|-h) printf '%s\n' 'Usage: bash claude.sh [--restore]' 'Requires Python 3.8+ and the installed claude CLI for setup.' 'API key: ANTHROPIC_AUTH_TOKEN or a hidden local prompt. No API key is sent during setup.' 'After setup/restore open a new terminal and restart your app.' exit 0 ;; --restore) action=restore ;; '') ;; *) printf '%s\n' 'Unexpected argument. Never pass API keys as command arguments. Use --help.' >&2; exit 2 ;; esac if (( $# > 1 )); then printf '%s\n' 'Too many arguments. Use --help.' >&2; exit 2; fi if ! command -v python3 >/dev/null 2>&1; then printf '%s\n' 'Python 3.8+ is required. Install python3 with your Linux package manager first.' >&2 exit 1 fi if [[ "$action" == setup ]]; then if ! command -v claude >/dev/null 2>&1; then printf '%s\n' 'claude CLI is not installed or is absent from PATH. Install the official CLI first, then rerun this helper.' >&2 exit 1 fi CLAUDSALE_SETUP_KEY="${ANTHROPIC_AUTH_TOKEN:-}" if [[ -z "$CLAUDSALE_SETUP_KEY" ]]; then if ! { exec 3<>/dev/tty; } 2>/dev/null; then printf '%s\n' 'No terminal for hidden key entry. Run this downloaded script in an interactive terminal, or set ANTHROPIC_AUTH_TOKEN in the environment.' >&2 exit 1 fi printf 'claud.sale claude API key (hidden): ' >&3 IFS= read -r -s CLAUDSALE_SETUP_KEY <&3 || { printf '\n' >&3; exit 1; } printf '\n' >&3 exec 3>&- fi export CLAUDSALE_SETUP_KEY fi python3 - claude "$action" <<'CLAUDSALE_PYTHON' """Source embedded in standalone Bash helpers. Standard library only (Python 3.8+).""" import datetime import hashlib import json import os import pathlib import re import shlex import shutil import stat import sys import tempfile APP = sys.argv[1] ACTION = sys.argv[2] HOME_DIR = pathlib.Path.home() BASE = pathlib.Path(os.environ.get("CODEX_HOME" if APP == "codex" else "CLAUDE_CONFIG_DIR") or HOME_DIR / (".codex" if APP == "codex" else ".claude")).expanduser().absolute() BACKUPS = BASE / "claudsale-backups" CONFIG = BASE / ("config.toml" if APP == "codex" else "settings.json") ENV_FILE = BASE / "claudsale.env" KEY_NAME = "CLAUDSALE_API_KEY" if APP == "codex" else "ANTHROPIC_AUTH_TOKEN" def check_file(path): if path.is_symlink() or (path.exists() and not path.is_file()): raise ValueError("Expected an ordinary file, not a symlink or directory: " + str(path)) def current(path): check_file(path) return path.read_bytes() if path.exists() else None def atomic_write(path, data, mode=0o600): path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) check_file(path) fd, tmp = tempfile.mkstemp(prefix=".claudsale-", dir=str(path.parent)) try: os.fchmod(fd, mode) with os.fdopen(fd, "wb") as f: f.write(data) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) finally: if os.path.exists(tmp): os.unlink(tmp) def snapshot(paths, prefix, profiles=()): BACKUPS.mkdir(parents=True, exist_ok=True, mode=0o700) if BACKUPS.is_symlink(): raise ValueError("Backup directory must not be a symlink") os.chmod(BACKUPS, 0o700) name = prefix + "-" + datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") dest = BACKUPS / name dest.mkdir(mode=0o700) entries = [] for index, path in enumerate(paths): data = current(path) entry = {"path": str(path), "exists": data is not None, "mode": stat.S_IMODE(path.stat().st_mode) if data is not None else 0o600} if path in profiles: entry["profile"] = True if data is not None: entry["backup"] = str(index) + ".bin" atomic_write(dest / entry["backup"], data) entries.append(entry) atomic_write(dest / "manifest.json", json.dumps({"app": APP, "files": entries}, indent=2).encode()) return dest def restore_snapshot(dest): manifest = json.loads((dest / "manifest.json").read_text()) if manifest.get("app") != APP: raise ValueError("Backup belongs to another app") for entry in manifest["files"]: path = pathlib.Path(entry["path"]) check_file(path) if entry.get("profile"): previous = (dest / entry["backup"]).read_bytes() if entry["exists"] else b"" now = current(path) restored = restore_source_block(now or b"", previous) if not entry["exists"] and not restored and path.exists(): path.unlink() elif restored != now: mode = stat.S_IMODE(path.stat().st_mode) if now is not None else entry["mode"] atomic_write(path, restored, mode) elif entry["exists"]: atomic_write(path, (dest / entry["backup"]).read_bytes(), entry["mode"]) elif path.exists(): path.unlink() def restore(): marker = BACKUPS / "last-setup" if not marker.exists(): raise ValueError("No setup snapshot to restore") name = marker.read_text().strip() if not re.fullmatch(r"setup-[0-9TZ.]+", name): raise ValueError("Invalid backup marker") dest = BACKUPS / name manifest = json.loads((dest / "manifest.json").read_text()) paths = [pathlib.Path(entry["path"]) for entry in manifest["files"]] recovery = snapshot(paths, "before-restore") try: restore_snapshot(dest) except Exception: restore_snapshot(recovery) raise marker.unlink() print("Previous setup restored. Current files were saved in: " + str(recovery)) print("Open a new terminal and restart the app to load the restored environment.") def dotted_key(text): pattern = r'''\s*("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)\s*(\.|$)''' result, pos = [], 0 while pos < len(text): match = re.match(pattern, text[pos:]) if not match: return None part = match.group(1) result.append(json.loads(part) if part.startswith('"') else part[1:-1] if part.startswith("'") else part) pos += match.end() if not match.group(2): return result return None def scan_line(line, state): """Track TOML multiline strings/arrays so embedded text cannot become a table.""" quote, depth = state i = 0 while i < len(line): char = line[i] if quote: if quote.startswith('"') and char == "\\": i += 2 continue if line.startswith(quote, i): i += len(quote) quote = None else: i += 1 continue if char == "#": break if char in "\"'": quote = char * 3 if line.startswith(char * 3, i) else char i += len(quote) else: if char in "[{": depth += 1 elif char in "]}": depth -= 1 if depth < 0: raise ValueError("Invalid TOML: unmatched bracket") i += 1 if quote and len(quote) == 1: raise ValueError("Unsupported or invalid TOML: single-line string crosses a line") return quote, depth def edit_toml(raw): text = raw.decode("utf-8-sig") try: import tomllib except ImportError: tomllib = None if tomllib: tomllib.loads(text) newline = "\r\n" if "\r\n" in text else "\n" lines = text.splitlines(keepends=True) root = {"model": '"gpt-5.6-sol"', "model_provider": '"claudsale"'} owned = {"name": '"claud.sale"', "base_url": '"https://claud.sale/v1"', "wire_api": '"responses"', "env_key": '"CLAUDSALE_API_KEY"', "requires_openai_auth": "false"} found_root, found_owned = set(), set() section, state, first_header, target_end, target_found = [], (None, 0), len(lines), len(lines), False output = list(lines) for index, line in enumerate(lines): if state == (None, 0): stripped = line.strip() if stripped.startswith("["): header = re.match(r"^\[([^\n]*)\]\s*(?:#.*)?$", stripped) if not header: raise ValueError("Unsupported TOML table header; configure manually (no files changed)") inside = header.group(1) if inside.startswith("[") and inside.endswith("]"): inside = inside[1:-1] parts = dotted_key(inside.strip()) if parts is None: raise ValueError("Unsupported TOML table header") first_header = min(first_header, index) if section == ["model_providers", "claudsale"]: target_end = index section = parts if section == ["model_providers", "claudsale"]: if target_found: raise ValueError("Duplicate claudsale provider table") if stripped.startswith("[["): raise ValueError("Provider must be a regular TOML table") target_found = True target_end = len(lines) elif stripped and not stripped.startswith("#"): assignment = re.match(r'''^\s*((?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)(?:\s*\.\s*(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+))*)\s*=\s*(.*)$''', line.rstrip("\r\n")) if assignment: key = dotted_key(assignment.group(1)) if section == [] and key == ["profile"]: raise ValueError("An active default Codex profile is configured. Follow the manual profile instructions; no files changed") if section == [] and key and key[0] == "model_providers": raise ValueError("Inline/dotted model_providers is not supported by automatic setup; use a [model_providers.claudsale] table") if section == ["model_providers"] and key and key[0] == "claudsale": raise ValueError("Inline claudsale provider is not supported; use a [model_providers.claudsale] table") values, found = (root, found_root) if section == [] else (owned, found_owned) if section == ["model_providers", "claudsale"] else ({}, set()) if key and len(key) == 1 and key[0] in values: if key[0] in found: raise ValueError("Duplicate TOML setting") if scan_line(assignment.group(2), (None, 0)) != (None, 0): raise ValueError("Managed TOML values must be single-line; configure manually") output[index] = key[0] + " = " + values[key[0]] + newline found.add(key[0]) state = scan_line(line, state) if state != (None, 0): raise ValueError("Invalid TOML: unclosed string or array") insertions = {} missing_root = "".join(key + " = " + value + newline for key, value in root.items() if key not in found_root) if missing_root: insertions.setdefault(first_header, []).append(missing_root + newline) missing_owned = "".join(key + " = " + value + newline for key, value in owned.items() if key not in found_owned) if target_found: if missing_owned: insertions.setdefault(target_end, []).append(missing_owned) else: insertions.setdefault(len(lines), []).append(newline + "[model_providers.claudsale]" + newline + missing_owned) merged = [] for index in range(len(lines) + 1): if index in insertions: if merged and not merged[-1].endswith(("\n", "\r")): merged.append(newline) merged.extend(insertions[index]) if index < len(lines): merged.append(output[index]) result = "".join(merged) if tomllib: tomllib.loads(result) return result.encode("utf-8") def source_span(text): begin = "# >>> claud.sale " + APP + " >>>" end = "# <<< claud.sale " + APP + " <<<" starts = list(re.finditer(r"(?m)^" + re.escape(begin) + r"\r?$", text)) ends = list(re.finditer(r"(?m)^" + re.escape(end) + r"\r?$", text)) if not starts and not ends: return None if len(starts) != 1 or len(ends) != 1 or ends[0].start() < starts[0].start(): raise ValueError("Malformed claud.sale startup block; no files changed") finish = ends[0].end() if finish < len(text) and text[finish] == "\n": finish += 1 return starts[0].start(), finish def restore_source_block(raw, previous): text = raw.decode("utf-8") old = previous.decode("utf-8") span = source_span(text) old_span = source_span(old) old_block = old[old_span[0]:old_span[1]] if old_span else "" if span: text = text[:span[0]] + old_block + text[span[1]:] elif old_block: text += ("\n" if text and not text.endswith("\n") else "") + old_block return text.encode() def source_block(raw, env_file): text = raw.decode("utf-8") begin = "# >>> claud.sale " + APP + " >>>" end = "# <<< claud.sale " + APP + " <<<" block = begin + "\n[ ! -r " + shlex.quote(str(env_file)) + " ] || . " + shlex.quote(str(env_file)) + "\n" + end + "\n" span = source_span(text) if span: return (text[:span[0]] + block + text[span[1]:]).encode() return (text + ("\n" if text and not text.endswith("\n") else "") + block).encode() def setup(): key = os.environ.get("CLAUDSALE_SETUP_KEY", "") if not re.fullmatch(r"sk-[A-Za-z0-9_-]{8,512}", key): raise ValueError("Invalid API key format. Use a claud.sale sk- key; spaces and shell characters are not allowed") original_config = current(CONFIG) or b"" env = {KEY_NAME: key} if APP == "codex": config = edit_toml(original_config) if os.environ.get("CODEX_HOME"): env["CODEX_HOME"] = str(BASE) else: obj = json.loads(original_config.decode("utf-8-sig")) if original_config.strip() else {} if not isinstance(obj, dict) or ("env" in obj and not isinstance(obj["env"], dict)): raise ValueError("Claude settings.json and its env property must be JSON objects") env.update({"ANTHROPIC_BASE_URL": "https://claud.sale", "ANTHROPIC_DEFAULT_FABLE_MODEL": "claude-fable-5-1", "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-5", "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-5", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1"}) if os.environ.get("CLAUDE_CONFIG_DIR"): env["CLAUDE_CONFIG_DIR"] = str(BASE) settings_env = dict(obj.get("env", {})) settings_env.update({name: value for name, value in env.items() if name != "CLAUDE_CONFIG_DIR"}) for name in ("ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"): settings_env.pop(name, None) obj["env"] = settings_env obj["model"] = "opus" config = (json.dumps(obj, ensure_ascii=False, indent=2) + "\n").encode() env_text = "# Managed by the claud.sale " + APP + " setup helper. Contains a secret.\n" if APP == "claude": env_text += "unset ANTHROPIC_API_KEY ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY\n" env_text += "".join("export " + name + "=" + shlex.quote(value) + "\n" for name, value in env.items()) changes = {CONFIG: (config, 0o600), ENV_FILE: (env_text.encode(), 0o600)} profiles = [HOME_DIR / ".profile", HOME_DIR / ".bashrc", pathlib.Path(os.environ.get("ZDOTDIR") or HOME_DIR) / ".zshrc"] # A pre-existing .bash_profile hides .profile in Bash login shells. for candidate in (HOME_DIR / ".bash_profile", HOME_DIR / ".bash_login"): if candidate.exists(): profiles.append(candidate) break for path in dict.fromkeys(profiles): raw = current(path) mode = stat.S_IMODE(path.stat().st_mode) if raw is not None else 0o600 changes[path] = (source_block(raw or b"", ENV_FILE), mode) changed = {path: value for path, value in changes.items() if current(path) != value[0] or (path.exists() and path in (CONFIG, ENV_FILE) and stat.S_IMODE(path.stat().st_mode) != 0o600)} if not changed: print("Already configured; no files changed.") return dest = snapshot(list(changed), "setup", profiles) try: for path, (data, mode) in changed.items(): atomic_write(path, data, mode) atomic_write(BACKUPS / "last-setup", (dest.name + "\n").encode()) except Exception: restore_snapshot(dest) raise print("Configured " + APP + " for claud.sale. API key was saved locally and was not sent over the network.") print("Configuration: " + str(CONFIG)) print("Protected backup: " + str(dest)) print("Open a new terminal and restart your editor/app, or load the environment in this terminal:") print(". " + shlex.quote(str(ENV_FILE))) try: if ACTION == "restore": restore() else: setup() except Exception as exc: # Parser exception text may quote lines containing secrets. Only our own # ValueError messages are safe to print; suppress JSON/TOML parser context. if type(exc) is ValueError: print("Setup stopped: " + str(exc), file=sys.stderr) else: print("Setup stopped (" + type(exc).__name__ + "). Check file permissions and existing configuration syntax. No secret is printed.", file=sys.stderr) sys.exit(1) CLAUDSALE_PYTHON