#!/usr/bin/env bash # Checks that need nothing but the tree: script syntax, JSON validity, the # version recorded in both places, and providers.json against the kinds of # behaviour the code knows. qmllint and shellcheck run when installed and are # skipped - out loud - when not. set -uo pipefail cd "$(dirname "$0")/.." fails=0 err="$(mktemp)" tmp="$(mktemp -d)" trap 'rm -rf "$err" "$tmp"' EXIT check() { local what="$1"; shift if "$@" >"$err" 2>&1; then printf ' ok %s\n' "$what" else printf ' FAIL %s\n' "$what" sed 's/^/ | /' "$err" | head -n 20 fails=$((fails + 1)) fi } bash_syntax() { local f for f in linux/claude-mode linux/*.sh linux/lib/*.sh omarchy/install.sh scripts/*.sh \ tests/*.sh tests/cli/*.sh tests/windows/*.sh; do [ -e "$f" ] || continue bash -n "$f" || { echo "$f"; return 1; } done } check 'shell scripts parse (bash -n)' bash_syntax # awk is absent from minimal images (a stock Fedora WSL rootfs, for one), so the # CLI does without it - and one slipped in once, in a listing no test ran. no_awk() { ! grep -nw awk linux/claude-mode linux/lib/*.sh 2>/dev/null | grep -vE '^[^:]+:[0-9]+:\s*#' } check 'no awk in the POSIX CLI' no_awk check 'python compiles' env PYTHONPYCACHEPREFIX="$tmp" python3 -m py_compile \ linux/cm-json.py tests/fake_server.py tests/python/test_cm_json.py check 'JSON files parse' python3 -c ' import json, sys, glob for f in ["providers.json", "omarchy/smoido.claude-mode/manifest.json"] + glob.glob("presets/*.json"): try: json.load(open(f, encoding="utf-8")) except ValueError as e: sys.exit("%s: %s" % (f, e))' check 'VERSION matches the widget manifest' python3 -c ' import json v = open("VERSION").read().strip() m = json.load(open("omarchy/smoido.claude-mode/manifest.json"))["version"] assert v == m, "VERSION is %s but manifest.json says %s - use scripts/bump-version.sh" % (v, m)' check 'CHANGELOG has an entry for VERSION' python3 -c ' v = open("VERSION").read().strip() assert ("## %s " % v) in open("CHANGELOG.md", encoding="utf-8").read(), "no CHANGELOG.md entry for " + v' # The values each field may take are the ones the code has a branch for. A new # kind of behaviour needs code in cm-json.py, the bash CLI and claude-mode.ps1 # before it can appear here - this is where that is enforced. check 'providers.json is consistent with the code and the presets' python3 - <<'PY' import json, os, sys KINDS = {"openrouter", "lmstudio", "ollama", "openai", "static"} PROBES = {"always", "lenient", "local"} KEYS = {"required", "optional"} MODELS = {"per-tier", "one-for-all"} CHECKS = {"openrouter-key", "guardrail", "message-check", "catalogue-models", "ollama-context", "lmstudio-templates"} COLORS = {"cyan", "green", "yellow", "magenta", "white", "gray", "dkcyan"} COMMANDS = {"menu", "status", "anthropic", "presets", "preset", "set-key", "models", "doctor", "health", "preflight", "setup", "sessions", "repair-session", "repair", "help"} errors = [] def bad(pid, msg): errors.append("%s: %s" % (pid, msg)) doc = json.load(open("providers.json", encoding="utf-8")) seen = {} for p in doc["providers"]: pid = p.get("id", "?") for name in [pid] + list(p.get("aliases") or []): if name in COMMANDS: bad(pid, "'%s' would shadow the '%s' command" % (name, name)) if name in seen: bad(pid, "'%s' is already used by %s" % (name, seen[name])) seen[name] = pid for field in ("title", "label", "blurb", "defaultPreset", "preset", "catalogue", "setup", "logo"): if not p.get(field): bad(pid, "missing " + field) cat, srv, setup = p.get("catalogue") or {}, p.get("server") or {}, p.get("setup") or {} if cat.get("kind") not in KINDS: bad(pid, "catalogue.kind %r is not one of %s" % (cat.get("kind"), sorted(KINDS))) if cat.get("kind") == "static" and not cat.get("static"): bad(pid, "a static catalogue needs a static list") if srv.get("probe", "local") not in PROBES: bad(pid, "server.probe %r is not one of %s" % (srv.get("probe"), sorted(PROBES))) if srv.get("probe") in ("always", "lenient") and not srv.get("paths"): bad(pid, "a probed server needs server.paths") if setup.get("key", "required") not in KEYS: bad(pid, "setup.key %r is not one of %s" % (setup.get("key"), sorted(KEYS))) if setup.get("models", "per-tier") not in MODELS: bad(pid, "setup.models %r is not one of %s" % (setup.get("models"), sorted(MODELS))) for c in p.get("doctor") or []: if c not in CHECKS: bad(pid, "unknown doctor check %r" % c) if p.get("color", "gray") not in COLORS: bad(pid, "color %r is not one of %s" % (p.get("color"), sorted(COLORS))) auth = (p.get("preset") or {}).get("auth") or {} if auth.get("mode") not in ("vault", "literal"): bad(pid, "preset.auth.mode must be vault or literal") preset = os.path.join("presets", "%s.json" % p.get("defaultPreset")) if not os.path.exists(preset): bad(pid, "default preset %s does not exist" % preset) elif json.load(open(preset, encoding="utf-8")).get("provider") != pid: bad(pid, "default preset %s belongs to another provider" % preset) for f in sorted(os.listdir("presets")): pp = json.load(open(os.path.join("presets", f), encoding="utf-8")) if pp.get("provider") not in {p["id"] for p in doc["providers"]}: errors.append("presets/%s: provider %r is not in providers.json" % (f, pp.get("provider"))) if errors: sys.exit("\n".join(errors)) PY # Relative links, and the #anchors in them, against the headings they point at - # so moving a section between files cannot leave a dead link behind. Anchors are # slugged the way Gitea and GitHub do: lowercase, punctuation dropped, spaces to # dashes. check 'Markdown links resolve' python3 - <<'PY' import glob, os, re, sys def slug(title): title = re.sub(r"`", "", title.strip().lower()) return re.sub(r"[^\w\- ]", "", title).replace(" ", "-") def anchors(path): text = open(path, encoding="utf-8").read() text = re.sub(r"```.*?```", "", text, flags=re.S) return {slug(m.group(1)) for m in re.finditer(r"^#{1,6}\s+(.+?)\s*$", text, flags=re.M)} files = ["README.md", "CONTRIBUTING.md", "CHANGELOG.md"] + glob.glob("docs/*.md") errors = [] for f in files: text = re.sub(r"```.*?```", "", open(f, encoding="utf-8").read(), flags=re.S) for target in re.findall(r"\]\(([^)\s]+)\)", text): if re.match(r"[a-z]+:", target): continue # http:, https:, mailto: path, _, anchor = target.partition("#") dest = os.path.normpath(os.path.join(os.path.dirname(f), path)) if path else f if not os.path.exists(dest): errors.append("%s: %s - no such file" % (f, target)) elif anchor and dest.endswith(".md") and anchor not in anchors(dest): errors.append("%s: %s - no heading for #%s" % (f, target, anchor)) if errors: sys.exit("\n".join(errors)) PY QMLLINT="$(command -v qmllint || true)" [ -z "$QMLLINT" ] && [ -x /usr/lib/qt6/bin/qmllint ] && QMLLINT=/usr/lib/qt6/bin/qmllint if [ -n "$QMLLINT" ]; then # The shell's own modules (qs.*, Quickshell) do not resolve outside it, so # only real syntax errors count; unresolved-type warnings are expected. # qmllint reports a syntax error as a *warning* tagged [syntax] - matching # on the word "error" instead caught every file that mentions lastError. qml_syntax() { local f out for f in omarchy/smoido.claude-mode/*.qml; do out="$("$QMLLINT" "$f" 2>&1 | grep -F '[syntax]' || true)" [ -z "$out" ] || { echo "$f"; echo "$out"; return 1; } done } check 'QML has no syntax errors (qmllint)' qml_syntax else printf ' skip QML syntax (qmllint not installed)\n' fi if command -v shellcheck >/dev/null 2>&1; then check 'shellcheck (errors only)' shellcheck -S error -s bash \ linux/claude-mode linux/*.sh omarchy/install.sh scripts/*.sh tests/*.sh tests/cli/*.sh else printf ' skip shellcheck (not installed)\n' fi [ "$fails" -eq 0 ]