diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5937a8b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# Matches what is already in the tree. Everything is LF, the PowerShell and +# .cmd files included: PowerShell 5.1 and cmd.exe both read LF, while a CRLF +# shell script fails its #! line. The PowerShell files are also kept ASCII-only, +# because 5.1 reads a .ps1 without a BOM in the ANSI codepage. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{qml,js,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..01454e7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# LF in every working tree, Windows checkouts included. A CRLF shell script +# fails its #! line, and the packages are built from a Windows checkout +# (scripts/build-package.ps1 normalises too, as a backstop). +* text=auto eol=lf + +*.png binary +*.jpg binary +*.ico binary diff --git a/.gitignore b/.gitignore index 07ee00b..e3abeac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ dist/ *.bak *.bak.* +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4a11c4f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +All notable changes to claude-mode. Versions follow [semver](https://semver.org): +a minor version adds behaviour, a patch fixes it. The version lives in `VERSION` +and in the bar widget's `omarchy/smoido.claude-mode/manifest.json`, and +`scripts/bump-version.sh` changes both. + +## 1.12.0 — 2026-09-15 + +- **Providers are data.** Every gateway provider is an entry in `providers.json`: + its endpoint, auth, model-list format, pre-switch probe, setup, doctor checks + and presentation. The bash CLI, the Windows build and the bar widget all read + it, and a provider that reuses existing behaviour needs no code. +- **Ollama** mode: local server, one model for every tier, `doctor` reads the + context each loaded model actually runs with (Ollama defaults to 4096 tokens + and truncates silently). +- **Custom** mode: any Anthropic-compatible endpoint; refused until it has an + address; a proxy with no model list is not treated as broken. +- Preflight refuses a preset with no server address. The Windows build gains + the no-address and no-models guards it never had. + +## 1.11.0 — 2026-09-15 + +- Preset editor in the bar panel: edit a tier from a searchable, cached model + list; create, duplicate, rename and delete presets. +- `claude-mode preset default ` chooses which preset + `claude-mode ` picks (`defaults.json`). +- `models --preset/--refresh/--json`, and a model cache every fetch keeps fresh. +- A tier edit to the active preset re-applies without the running-sessions + prompt (it moves neither endpoint nor key). +- Preset names are validated (`preset show ../../etc/passwd` used to print it). +- The key helper reads the active preset in one open, so renaming it cannot + catch a running session mid-lookup. +- The panel moved to `KeyboardPanel`: no text field in the old popup could + ever receive typing. + +## 1.10.0 — 2026-09-14 + +- Dismiss a broken session (`repair-session --ignore`), and hide broken ones + untouched for 7 days; hidden ones are always counted, never silently dropped. +- The panel offers Ignore beside Repair, with a collapsed hidden section. + +## 1.9.3 — 2026-09-13 + +- `set-key [ref] [key]` takes the ref it was given, and a key inline; the + Windows build builds the SecureString for an inline key too. + +## 1.9.2 — 2026-09-12 + +- Transcript lookup uses the project directory Claude Code actually uses: every + non-alphanumeric character becomes a dash, and the physical path is slugged. + +## 1.9.1 — 2026-09-12 + +- Runs on macOS: bash 3.2, arrow keys, honest session reporting without `/proc`. +- The `apiKeyHelper` path is quoted, and the helper says why it failed. + +## 1.9.0 — 2026-09-01 + +- One-line installs from the repository (`irm | iex`, `curl | bash`). + +## 1.8.x and earlier — 2026-08-30 to 2026-08-31 + +- Linux port with a theme-aware TUI, switch preflight and session control. +- Omarchy bar widget: shows the active provider and switches from the bar. +- Asks before switching while sessions are live; repairs sessions a switch + broke, keeping the cut turns as Markdown and handing them back. +- One preset per mode, with a first-run setup that fills it in. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e38fce6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 smoido + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a6c74e9 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +# A thin wrapper. The real commands live in scripts/ and the installers, so +# everything also works where make is not installed. +.PHONY: test test-windows install install-widget version + +test: + scripts/test.sh + +test-windows: + scripts/test.sh --windows + +install: + bash linux/install.sh + +install-widget: + bash omarchy/install.sh + +version: + @cat VERSION diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..e82c9a8 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Set the version everywhere it is recorded: VERSION (read by both CLIs and +# stamped into health.json) and the bar widget's manifest. tests/static.sh +# fails when the two disagree - they drifted apart once already (1.8.0 against +# 1.9.3) with nothing to notice. +# +# scripts/bump-version.sh 1.13.0 +set -euo pipefail +cd "$(dirname "$0")/.." + +new="${1:-}" +if ! [[ "$new" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "usage: scripts/bump-version.sh (now $(cat VERSION))" >&2 + exit 1 +fi + +printf '%s\n' "$new" > VERSION +python3 - "$new" <<'PY' +import json, re, sys +path = "omarchy/smoido.claude-mode/manifest.json" +text = open(path, encoding="utf-8").read() +# A targeted substitution rather than a json.dump, so the file's own layout +# (key order, spacing, the kinds array on one line) is left alone. +new = re.sub(r'("version"\s*:\s*")[^"]*(")', lambda m: m.group(1) + sys.argv[1] + m.group(2), text, count=1) +json.loads(new) +open(path, "w", encoding="utf-8").write(new) +PY +echo "version $new - add a CHANGELOG.md entry for it" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..93c0b4a --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Everything that can be checked from this machine, in one command. +# +# scripts/test.sh static checks, Python unit tests, CLI tests +# scripts/test.sh --windows [host] ...and the PowerShell suite on a Windows host +# over SSH (default host: winbox) +# +# Nothing here touches the real install. The CLI tests run in a sandbox with its +# own HOME, CM_ROOT and CLAUDE_CONFIG_DIR and a file-only vault (tests/lib.sh), +# and the Windows suite points USERPROFILE at a temp folder (tests/windows/run.ps1). +set -uo pipefail +cd "$(dirname "$0")/.." + +windows=0 +host=winbox +while [ $# -gt 0 ]; do + case "$1" in + --windows) + windows=1 + if [ $# -gt 1 ] && [ "${2#-}" = "$2" ]; then host="$2"; shift; fi ;; + -h|--help) sed -n '2,11p' "$0"; exit 0 ;; + *) echo "unknown option '$1'" >&2; exit 2 ;; + esac + shift +done + +failed='' +step() { + local name="$1"; shift + "$@" || failed="$failed $name" +} + +printf '== static\n' +step static bash tests/static.sh + +printf '\n== python\n' +step python env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests/python -p 'test_*.py' + +printf '\n== cli\n' +for t in tests/cli/test_*.sh; do + step "cli/$(basename "$t" .sh)" bash "$t" +done + +if [ "$windows" -eq 1 ]; then + printf '\n== windows (%s)\n' "$host" + step windows bash tests/windows/run-remote.sh "$host" +fi + +printf '\n' +if [ -z "$failed" ]; then + echo 'all passed' +else + echo "FAILED:$failed" + exit 1 +fi diff --git a/tests/cli/test_doctor.sh b/tests/cli/test_doctor.sh new file mode 100755 index 0000000..14cf180 --- /dev/null +++ b/tests/cli/test_doctor.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# doctor on a server provider: model ids against the catalogue, and Ollama's +# server-side context window. +. "$(dirname "$0")/../lib.sh" +start_fake + +ready_preset ollama "$OLLAMA_URL" +set_state ollama ollama +run_cm doctor +expect_out "Ollama reachable at $OLLAMA_URL" 'the server is found' +expect_out 'opus qwen3-coder [30.5B Q4_K_M]' 'a bare name matches its :latest tag' +expect_no_out 'NOT available' 'no model is reported missing' +expect_out 'loaded with a 4096-token context, below the declared 65536' 'the short server context is caught' +expect_out 'OLLAMA_CONTEXT_LENGTH=65536' 'and the fix is named' + +ready_preset lmstudio "$LMSTUDIO_URL" +python3 "$J" set-all "$P/lmstudio.json" gemma-small >/dev/null +set_state lmstudio lmstudio +run_cm doctor +expect_out 'gemma-small [not-loaded, ctx 8192]' 'lm studio models show their state' +expect_out 'below the declared 262144 - this tier can overflow' 'a small model window is warned about' + +python3 "$J" set-url "$P/lmstudio.json" http://127.0.0.1:1 >/dev/null +run_cm doctor +expect_out 'LM Studio not reachable' 'a dead server is a failure' + +finish diff --git a/tests/cli/test_keyhelper.sh b/tests/cli/test_keyhelper.sh new file mode 100755 index 0000000..71e8e19 --- /dev/null +++ b/tests/cli/test_keyhelper.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# The key helper Claude Code runs on a timer in every live session. +. "$(dirname "$0")/../lib.sh" +H="$CM_ROOT/bin/claude-key-helper.sh" + +helper() { OUT="$("$H" 2>&1)"; RC=$?; } + +set_state anthropic +helper +expect_eq "$RC:$OUT" '0:' 'silent on anthropic' +set_state lmstudio lmstudio +helper +expect_eq "$RC:$OUT" '0:' 'silent for an inline token' +set_state lmstudio nosuch +helper +expect_rc 1 'a missing preset fails' +expect_out 'does not exist' 'and says which' + +"$CM" set-key zai sk-test-0123456789abcdef >/dev/null 2>&1 +set_state zai zai +helper +expect_eq "$OUT" sk-test-0123456789abcdef 'emits the vault key, and nothing else' + +# Renaming the active preset while the helper loops: no fetch may see a gap. +cp "$P/lmstudio.json" "$P/racer.json" +set_state lmstudio racer +( + misses=0 + end=$((SECONDS + 4)) + while [ "$SECONDS" -lt "$end" ]; do + "$H" >/dev/null 2>"$SB/helper.err" || { grep -q 'does not exist' "$SB/helper.err" && misses=$((misses + 1)); } + done + echo "$misses" > "$SB/misses" +) & +loop=$! +cur=racer +for i in $(seq 1 30); do + "$CM" preset rename "$cur" "racer$i" >/dev/null 2>&1 + cur="racer$i" +done +wait "$loop" +expect_eq "$(cat "$SB/misses")" 0 'no key fetch is lost to a rename' +expect_eq "$(jget "$CM_ROOT/state.json" preset)" racer30 'state ends on the last name' + +finish diff --git a/tests/cli/test_models.sh b/tests/cli/test_models.sh new file mode 100755 index 0000000..a8843e5 --- /dev/null +++ b/tests/cli/test_models.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Model catalogues: fetched by each provider's kind, cached per server. +. "$(dirname "$0")/../lib.sh" +start_fake +C="$CM_ROOT/models-cache.json" + +ready_preset ollama "$OLLAMA_URL" +run_cm models --preset ollama +expect_out 'qwen3-coder:latest' 'ollama models are listed' +run_cm models --preset ollama --refresh +expect_out 'cached 2 ollama model(s)' 'and cached' +expect_eq "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["providers"]["ollama"]["baseUrl"])' "$C")" \ + "$OLLAMA_URL" 'against the server they came from' + +ready_preset lmstudio "$LMSTUDIO_URL" +run_cm models --preset lmstudio +expect_out 'qwen3-coder-30b' 'lm studio models are listed' + +python3 - "$P/custom.json" "$KEYED_URL" <<'PY' +import json, sys +p = json.load(open(sys.argv[1])) +p.update(baseUrl=sys.argv[2], auth={"mode": "literal", "token": "sk-real"}, configured=True) +json.dump(p, open(sys.argv[1], "w"), indent=2) +PY +run_cm models --preset custom +expect_out 'gw/model-b' 'a keyed endpoint lists its models with the key' + +cp "$P/custom.json" "$P/proxy.json" +python3 "$J" set-url "$P/proxy.json" "$PROXY_URL" >/dev/null +run_cm models --preset proxy --refresh +expect_rc 1 'a proxy has no list to fetch' +expect_out 'model ids can still be typed by hand' 'and that is explained' + +run_cm models --preset zai +expect_out 'glm-5.3' 'the static zai list' +run_cm models --preset zai --json +expect_out '"glm-4.7"' '--json prints the cached node' + +run_cm models --preset nope --refresh +expect_rc 1 'an unknown preset is refused' + +for word in keyRef token auth; do + case "$(cat "$C")" in *"\"$word\""*) fail "the cache holds a $word" ;; *) pass ;; esac +done + +finish diff --git a/tests/cli/test_preflight.sh b/tests/cli/test_preflight.sh new file mode 100755 index 0000000..b0985d7 --- /dev/null +++ b/tests/cli/test_preflight.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Preflight: what refuses a switch, and why - against fake servers. +. "$(dirname "$0")/../lib.sh" +start_fake + +code() { preflight_code "$@" | cut -d' ' -f1; } + +expect_eq "$(code ollama)" needs-setup 'a shipped preset is set up before use' + +ready_preset ollama "$OLLAMA_URL" +expect_eq "$(code ollama)" ok 'ollama answers' +cp "$P/ollama.json" "$P/dead.json" +python3 "$J" set-url "$P/dead.json" http://127.0.0.1:1 >/dev/null +expect_eq "$(code ollama dead)" server-unreachable 'a dead ollama is refused' + +ready_preset lmstudio "$LMSTUDIO_URL" +expect_eq "$(code lmstudio)" ok 'lm studio answers' + +# custom: every tier empty first, then no address, then the key, then a proxy +ready_preset custom +expect_eq "$(preflight_code custom)" 'no-models edit-preset' 'a preset with no models is refused' +python3 "$J" set-tier "$P/custom.json" opus gw/model-a +expect_eq "$(preflight_code custom)" 'no-url set-url' 'a custom endpoint needs an address' +python3 "$J" set-url "$P/custom.json" "$KEYED_URL" >/dev/null +python3 "$J" set-auth "$P/custom.json" none >/dev/null +expect_eq "$(code custom)" server-auth 'a keyed endpoint refuses the placeholder' +cp "$P/custom.json" "$P/proxy.json" +python3 "$J" set-url "$P/proxy.json" "$PROXY_URL" >/dev/null +expect_eq "$(code custom proxy)" ok 'a proxy with no model list passes the lenient probe' + +run_cm preset new b1 --provider openrouter --blank +expect_eq "$(code openrouter b1)" no-models 'a blank preset cannot be switched to' + +finish diff --git a/tests/cli/test_presets.sh b/tests/cli/test_presets.sh new file mode 100755 index 0000000..c27f87e --- /dev/null +++ b/tests/cli/test_presets.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Preset lifecycle: names, new/rename/rm, and the per-provider default. +. "$(dirname "$0")/../lib.sh" + +run_cm preset show ../../etc/passwd +expect_rc 1 'a path is not a preset name' +expect_out 'invalid preset name' 'and it says why' +run_cm preset new .hidden +expect_rc 1 'a leading dot is refused' +run_cm preset new a/b +expect_rc 1 'a slash is refused' + +run_cm preset new cheap +expect_rc 0 'new copies default' +expect_eq "$(jget "$P/cheap.json" provider)" openrouter 'the copy keeps the provider' +run_cm preset new z2 --provider zai +expect_out "from 'zai'" "--provider copies that provider's default" +run_cm preset new z3 --provider zai default +expect_rc 1 'copying across providers is refused' +run_cm preset new b1 --provider ollama --blank +expect_eq "$(jget "$P/b1.json" auth.token)" ollama "a blank preset carries its provider's token" +run_cm preset new b2 --blank +expect_rc 1 '--blank needs --provider' +run_cm preset new x --provider foo +expect_rc 1 'an unknown provider is refused' +run_cm preset set nope opus x +expect_rc 1 'setting a tier on a missing preset is refused' +expect_no_file "$P/nope.json" 'and does not create it' + +run_cm preset rename cheap cheap2 +expect_file "$P/cheap2.json" 'rename moves the file' +expect_no_file "$P/cheap.json" 'and removes the old name' +run_cm preset rename cheap2 default +expect_rc 1 'renaming onto an existing preset is refused' +set_state openrouter cheap2 +run_cm preset rename cheap2 cheap3 +expect_eq "$(jget "$CM_ROOT/state.json" preset)" cheap3 'state follows the active preset' +run_cm preset rm cheap3 +expect_rc 1 'the active preset cannot be deleted' +set_state anthropic + +run_cm preset default openrouter cheap3 +expect_eq "$("$CM" preset default openrouter)" cheap3 'a chosen default is used' +run_cm preset rename cheap3 cheap4 +expect_eq "$(jget "$CM_ROOT/defaults.json" openrouter)" cheap4 'the choice follows a rename' +run_cm preset rm cheap4 +expect_eq "$(jget "$CM_ROOT/defaults.json" openrouter)" '' 'deleting clears the choice' +expect_eq "$("$CM" preset default openrouter)" default 'and the built-in applies again' +run_cm preset default openrouter zai +expect_rc 1 "a default from another provider is refused" + +"$CM" preset new c5 >/dev/null 2>&1 +"$CM" preset default openrouter c5 >/dev/null 2>&1 +rm -f "$P/c5.json" +expect_eq "$("$CM" preset default openrouter)" default 'a vanished choice falls back' + +run_cm preset rm z2 +run_cm preset rm zai +expect_out 'that was the last zai preset' 'removing the last preset warns' + +finish diff --git a/tests/cli/test_providers.sh b/tests/cli/test_providers.sh new file mode 100755 index 0000000..5d665c2 --- /dev/null +++ b/tests/cli/test_providers.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Every provider in providers.json is a mode: listed, dispatched, aliased. +. "$(dirname "$0")/../lib.sh" + +run_cm --help +for id in openrouter zai lmstudio ollama custom; do + expect_out "claude-mode $id [preset]" "usage lists $id" +done + +run_cm presets +expect_out 'ollama' 'presets lists ollama' +expect_out 'custom' 'presets lists custom' + +for word in zai Z.AI z-ai lm-studio OLLAMA; do + code="$(preflight_code "$word")" + expect_eq "${code%% *}" needs-setup "preflight resolves '$word'" +done + +run_cm nope +expect_rc 1 'an unknown command fails' +expect_out "unknown command 'nope'" 'and says so' + +run_cm preset default +expect_out 'ollama ollama (built-in)' 'ollama has a built-in default' +expect_out 'custom custom (built-in)' 'custom has a built-in default' + +finish diff --git a/tests/cli/test_sessions.sh b/tests/cli/test_sessions.sh new file mode 100755 index 0000000..e2ee60d --- /dev/null +++ b/tests/cli/test_sessions.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Broken transcripts: the scan, dismissing and restoring, the age rule, repair. +. "$(dirname "$0")/../lib.sh" + +proj="$CLAUDE_CONFIG_DIR/projects/-tmp-fixture" +mkdir -p "$proj" +transcript() { # [age-in-hours] + printf '%s\n' \ + '{"type":"assistant","message":{"id":"msg_1","model":"claude-opus-5","content":[{"type":"text","text":"hi"}]}}' \ + '{"type":"assistant","message":{"id":"gen-1-a","model":"deepseek/x","content":[{"type":"text","text":"yo"}]}}' \ + > "$proj/$1.jsonl" + python3 -c 'import os, sys, time; t = time.time() - float(sys.argv[2]) * 3600; os.utime(sys.argv[1], (t, t))' \ + "$proj/$1.jsonl" "${2:-0}" +} +transcript aaaa-new +transcript bbbb-old 240 +transcript dddd-fix 1 +printf '%s\n' '{"type":"assistant","message":{"id":"msg_1"}}' > "$proj/cccc-ok.jsonl" + +ids() { # - the session ids in that list of the scan + "$CM" repair-session --json "${@:2}" | python3 -c ' +import json, sys +print(" ".join(x["sessionId"] for x in json.load(sys.stdin)[sys.argv[1]]))' "$1" +} + +expect_eq "$(ids broken)" 'aaaa-new dddd-fix' 'broken transcripts are found' +expect_eq "$(ids ignored)" 'bbbb-old' 'one untouched for 10 days is hidden' +expect_eq "$(ids broken --max-age 0)" 'aaaa-new bbbb-old dddd-fix' '--max-age 0 shows all' + +run_cm repair-session --ignore aaaa-new +expect_out 'hidden aaaa-new' 'a session is dismissed' +expect_eq "$(ids broken)" 'dddd-fix' 'and leaves the broken list' +run_cm repair-session --all +expect_out '2 hidden: 1 ignored, 1 older than 7 days' 'hidden sessions are always counted' +run_cm repair-session --ignored +expect_out 'still broken' 'the dismissed list reconciles against the disk' +run_cm repair-session --ignore zzzz +expect_rc 1 'an unknown session cannot be dismissed' +run_cm repair-session --unignore aaaa-new +expect_eq "$(ids broken)" 'aaaa-new dddd-fix' 'restoring brings it back' + +run_cm repair-session --ignore dddd-fix +run_cm repair-session dddd-fix --apply +expect_rc 0 'a broken session is repaired' +expect_eq "$(ls "$proj" | grep -c 'dddd-fix.jsonl.pre-repair-backup')" 1 'the original is backed up' +expect_eq "$(ids ignored)" 'bbbb-old' 'a repair clears its dismissal' + +run_cm repair-session --ignore bbbb-old +rm -f "$proj/bbbb-old.jsonl" +run_cm repair-session --ignore aaaa-new +expect_out 'forgot 1 whose transcript no longer exists' 'entries for deleted transcripts are pruned' +run_cm repair-session --unignore-all +expect_out 'restored every dismissed session' 'unignore-all empties the list' + +finish diff --git a/tests/cli/test_switch.sh b/tests/cli/test_switch.sh new file mode 100755 index 0000000..aac6986 --- /dev/null +++ b/tests/cli/test_switch.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# A real switch - into the sandbox's own settings.json - and editing the preset +# in use. --yes because the machine running the tests may well have Claude Code +# sessions open; with --yes the CLI proceeds and leaves them alone. +. "$(dirname "$0")/../lib.sh" +start_fake +S="$CLAUDE_CONFIG_DIR/settings.json" + +ready_preset ollama "$OLLAMA_URL" +run_cm ollama --yes +expect_rc 0 'switch to ollama' +expect_eq "$(jget "$S" env.ANTHROPIC_BASE_URL)" "$OLLAMA_URL" 'base url written' +expect_eq "$(jget "$S" env.ANTHROPIC_AUTH_TOKEN)" ollama 'placeholder token written' +expect_eq "$(jget "$S" env.ANTHROPIC_DEFAULT_OPUS_MODEL)" qwen3-coder 'tier mapped' +expect_eq "$(jget "$S" env.CLAUDE_CODE_MAX_CONTEXT_TOKENS)" 65536 'context declared' +expect_eq "$(jget "$S" env.CLAUDE_CODE_ATTRIBUTION_HEADER)" 0 'extra env written' +expect_eq "$(jget "$CM_ROOT/state.json" mode)" ollama 'state records the mode' +expect_eq "$(jget "$CM_ROOT/health.json" preset)" ollama 'health.json follows' + +run_cm anthropic --yes +expect_rc 0 'switch back to anthropic' +expect_eq "$(jget "$S" env.ANTHROPIC_BASE_URL)" '' 'gateway env removed' +expect_eq "$(jget "$S" env.CLAUDE_CODE_ATTRIBUTION_HEADER)" '' 'including the extra env' + +# The preset in use: a tier edit re-applies without asking about sessions. +set_state openrouter default +run_cm preset set default sonnet test/model-c --force +expect_rc 0 'a tier edit on the active preset re-applies' +expect_no_out 'refusing to switch while sessions are running' 'without the sessions prompt' +expect_eq "$(jget "$S" env.ANTHROPIC_DEFAULT_SONNET_MODEL)" test/model-c 'and reaches settings.json' + +run_cm preset set default opus anthropic/claude-opus-5 --force +expect_rc 1 'the cost guard still refuses an Anthropic model' +expect_out 'saved, but re-applying the active preset failed' 'and says the edit was saved' + +finish diff --git a/tests/fake_server.py b/tests/fake_server.py new file mode 100755 index 0000000..7580a15 --- /dev/null +++ b/tests/fake_server.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Fake provider servers for the CLI and Windows tests, all on 127.0.0.1. + + ollama GET /api/tags tagged names only, as the real server lists them + GET /api/ps qwen3-coder:latest loaded with a 4096-token context + POST /api/show a 262144-token maximum + lmstudio GET /api/v0/models + keyed GET /v1/models, only with `Bearer sk-real` or `x-api-key: sk-real` + proxy 404 for everything - a Messages-only proxy with no model list + +Ports are chosen by the OS. The first line printed is a JSON map of kind to +port, which the harness reads before running anything. +""" + +import json +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def send(self, code, obj): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + kind, path = self.server.kind, self.path + if kind == "ollama" and path == "/api/tags": + return self.send(200, {"models": [ + {"name": "qwen3-coder:latest", "model": "qwen3-coder:latest", + "details": {"parameter_size": "30.5B", "quantization_level": "Q4_K_M", "family": "qwen3moe"}}, + {"name": "glm-4.7:cloud", "model": "glm-4.7:cloud", "details": {}}]}) + if kind == "ollama" and path == "/api/ps": + return self.send(200, {"models": [ + {"name": "qwen3-coder:latest", "model": "qwen3-coder:latest", "context_length": 4096}]}) + if kind == "lmstudio" and path == "/api/v0/models": + return self.send(200, {"data": [ + {"id": "qwen3-coder-30b", "state": "loaded", "max_context_length": 262144}, + {"id": "gemma-small", "state": "not-loaded", "max_context_length": 8192}]}) + if kind == "keyed" and path == "/v1/models": + if self.headers.get("authorization") == "Bearer sk-real" or self.headers.get("x-api-key") == "sk-real": + return self.send(200, {"data": [{"id": "gw/model-a"}, {"id": "gw/model-b"}]}) + return self.send(401, {"error": "unauthorized"}) + return self.send(404, {"error": "not found"}) + + def do_POST(self): + self.rfile.read(int(self.headers.get("content-length") or 0)) + if self.server.kind == "ollama" and self.path == "/api/show": + return self.send(200, {"model_info": {"general.architecture": "qwen3moe", + "qwen3moe.context_length": 262144}}) + return self.send(404, {"error": "not found"}) + + +def main(): + ports = {} + for kind in ("ollama", "lmstudio", "keyed", "proxy"): + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server.kind = kind + ports[kind] = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + print(json.dumps(ports), flush=True) + threading.Event().wait() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(0) diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100755 index 0000000..645e76e --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,117 @@ +# Shared by tests/cli/test_*.sh - source it first. +# +# Sourcing gives the test its own sandbox: a HOME, CM_ROOT and CLAUDE_CONFIG_DIR +# of its own, the repository's scripts installed into it the way linux/install.sh +# lays them out, and the vault forced to the plain-file backend. Nothing on the +# machine running the tests is read or written - not the real keyring, not +# ~/.claude-mode, not ~/.claude/settings.json - and a switch performed by a test +# only rewrites the sandbox's settings.json. +# +# Assertions count rather than stop, so one run reports every failure; the +# file ends with `finish`, whose status is the test's. + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T_NAME="$(basename "$0" .sh)" +T_PASS=0 +T_FAIL=0 +OUT='' +RC=0 +FAKE_PID='' + +SB="$(mktemp -d "${TMPDIR:-/tmp}/cm-test.XXXXXX")" +mkdir -p "$SB/root/bin" "$SB/root/presets" "$SB/home/.claude/projects" +cp "$REPO/linux/claude-mode" "$REPO/linux/cm-json.py" "$REPO/linux/cm-vault.sh" \ + "$REPO/linux/claude-key-helper.sh" "$SB/root/bin/" +if [ -d "$REPO/linux/lib" ]; then cp -R "$REPO/linux/lib" "$SB/root/bin/"; fi +chmod +x "$SB/root/bin/claude-mode" "$SB/root/bin/claude-key-helper.sh" +cp "$REPO"/presets/*.json "$SB/root/presets/" +cp "$REPO/providers.json" "$REPO/VERSION" "$SB/root/" + +export HOME="$SB/home" +export CM_ROOT="$SB/root" +export CLAUDE_CONFIG_DIR="$SB/home/.claude" +export CLAUDE_MODE_VAULT=file +unset CM_IGNORE_AGE_DAYS CLAUDE_MODE_THEME + +CM="$SB/root/bin/claude-mode" +J="$SB/root/bin/cm-json.py" +P="$SB/root/presets" + +set_state() { + printf '{"mode":"%s","preset":"%s","writtenEnvKeys":[]}\n' "$1" "${2:-}" > "$CM_ROOT/state.json" +} +set_state anthropic + +t_cleanup() { + if [ -n "$FAKE_PID" ]; then kill "$FAKE_PID" 2>/dev/null; fi + rm -rf "$SB" +} +trap t_cleanup EXIT + +# Starts tests/fake_server.py and sets OLLAMA_URL, LMSTUDIO_URL, KEYED_URL and +# PROXY_URL from the ports it chose. +start_fake() { + python3 "$REPO/tests/fake_server.py" > "$SB/ports" 2>/dev/null & + FAKE_PID=$! + local i + for i in $(seq 1 50); do + [ -s "$SB/ports" ] && break + sleep 0.1 + done + [ -s "$SB/ports" ] || { echo "$T_NAME: the fake server did not start" >&2; exit 1; } + eval "$(python3 -c ' +import json, sys +for kind, port in json.load(open(sys.argv[1])).items(): + print("%s_URL=http://127.0.0.1:%d" % (kind.upper(), port)) +' "$SB/ports")" +} + +ESC="$(printf '\033')" + +# run_cm : the CLI's combined output, colour codes removed, in OUT; +# its exit status in RC. +run_cm() { + OUT="$("$CM" "$@" 2>&1)" + RC=$? + OUT="$(printf '%s' "$OUT" | sed "s/${ESC}\[[0-9;]*m//g")" +} + +pass() { T_PASS=$((T_PASS + 1)); } + +fail() { + T_FAIL=$((T_FAIL + 1)) + printf ' FAIL %s: %s\n' "$T_NAME" "$*" + if [ -n "$OUT" ]; then printf '%s\n' "$OUT" | tail -n 8 | sed 's/^/ | /'; fi +} + +expect_rc() { if [ "$RC" -eq "$1" ]; then pass; else fail "$2 (exit $RC, wanted $1)"; fi; } +expect_out() { case "$OUT" in *"$1"*) pass ;; *) fail "$2 (output lacks: $1)" ;; esac; } +expect_no_out() { case "$OUT" in *"$1"*) fail "$2 (output has: $1)" ;; *) pass ;; esac; } +expect_eq() { if [ "$1" = "$2" ]; then pass; else fail "$3 (got '$1', wanted '$2')"; fi; } +expect_file() { if [ -e "$1" ]; then pass; else fail "$2 (no $1)"; fi; } +expect_no_file(){ if [ -e "$1" ]; then fail "$2 ($1 exists)"; else pass; fi; } + +jget() { python3 "$J" get "$1" "$2"; } + +# The preflight verdict for a mode (and preset) as " ". +preflight_code() { + "$CM" preflight "$@" 2>/dev/null | python3 -c ' +import json, sys +d = json.load(sys.stdin) +print(d["code"], d["remedyKind"])' +} + +# Point a shipped preset at a fake server and mark it set up, so preflight +# gets past "not set up yet" to the checks under test. +ready_preset() { + python3 "$J" set-flag "$P/$1.json" configured true + [ -n "${2:-}" ] && python3 "$J" set-url "$P/$1.json" "$2" >/dev/null + return 0 +} + +finish() { + printf '%-22s %3d passed' "$T_NAME" "$T_PASS" + if [ "$T_FAIL" -gt 0 ]; then printf ', %d FAILED' "$T_FAIL"; fi + printf '\n' + [ "$T_FAIL" -eq 0 ] +} diff --git a/tests/python/test_cm_json.py b/tests/python/test_cm_json.py new file mode 100644 index 0000000..409443a --- /dev/null +++ b/tests/python/test_cm_json.py @@ -0,0 +1,393 @@ +"""Unit tests for linux/cm-json.py - the JSON engine behind the POSIX CLI. + +Standard library only. cm-json.py is loaded from the repository, so it reads the +repository's providers.json (it looks one level above itself). Every test that +writes works in a temporary directory. +""" + +import contextlib +import importlib.util +import io +import json +import os +import shutil +import sys +import tempfile +import time +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_spec = importlib.util.spec_from_file_location("cm_json", os.path.join(REPO, "linux", "cm-json.py")) +cm = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(cm) + + +def call(fn, argv, stdin=""): + """Run a cm-json command function; return what it printed.""" + out, old = io.StringIO(), sys.stdin + sys.stdin = io.StringIO(stdin) + try: + with contextlib.redirect_stdout(out): + fn(argv) + finally: + sys.stdin = old + return out.getvalue() + + +def call_json(fn, argv, stdin=""): + return json.loads(call(fn, argv, stdin)) + + +class TempDir(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="cm-json-test.") + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def path(self, *parts): + return os.path.join(self.tmp, *parts) + + def write_json(self, rel, data): + p = self.path(rel) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w", encoding="utf-8") as fh: + json.dump(data, fh) + return p + + +# --------------------------------------------------------------------------- +# providers.json +# --------------------------------------------------------------------------- + +class Providers(unittest.TestCase): + def test_order(self): + self.assertEqual([p["id"] for p in cm.providers()], + ["openrouter", "zai", "lmstudio", "ollama", "custom"]) + + def test_builtin_defaults(self): + self.assertEqual(cm.builtin_default_preset(), { + "openrouter": "default", "zai": "zai", "lmstudio": "lmstudio", + "ollama": "ollama", "custom": "custom"}) + + def test_tsv_column_contract(self): + # The bash CLI addresses these columns by number (see prov_field in + # linux/claude-mode). Reordering them silently breaks it; append only. + self.assertEqual([name for name, _ in cm.PROVIDER_TSV], [ + "id", "aliases", "title", "label", "color", "defaultPreset", + "serverEditable", "probe", "probePaths", "catalogueKind", "perServer", + "setupKey", "setupModels", "keyUrl", "guardrail", "doctor", + "defaultKeyRef", "literalToken", "defaultBaseUrl", "serverHint", "serverStart"]) + for line in call(cm.cmd_provider_tsv, []).splitlines(): + self.assertEqual(len(line.split("\t")), len(cm.PROVIDER_TSV), line) + + def test_resolve(self): + for word, want in [("zai", "zai"), ("Z.AI", "zai"), ("z-ai", "zai"), + ("lm-studio", "lmstudio"), ("OLLAMA", "ollama")]: + self.assertEqual(call(cm.cmd_provider_resolve, [word]).strip(), want) + for word in ("anthropic", "nope", ""): + with self.assertRaises(SystemExit): + call(cm.cmd_provider_resolve, [word]) + + def test_static_catalogue(self): + self.assertEqual(call(cm.cmd_provider_static, ["zai"]).splitlines()[0].split("\t")[0], "glm-5.3") + + +class Scaffold(unittest.TestCase): + def blank(self, **over): + base = {"provider": None, "description": "new preset", "baseUrl": None, "auth": None, + "models": {t: "" for t in cm.TIERS}, "subagentModel": "inherit", + "gatewayModelDiscovery": False, "contextTokens": None} + base.update(over) + return json.dumps(base, indent=2) + "\n" + + # The original three, exactly as the hard-coded scaffold produced them + # before providers.json existed. + def test_openrouter(self): + self.assertEqual(call(cm.cmd_scaffold, ["openrouter"]), self.blank( + provider="openrouter", baseUrl="https://openrouter.ai/api", + auth={"mode": "vault", "keyRef": "openrouter"}, gatewayModelDiscovery=True, + contextTokens=1000000)) + + def test_zai(self): + want = json.loads(self.blank( + provider="zai", baseUrl="https://api.z.ai/api/anthropic", + auth={"mode": "vault", "keyRef": "zai"}, contextTokens=1000000)) + want["extraEnv"] = {"API_TIMEOUT_MS": "3000000", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"} + self.assertEqual(call(cm.cmd_scaffold, ["zai"]), json.dumps(want, indent=2) + "\n") + + def test_lmstudio(self): + want = json.loads(self.blank( + provider="lmstudio", baseUrl="http://127.0.0.1:1234", + auth={"mode": "literal", "token": "lmstudio"}, contextTokens=262144)) + want["extraEnv"] = {"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"} + self.assertEqual(call(cm.cmd_scaffold, ["lmstudio"]), json.dumps(want, indent=2) + "\n") + + def test_new_providers(self): + o = json.loads(call(cm.cmd_scaffold, ["ollama"])) + self.assertEqual((o["baseUrl"], o["auth"], o["contextTokens"]), + ("http://127.0.0.1:11434", {"mode": "literal", "token": "ollama"}, 65536)) + c = json.loads(call(cm.cmd_scaffold, ["custom"])) + self.assertEqual((c["baseUrl"], c["auth"]), ("", {"mode": "vault", "keyRef": "custom"})) + + def test_unknown(self): + with self.assertRaises(SystemExit): + call(cm.cmd_scaffold, ["nope"]) + + +# --------------------------------------------------------------------------- +# Catalogues +# --------------------------------------------------------------------------- + +class Parsers(unittest.TestCase): + def test_openrouter(self): + src = {"data": [{"id": "b/x", "context_length": 1000, "pricing": {"prompt": "0.000001", "completion": "0.000002"}}, + {"id": "a/y", "context_length": 5, "pricing": {}}]} + self.assertEqual(call(cm.cmd_or_models, [], json.dumps(src)).splitlines(), + ["a/y\t5\t\t", "b/x\t1000\t1.0\t2.0"]) + + def test_lmstudio(self): + src = {"data": [{"id": "m", "state": "loaded", "max_context_length": 8192}]} + self.assertEqual(call(cm.cmd_lms_models, [], json.dumps(src)), "m\tloaded\t8192\n") + + def test_ollama_placeholders(self): + # "-" rather than empty, so bash's tab-IFS read cannot merge columns. + src = {"models": [{"name": "glm:cloud", "details": {}}, + {"name": "q:latest", "details": {"parameter_size": "7B", "quantization_level": "Q4"}}]} + self.assertEqual(call(cm.cmd_ollama_models, [], json.dumps(src)).splitlines(), + ["glm:cloud\t-\t-\t-", "q:latest\t7B\tQ4\t-"]) + + def test_openai_both_shapes(self): + self.assertEqual(call(cm.cmd_openai_models, [], json.dumps({"data": [{"id": "b"}, {"id": "a"}]})), "a\nb\n") + self.assertEqual(call(cm.cmd_openai_models, [], json.dumps([{"id": "x"}])), "x\n") + + +class Cache(TempDir): + def cache(self, provider, ok, base, tsv): + call(cm.cmd_cache_models, [self.path("c.json"), provider, "1" if ok else "0", base], tsv) + with open(self.path("c.json")) as fh: + return json.load(fh)["providers"][provider] + + def test_per_server_and_failure(self): + node = self.cache("ollama", True, "http://a:1/", "q:latest\t7B\tQ4\t-\n") + self.assertEqual((node["baseUrl"], node["models"]), ("http://a:1", [{"id": "q:latest", "note": "7B Q4"}])) + node = self.cache("ollama", False, "http://a:1", "") + self.assertEqual((node["ok"], len(node["models"])), (False, 1)) # a stale list beats none + node = self.cache("ollama", False, "http://b:2", "") + self.assertEqual((node["baseUrl"], node["models"]), ("http://b:2", [])) # another server's list is wrong + + def test_openrouter_prices_and_no_secrets(self): + node = self.cache("openrouter", True, "", "a/y\t5\t0.1\t0.2\n") + self.assertEqual(node["models"], [{"id": "a/y", "contextTokens": 5, "priceIn": 0.1, "priceOut": 0.2}]) + self.assertNotIn("baseUrl", node) + with open(self.path("c.json")) as fh: + text = fh.read() + for word in ("keyRef", "token", "auth"): + self.assertNotIn('"%s"' % word, text) + + +# --------------------------------------------------------------------------- +# Session transcripts: scan, dismiss, repair +# --------------------------------------------------------------------------- + +GOOD = {"type": "assistant", "message": {"id": "msg_1", "model": "claude-opus-5", "content": [{"type": "text", "text": "hi"}]}} +FOREIGN = {"type": "assistant", "message": {"id": "gen-1-a", "model": "deepseek/x", "content": [{"type": "text", "text": "yo"}]}} + + +class Sessions(TempDir): + def setUp(self): + super().setUp() + self.projects = self.path("projects") + os.makedirs(os.path.join(self.projects, "-p")) + self.ignored = self.path("ignored.json") + + def transcript(self, sid, lines, age_days=0): + p = os.path.join(self.projects, "-p", sid + ".jsonl") + with open(p, "w") as fh: + fh.write("\n".join(json.dumps(x) for x in lines) + "\n") + t = time.time() - age_days * 86400 + os.utime(p, (t, t)) + return p + + def scan(self, max_age=""): + return call_json(cm.cmd_scan_sessions, [self.projects, max_age, self.ignored]) + + def test_scan_routes_by_reason(self): + self.transcript("new", [GOOD, FOREIGN]) + self.transcript("old", [GOOD, FOREIGN], age_days=10) + self.transcript("healthy", [GOOD]) + self.transcript("native", [FOREIGN]) # ran entirely on a gateway: not damage + d = self.scan() + self.assertEqual([b["sessionId"] for b in d["broken"]], ["new"]) + self.assertEqual([(i["sessionId"], i["reason"]) for i in d["ignored"]], [("old", "stale")]) + self.assertEqual(self.scan("0")["count"], 2) + + def test_dismiss_list_prune(self): + self.transcript("new", [GOOD, FOREIGN]) + gone = self.transcript("gone", [GOOD, FOREIGN]) + call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "new"]) + call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "gone"]) + self.assertEqual([(i["sessionId"], i["reason"]) for i in self.scan()["ignored"]], + [("gone", "dismissed"), ("new", "dismissed")]) + rows = call_json(cm.cmd_ignore_session, [self.ignored, self.projects, "list"])["sessions"] + self.assertTrue(all(r["broken"] and r["exists"] for r in rows)) + os.remove(gone) + out = call_json(cm.cmd_ignore_session, [self.ignored, self.projects, "remove", "new"]) + self.assertEqual((out["changed"], out["pruned"], out["count"]), (True, 1, 0)) + with self.assertRaises(SystemExit): + call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "../x"]) + with self.assertRaises(SystemExit): + call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "nosuch"]) + + def test_repair_truncates_and_hands_back(self): + p = self.transcript("fix", [GOOD, FOREIGN]) + v = call_json(cm.cmd_repair_session, [p, "--apply"]) + self.assertTrue(v["applied"] and v["reinjected"]) + self.assertTrue(os.path.exists(v["backup"]) and os.path.exists(v["recovered"])) + with open(p) as fh: + lines = [json.loads(x) for x in fh.read().splitlines()] + self.assertEqual(lines[0]["message"]["id"], "msg_1") + self.assertTrue(lines[1]["isMeta"]) + self.assertIn("", lines[1]["message"]["content"]) + self.assertTrue(call_json(cm.cmd_repair_session, [p])["healthy"]) + + +# --------------------------------------------------------------------------- +# Presets: rename, defaults, auth +# --------------------------------------------------------------------------- + +class Presets(TempDir): + def setUp(self): + super().setUp() + self.presets = self.path("presets") + shutil.copytree(os.path.join(REPO, "presets"), self.presets) + self.state = self.write_json("state.json", {"mode": "openrouter", "preset": "default", "writtenEnvKeys": []}) + self.defaults = self.write_json("defaults.json", {"openrouter": "default"}) + + def load(self, p): + with open(p) as fh: + return json.load(fh) + + def test_rename_repoints_state_and_default(self): + out = call_json(cm.cmd_preset_rename, [self.presets, "default", "daily", self.state, self.defaults]) + self.assertEqual(out, {"renamed": True, "active": True, "default": True}) + self.assertEqual(self.load(self.state)["preset"], "daily") + self.assertEqual(self.load(self.defaults)["openrouter"], "daily") + self.assertFalse(os.path.exists(os.path.join(self.presets, "default.json"))) + with self.assertRaises(SystemExit): + call(cm.cmd_preset_rename, [self.presets, "daily", "zai", self.state]) + with self.assertRaises(SystemExit): + call(cm.cmd_preset_rename, [self.presets, "nosuch", "x", self.state]) + + def test_set_default_and_clear(self): + call(cm.cmd_set_default, [self.defaults, "zai", "zai"]) + call(cm.cmd_set_default, [self.defaults, "openrouter", ""]) + self.assertEqual(self.load(self.defaults), {"zai": "zai"}) + + def test_auth_of(self): + self.assertEqual(call(cm.cmd_auth_of, [os.path.join(self.presets, "zai.json")]), "vault\tzai\n") + self.assertEqual(call(cm.cmd_auth_of, [os.path.join(self.presets, "ollama.json")]), "literal\topenrouter\n") + with self.assertRaises(SystemExit) as e: + call(cm.cmd_auth_of, [os.path.join(self.presets, "nosuch.json")]) + self.assertEqual(e.exception.code, 1) + + def test_set_auth_uses_the_providers_names(self): + p = os.path.join(self.presets, "ollama.json") + call(cm.cmd_set_auth, [p, "key"]) + self.assertEqual(self.load(p)["auth"], {"mode": "vault", "keyRef": "ollama"}) + call(cm.cmd_set_auth, [p, "none"]) + self.assertEqual(self.load(p)["auth"], {"mode": "literal", "token": "ollama"}) + + +class Health(Presets): + def health(self, mode="anthropic", preset=""): + call(cm.cmd_health, [self.tmp, "/nonexistent", mode, preset, "", "file", "9.9.9"]) + return self.load(self.path("health.json")) + + def test_effective_defaults(self): + self.assertEqual(self.health()["defaultPresetFor"]["openrouter"], "default") + shutil.copy(os.path.join(self.presets, "default.json"), os.path.join(self.presets, "cheap.json")) + self.write_json("defaults.json", {"openrouter": "cheap"}) + h = self.health() + self.assertEqual((h["defaultPresetFor"]["openrouter"], h["defaultPresetChosen"]), ("cheap", {"openrouter": "cheap"})) + os.remove(os.path.join(self.presets, "cheap.json")) # a vanished choice falls back + self.assertEqual(self.health()["defaultPresetFor"]["openrouter"], "default") + os.remove(os.path.join(self.presets, "default.json")) # and with the built-in gone, the first by name + shutil.copy(os.path.join(self.presets, "zai.json"), os.path.join(self.presets, "alpha.json")) + alpha = self.load(os.path.join(self.presets, "alpha.json")) + alpha["provider"] = "openrouter" + self.write_json("presets/alpha.json", alpha) + self.assertEqual(self.health()["defaultPresetFor"]["openrouter"], "alpha") + + def test_publishes_providers(self): + h = self.health() + self.assertEqual(h["version"], "9.9.9") + byid = {p["id"]: p for p in h["providers"]} + self.assertEqual(list(byid), ["openrouter", "zai", "lmstudio", "ollama", "custom"]) + self.assertTrue(byid["ollama"]["serverEditable"] and byid["ollama"]["perServerCatalogue"]) + self.assertFalse(byid["zai"]["serverEditable"]) + self.assertEqual(byid["custom"]["defaultBaseUrl"], "") + self.assertTrue(byid["openrouter"]["logo"]) + + +class OllamaCtx(unittest.TestCase): + def test_show(self): + src = {"model_info": {"general.architecture": "q", "q.context_length": 262144}} + self.assertEqual(call(cm.cmd_ollama_ctx, ["show"], json.dumps(src)), "262144\n") + + def test_ps_matches_latest(self): + src = {"models": [{"name": "qwen3-coder:latest", "model": "qwen3-coder:latest", "context_length": 4096}]} + self.assertEqual(call(cm.cmd_ollama_ctx, ["ps", "qwen3-coder"], json.dumps(src)), "4096\n") + self.assertEqual(call(cm.cmd_ollama_ctx, ["ps", "other"], json.dumps(src)), "") + + +# --------------------------------------------------------------------------- +# apply - the write into settings.json +# --------------------------------------------------------------------------- + +class Apply(TempDir): + def preset(self, **over): + base = {"provider": "openrouter", "baseUrl": "https://gw", "auth": {"mode": "vault", "keyRef": "openrouter"}, + "models": {"opus": "x/opus", "sonnet": "x/sonnet"}, "contextTokens": 1000} + base.update(over) + return self.write_json("p.json", base) + + def apply(self, mode, preset="", helper="/opt/key helper/h.sh"): + return call(cm.cmd_apply, [self.path("settings.json"), self.path("state.json"), mode, preset, helper]) + + def settings(self): + with open(self.path("settings.json")) as fh: + return json.load(fh) + + def test_writes_env_and_quotes_the_helper(self): + self.apply("openrouter", self.preset()) + s = self.settings() + self.assertEqual(s["env"]["ANTHROPIC_BASE_URL"], "https://gw") + self.assertEqual(s["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"], "x/opus") + self.assertEqual(s["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], "1000") + self.assertEqual(s["env"]["ANTHROPIC_API_KEY"], "") + self.assertEqual(s["apiKeyHelper"], "'/opt/key helper/h.sh'") + + def test_cost_guard(self): + with self.assertRaises(SystemExit) as e: + self.apply("openrouter", self.preset(models={"opus": "anthropic/claude-opus-5"})) + self.assertIn("refusing to switch", str(e.exception)) + self.apply("openrouter", self.preset(models={"opus": "anthropic/claude-opus-5"}, allowAnthropicModels=True)) + + def test_extra_env_is_removed_on_the_way_out(self): + self.apply("openrouter", self.preset(extraEnv={"SOME_CUSTOM_KEY": "1"})) + self.assertIn("SOME_CUSTOM_KEY", self.settings()["env"]) + self.apply("anthropic") + s = self.settings() + self.assertNotIn("env", s) + self.assertNotIn("apiKeyHelper", s) + + def test_literal_token_and_provider_check(self): + self.apply("ollama", self.preset(provider="ollama", auth={"mode": "literal", "token": "ollama"})) + self.assertEqual(self.settings()["env"]["ANTHROPIC_AUTH_TOKEN"], "ollama") + with self.assertRaises(SystemExit): + self.apply("zai", self.preset()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/static.sh b/tests/static.sh new file mode 100755 index 0000000..b33ec4f --- /dev/null +++ b/tests/static.sh @@ -0,0 +1,145 @@ +#!/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 + +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 + +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. + qml_syntax() { + local f out + for f in omarchy/smoido.claude-mode/*.qml; do + out="$("$QMLLINT" "$f" 2>&1 | grep -iE 'error|expected token|syntax|unexpected|duplicate' || 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 ] diff --git a/tests/windows/run-remote.sh b/tests/windows/run-remote.sh new file mode 100755 index 0000000..ba34865 --- /dev/null +++ b/tests/windows/run-remote.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Runs tests/windows/run.ps1 on a Windows host over SSH (default: winbox): +# copies the payload to the host's %TEMP%, runs it there, and removes it again. +# Needs key-based SSH to the host and python on the host (for the fake servers). +# The suite itself runs in a USERPROFILE sandbox; see run.ps1. +set -uo pipefail +cd "$(dirname "$0")/../.." + +host="${1:-winbox}" +name='cm-test-payload' +ssh_() { ssh -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=10 "$host" "$@"; } + +parent="$(mktemp -d)" +trap 'rm -rf "$parent"' EXIT +stage="$parent/$name" +mkdir -p "$stage/presets" "$stage/bin" +cp claude-mode.ps1 providers.json VERSION tests/fake_server.py tests/windows/run.ps1 "$stage/" +cp presets/*.json "$stage/presets/" +cp bin/* "$stage/bin/" + +rtemp="$(ssh_ 'Write-Output $env:TEMP' | tr -d '\r')" || { echo "cannot reach $host" >&2; exit 1; } +[ -n "$rtemp" ] || { echo "no %TEMP% on $host" >&2; exit 1; } +ssh_ "Remove-Item -Recurse -Force '$rtemp\\$name' -ErrorAction SilentlyContinue" || true +scp -q -r -o BatchMode=yes -o LogLevel=ERROR "$stage" "$host:${rtemp//\\//}/" || { echo "copy to $host failed" >&2; exit 1; } + +ssh_ "powershell -NoProfile -ExecutionPolicy Bypass -File '$rtemp\\$name\\run.ps1'" +rc=$? +ssh_ "Remove-Item -Recurse -Force '$rtemp\\$name' -ErrorAction SilentlyContinue" || true +exit "$rc" diff --git a/tests/windows/run.ps1 b/tests/windows/run.ps1 new file mode 100644 index 0000000..d11f25f --- /dev/null +++ b/tests/windows/run.ps1 @@ -0,0 +1,127 @@ +# Windows suite for claude-mode.ps1. Run on a Windows host by +# tests/windows/run-remote.sh, from a copy of the payload in that host's %TEMP%. +# +# Everything happens in a sandbox: USERPROFILE is pointed at a temp folder for +# this process and its children, and the script derives every path from it, so +# the host's real ~\.claude-mode, vault and Claude settings are never read or +# written. The fake provider servers run on the host's own loopback. +# ASCII only: Windows PowerShell 5.1 reads a .ps1 without a BOM as ANSI. + +$ErrorActionPreference = 'Continue' +$src = $PSScriptRoot +$sb = Join-Path $env:TEMP ('cm-test-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$root = Join-Path $sb '.claude-mode' +$real = $env:USERPROFILE +$script:pass = 0 +$script:fail = 0 + +function Pass { $script:pass++ } +function Fail([string] $what, $out) { + $script:fail++ + Write-Host " FAIL windows: $what" + if ($out) { @($out) | Select-Object -Last 8 | ForEach-Object { Write-Host " | $_" } } +} +function Expect-Match($out, [string] $pattern, [string] $what) { + if ((@($out) -join "`n") -match $pattern) { Pass } else { Fail $what $out } +} +function Expect-Eq($got, $want, [string] $what) { + if ("$got" -eq "$want") { Pass } else { Fail "$what (got '$got', wanted '$want')" $null } +} + +New-Item -ItemType Directory -Force -Path "$root\presets", "$root\bin", "$sb\.claude" | Out-Null +Copy-Item "$src\claude-mode.ps1", "$src\providers.json", "$src\VERSION" $root +Copy-Item "$src\presets\*.json" "$root\presets" +Copy-Item "$src\bin\*" "$root\bin" +$cm = Join-Path $root 'claude-mode.ps1' + +$tokens = $null; $errors = $null +[void][System.Management.Automation.Language.Parser]::ParseFile($cm, [ref]$tokens, [ref]$errors) +Expect-Eq $errors.Count 0 'claude-mode.ps1 parses' +foreach ($e in $errors) { Write-Host " | line $($e.Extent.StartLineNumber): $($e.Message)" } + +$portsFile = Join-Path $sb 'ports.json' +$fake = Start-Process python -ArgumentList ('"' + (Join-Path $src 'fake_server.py') + '"') ` + -NoNewWindow -RedirectStandardOutput $portsFile -PassThru +for ($i = 0; $i -lt 50; $i++) { + if ((Test-Path $portsFile) -and (Get-Item $portsFile).Length -gt 0) { break } + Start-Sleep -Milliseconds 100 +} +$ports = Get-Content $portsFile -TotalCount 1 | ConvertFrom-Json +$ollama = "http://127.0.0.1:$($ports.ollama)" +$keyed = "http://127.0.0.1:$($ports.keyed)" + +$env:USERPROFILE = $sb +$script:rc = 0 +function CM { + param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $a) + $o = & powershell -NoProfile -ExecutionPolicy Bypass -File $cm @a 2>&1 | ForEach-Object { "$_" } + $script:rc = $LASTEXITCODE + return $o +} +function Set-PresetJson([string] $name, [scriptblock] $edit) { + $p = Join-Path $root "presets\$name.json" + $j = Get-Content $p -Raw | ConvertFrom-Json + & $edit $j + $j | ConvertTo-Json -Depth 6 | Set-Content $p -Encoding UTF8 +} + +$o = CM help +foreach ($id in 'openrouter', 'zai', 'lmstudio', 'ollama', 'custom') { + Expect-Match $o "claude-mode $id \[preset\]" "usage lists $id" +} + +$o = CM custom +Expect-Eq $rc 1 'custom without an address is refused' +Expect-Match $o 'has no server address' 'and says why' +Copy-Item "$root\presets\custom.json" "$root\presets\blank.json" +Set-PresetJson 'blank' { param($j) $j.baseUrl = 'https://llm.example.com' } +$o = CM custom blank +Expect-Eq $rc 1 'a preset with no models is refused' +Expect-Match $o 'has no models set' 'and says why' + +Set-PresetJson 'ollama' { param($j) $j.baseUrl = $ollama } +$o = CM ollama +Expect-Eq $rc 0 'switch to ollama' +$envBlock = (Get-Content "$sb\.claude\settings.json" -Raw | ConvertFrom-Json).env +Expect-Eq $envBlock.ANTHROPIC_BASE_URL $ollama 'base url written' +Expect-Eq $envBlock.ANTHROPIC_AUTH_TOKEN 'ollama' 'placeholder token written' +Expect-Eq $envBlock.CLAUDE_CODE_MAX_CONTEXT_TOKENS '65536' 'context declared' + +$o = CM models +Expect-Match $o 'qwen3-coder:latest' 'ollama models listed' +$o = CM doctor +Expect-Match $o 'Ollama reachable' 'doctor finds the server' +Expect-Match $o 'opus\s+qwen3-coder\s+\[30\.5B Q4_K_M\]' 'a bare name matches its :latest tag' +Expect-Match $o 'loaded with a 4096-token context' 'the short server context is caught' +Expect-Match $o 'OLLAMA_CONTEXT_LENGTH=65536' 'and the fix is named' + +Set-PresetJson 'custom' { + param($j) + $j.baseUrl = $keyed + $j.auth = [pscustomobject]@{ mode = 'literal'; token = 'sk-real' } + $j.models.opus = 'gw/model-a' +} +$o = CM custom +Expect-Eq $rc 0 'switch to a custom endpoint' +$o = CM models +Expect-Match $o 'gw/model-b' 'a keyed endpoint lists its models' + +$o = CM 'Z.AI' +Expect-Match $o "no key stored for ref 'zai'" 'an alias resolves to its provider' +$o = CM nope +Expect-Eq $rc 1 'an unknown command fails' + +$want = '{"provider":"zai","description":"new preset","baseUrl":"https://api.z.ai/api/anthropic","auth":{"mode":"vault","keyRef":"zai"},"models":{"opus":"","sonnet":"","haiku":"","fable":""},"subagentModel":"inherit","gatewayModelDiscovery":false,"contextTokens":1000000,"extraEnv":{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1"}}' +$got = & powershell -NoProfile -ExecutionPolicy Bypass -Command ". '$cm' help *> `$null; New-PresetScaffold 'zai' | ConvertTo-Json -Depth 5 -Compress" +Expect-Eq $got $want 'the zai scaffold is unchanged' + +Stop-Process -Id $fake.Id -Force -ErrorAction SilentlyContinue +$env:USERPROFILE = $real +Start-Sleep -Milliseconds 300 +Remove-Item -Recurse -Force $sb -ErrorAction SilentlyContinue + +$line = '{0,-22} {1,3} passed' -f 'windows', $script:pass +if ($script:fail -gt 0) { $line += ", $($script:fail) FAILED" } +Write-Host $line +if ($script:fail -gt 0) { exit 1 } +exit 0