Skip to content

Instantly share code, notes, and snippets.

@maciekish
Last active June 16, 2026 17:53
Show Gist options
  • Select an option

  • Save maciekish/381fae72eb1875e413295e6685d61213 to your computer and use it in GitHub Desktop.

Select an option

Save maciekish/381fae72eb1875e413295e6685d61213 to your computer and use it in GitHub Desktop.
Run Claude Code CLI against a local LM Studio instance. Tested on macOS only.
#!/bin/zsh
# localclaude.sh - Claude Code launcher for real Claude vs local LM Studio
# Claude Code -> local Anthropic-compatible proxy -> LM Studio /v1/messages
# --- persisted choices; this script edits these lines ---
LAST_PROVIDER="local"
LAST_MODEL="claude-local-qwen-qwen3-6-27b"
LAST_CONTEXT="65536"
LM_BASE_URL="${LM_BASE_URL:-http://127.0.0.1:1234}"
LM_AUTH_TOKEN="${LM_AUTH_TOKEN:-lmstudio}"
PROXY_HOST="${PROXY_HOST:-127.0.0.1}"
PROXY_PORT="${PROXY_PORT:-1235}"
PROXY_BASE_URL="http://${PROXY_HOST}:${PROXY_PORT}"
EARLY_PROXY_MODE="normal"
USING_EXISTING_PROXY=0
SCRIPT_PATH="${(%):-%x}"
[[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$PWD/$SCRIPT_PATH"
LOCALCLAUDE_CONTEXT_STATE="${LOCALCLAUDE_CONTEXT_STATE:-$HOME/.localclaude_contexts.tsv}"
MODEL_ALIASES=()
MODEL_REAL_IDS=()
MODEL_NAMES=()
MODEL_CONTEXTS=()
CONTEXT_OPTIONS=()
RESET=$'\033[0m'
REV=$'\033[7m'
CYAN=$'\033[36m'
YELLOW=$'\033[33m'
GREEN=$'\033[32m'
DIM=$'\033[2m'
cleanup() {
stty sane 2>/dev/null
printf "\033[?25h\033[0m"
}
fail() {
cleanup
echo "Error: $*" >&2
return 1
}
abort_script() {
local code="${1:-1}"
cleanup
return "$code" 2>/dev/null || exit "$code"
}
handle_interrupt() {
cleanup
echo
echo "Cancelled."
return 130 2>/dev/null || exit 130
}
trap handle_interrupt INT
trap cleanup EXIT TERM
have() {
command -v "$1" >/dev/null 2>&1
}
term_cols() {
local c
c="$(tput cols 2>/dev/null)"
[[ -z "$c" || "$c" -lt 100 ]] && c=100
echo "$c"
}
repeat_char() {
local ch="$1"
local n="$2"
local out=""
while (( ${#out} < n )); do out+="$ch"; done
printf "%s" "${out[1,$n]}"
}
truncate_plain() {
local s="$1"
local max="$2"
if (( max <= 0 )); then
printf ""
elif (( ${#s} <= max )); then
printf "%s" "$s"
elif (( max <= 1 )); then
printf "…"
else
printf "%s…" "${s[1,$((max - 1))]}"
fi
}
pad_plain() {
local s="$1"
local w="$2"
local len="${#s}"
if (( len >= w )); then
printf "%s" "$(truncate_plain "$s" "$w")"
else
printf "%s%*s" "$s" $((w - len)) ""
fi
}
fetch_models_json() {
curl -fsS --max-time 3 \
-H "Authorization: Bearer ${LM_AUTH_TOKEN}" \
"${LM_BASE_URL}/api/v1/models" 2>/dev/null && return 0
return 1
}
parse_models() {
python3 -c '
import json
import re
import sys
raw = sys.stdin.read()
try:
data = json.loads(raw)
except Exception as e:
print(f"JSON parse error: {e}", file=sys.stderr)
sys.exit(1)
def alias_for(real_id):
s = real_id.lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
s = re.sub(r"-+", "-", s).strip("-")
return "claude-local-" + s
out = []
if isinstance(data, dict) and isinstance(data.get("models"), list):
for m in data["models"]:
if not isinstance(m, dict):
continue
if m.get("type") != "llm":
continue
real = m.get("key") or m.get("id") or m.get("selected_variant")
if not real:
continue
display = m.get("display_name") or real
quant = m.get("quantization")
q = quant.get("name") if isinstance(quant, dict) else None
params = m.get("params_string")
ctx = m.get("max_context_length") or 65536
variant = m.get("selected_variant")
details = []
if params:
details.append(str(params))
if q:
details.append(str(q))
if ctx:
details.append("ctx " + str(ctx))
if variant and variant != real:
details.append("variant " + str(variant).split("@")[-1])
name = display + (" (" + ", ".join(details) + ")" if details else "")
out.append((alias_for(real), real, name, str(ctx)))
seen = set()
for alias, real, name, ctx in out:
if alias in seen:
continue
seen.add(alias)
print(alias + "\t" + real + "\t" + name + "\t" + ctx)
'
}
load_models() {
local json lines alias real name ctx
have curl || {
fail "curl is required."
return 1
}
have python3 || {
fail "python3 is required."
return 1
}
json="$(fetch_models_json)" || {
fail "Could not reach LM Studio at ${LM_BASE_URL}/api/v1/models using Authorization: Bearer token."
return 1
}
lines="$(printf "%s" "$json" | parse_models)" || {
fail "Could not parse LM Studio model list."
return 1
}
if [[ -z "$lines" ]]; then
fail "LM Studio returned no usable LLM models."
return 1
fi
MODEL_ALIASES=()
MODEL_REAL_IDS=()
MODEL_NAMES=()
MODEL_CONTEXTS=()
while IFS=$'\t' read -r alias real name ctx; do
[[ -z "$alias" || -z "$real" ]] && continue
MODEL_ALIASES+=("$alias")
MODEL_REAL_IDS+=("$real")
MODEL_NAMES+=("$name")
MODEL_CONTEXTS+=("${ctx:-65536}")
done <<< "$lines"
if (( ${#MODEL_ALIASES[@]} == 0 )); then
fail "Model parser produced zero usable models."
return 1
fi
return 0
}
model_map_json() {
python3 - "$@" <<'PY'
import json
import sys
pairs = sys.argv[1:]
m = {}
for p in pairs:
alias, real = p.split("\t", 1)
m[alias] = real
print(json.dumps(m, separators=(",", ":")))
PY
}
make_context_options() {
# Kept only for compatibility with older draw/event-loop patches.
# Context is now a single editable value, not a preset list.
CONTEXT_OPTIONS=()
}
get_saved_context_for_model() {
local alias="$1"
local max_ctx="$2"
local saved=""
if [[ -f "$LOCALCLAUDE_CONTEXT_STATE" ]]; then
saved="$(awk -F '\t' -v a="$alias" '$1 == a { v = $2 } END { print v }' "$LOCALCLAUDE_CONTEXT_STATE" 2>/dev/null)"
fi
if [[ "$saved" == <-> ]]; then
(( saved > max_ctx )) && saved="$max_ctx"
(( saved < 4096 )) && saved=4096
echo "$saved"
else
echo "$max_ctx"
fi
}
remember_context_for_model() {
local alias="$1"
local ctx="$2"
local tmp
[[ -n "$alias" ]] || return 0
[[ "$ctx" == <-> ]] || return 0
tmp="${LOCALCLAUDE_CONTEXT_STATE}.tmp.$$"
mkdir -p "$(dirname "$LOCALCLAUDE_CONTEXT_STATE")" 2>/dev/null || true
if [[ -f "$LOCALCLAUDE_CONTEXT_STATE" ]]; then
awk -F '\t' -v OFS='\t' -v a="$alias" -v c="$ctx" '
BEGIN { done = 0 }
$1 == a {
print a, c
done = 1
next
}
{ print }
END {
if (!done) print a, c
}
' "$LOCALCLAUDE_CONTEXT_STATE" > "$tmp"
else
printf "%s\t%s\n" "$alias" "$ctx" > "$tmp"
fi
mv "$tmp" "$LOCALCLAUDE_CONTEXT_STATE"
}
set_context_to_model_default() {
local idx="$1"
local alias="${MODEL_ALIASES[$((idx + 1))]}"
local max_ctx="${MODEL_CONTEXTS[$((idx + 1))]:-65536}"
context_value="$(get_saved_context_for_model "$alias" "$max_ctx")"
}
set_context_to_model_max() {
# Historical function name used by the event loop.
# New behavior: saved per-model value if one exists, otherwise max.
set_context_to_model_default "$1"
}
clamp_context_to_model() {
local idx="$1"
local alias="${MODEL_ALIASES[$((idx + 1))]}"
local max_ctx="${MODEL_CONTEXTS[$((idx + 1))]:-65536}"
# On startup/model change, prefer per-model saved context.
# If no saved value exists, use model max.
context_value="$(get_saved_context_for_model "$alias" "$max_ctx")"
}
save_choice() {
local provider="$1"
local model="$2"
local ctx="$3"
[[ -n "$SCRIPT_PATH" ]] || return 0
[[ -f "$SCRIPT_PATH" ]] || return 0
[[ -w "$SCRIPT_PATH" ]] || return 0
local tmp="${SCRIPT_PATH}.tmp.$$"
awk -v p="$provider" -v m="$model" -v c="$ctx" '
BEGIN { donep = 0; donem = 0; donec = 0 }
/^LAST_PROVIDER=/ && donep == 0 {
gsub(/\\/,"\\\\",p)
gsub(/"/,"\\\"",p)
print "LAST_PROVIDER=\"" p "\""
donep = 1
next
}
/^LAST_MODEL=/ && donem == 0 {
gsub(/\\/,"\\\\",m)
gsub(/"/,"\\\"",m)
print "LAST_MODEL=\"" m "\""
donem = 1
next
}
/^LAST_CONTEXT=/ && donec == 0 {
gsub(/\\/,"\\\\",c)
gsub(/"/,"\\\"",c)
print "LAST_CONTEXT=\"" c "\""
donec = 1
next
}
{ print }
' "$SCRIPT_PATH" > "$tmp" && mv "$tmp" "$SCRIPT_PATH" && chmod 700 "$SCRIPT_PATH"
}
load_lmstudio_model_context() {
local real_model="$1"
local ctx="$2"
local min_ctx=32768
local resp applied tmp
echo "Loading LM Studio model with context:"
echo " Model: $real_model"
echo " Requested context length: $ctx"
echo
tmp="/tmp/localclaude_load_$$.json"
# Important: unload everything first. If LM Studio already has this model loaded
# at a smaller context, requests may keep using that old instance.
if command -v lms >/dev/null 2>&1; then
lms unload --all >/dev/null 2>&1 || true
fi
curl -fsS --max-time 120 \
-X POST \
-H "Authorization: Bearer ${LM_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
"${LM_BASE_URL}/api/v1/models/load" \
-d "{\"model\":\"${real_model}\",\"context_length\":${ctx},\"flash_attention\":true,\"echo_load_config\":true}" \
> "$tmp" 2>/dev/null
if [[ $? -ne 0 ]]; then
echo "REST load failed; trying lms CLI." >&2
if command -v lms >/dev/null 2>&1; then
lms unload --all >/dev/null 2>&1 || true
lms load "$real_model" --context-length "$ctx" --ttl 3600 >/dev/null 2>&1 || {
echo "Error: LM Studio did not load $real_model with context $ctx." >&2
echo "Load it manually in LM Studio with context >= $min_ctx, then retry." >&2
return 1
}
else
echo "Error: REST load failed and lms CLI is not available." >&2
return 1
fi
fi
# Verify the REST echo if available.
if [[ -s "$tmp" ]]; then
applied="$(python3 - "$tmp" <<'PY2'
import json, sys
p = sys.argv[1]
try:
d = json.load(open(p))
except Exception:
print("")
raise SystemExit
cfg = d.get("load_config") or {}
def find_ctx(x):
if isinstance(x, dict):
for k, v in x.items():
lk = str(k).lower()
if lk in ("context_length", "contextlength", "n_ctx", "context") and isinstance(v, int):
return v
r = find_ctx(v)
if r:
return r
elif isinstance(x, list):
for v in x:
r = find_ctx(v)
if r:
return r
return None
print(find_ctx(cfg) or "")
PY2
)"
fi
if [[ -n "$applied" ]]; then
echo "LM Studio applied context length: $applied"
if (( applied < min_ctx )); then
echo "Error: LM Studio applied only $applied context, which is too small for Claude Code." >&2
echo "Requested: $ctx. Minimum practical: $min_ctx." >&2
echo "Try a smaller quant, reduce GPU offload, or manually reload in LM Studio with >= $min_ctx." >&2
return 1
fi
if (( applied < ctx )); then
echo "Warning: LM Studio applied $applied instead of requested $ctx." >&2
echo "Continuing because it is still >= $min_ctx." >&2
fi
else
echo "Could not verify applied context from load response; continuing after explicit load."
fi
return 0
}
write_proxy() {
local proxy_file="$1"
cat > "$proxy_file" <<'PY'
#!/usr/bin/env python3
import json
import os
import sys
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
LM_BASE_URL = os.environ.get("LM_BASE_URL", "http://127.0.0.1:1234").rstrip("/")
LM_AUTH_TOKEN = os.environ.get("LM_AUTH_TOKEN", "")
PROXY_HOST = os.environ.get("PROXY_HOST", "127.0.0.1")
PROXY_PORT = int(os.environ.get("PROXY_PORT", "1235"))
MODEL_MAP = json.loads(os.environ.get("LOCALCLAUDE_MODEL_MAP", "{}"))
DEBUG = os.environ.get("LOCALCLAUDE_PROXY_DEBUG", "1") == "1"
SYSTEM_PROMPT_LOG = os.environ.get(
"LOCALCLAUDE_SYSTEM_PROMPT_LOG",
os.path.expanduser("~/.localclaude_system_prompt.txt"),
)
def log(msg):
if DEBUG:
print("[localclaude-proxy]", msg, file=sys.stderr, flush=True)
def overwrite_system_prompt_log(payload):
if not isinstance(payload, dict):
return
system = payload.get("system")
if not system:
return
if isinstance(system, str):
text = system
else:
try:
text = json.dumps(system, ensure_ascii=False, indent=2)
except Exception:
text = str(system)
tmp = SYSTEM_PROMPT_LOG + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
if not text.endswith("\n"):
f.write("\n")
os.replace(tmp, SYSTEM_PROMPT_LOG)
log("wrote full system prompt to " + SYSTEM_PROMPT_LOG)
def content_to_text(content):
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
t = item.get("type")
if t == "text":
parts.append(str(item.get("text", "")))
else:
parts.append("[" + str(t or "block") + "]\n" + json.dumps(item, ensure_ascii=False))
else:
parts.append(str(item))
return "\n\n".join([p for p in parts if p])
if isinstance(content, dict):
return json.dumps(content, ensure_ascii=False)
return str(content)
def clean_content(content):
# Preserve Anthropic content blocks. Claude Code depends on tool_use/tool_result
# round-tripping as structured blocks. Flattening these to text makes the
# agent stop behaving like an agent.
if isinstance(content, str):
return content
if isinstance(content, list):
cleaned = []
for block in content:
if not isinstance(block, dict):
cleaned.append({"type": "text", "text": str(block)})
continue
btype = block.get("type")
if btype in ("text", "tool_use", "tool_result", "image", "document", "thinking", "redacted_thinking"):
cleaned.append(block)
else:
cleaned.append({
"type": "text",
"text": "[" + str(btype or "unknown_block") + "]\n" + json.dumps(block, ensure_ascii=False),
})
return cleaned
return content_to_text(content)
def normalise_payload(payload):
if not isinstance(payload, dict):
return payload
model = payload.get("model")
if model in MODEL_MAP:
payload["model"] = MODEL_MAP[model]
original = payload.get("messages", [])
if not isinstance(original, list):
return payload
system_parts = []
if payload.get("system"):
system_parts.append(content_to_text(payload.get("system")))
fixed = []
for msg in original:
if not isinstance(msg, dict):
fixed.append({"role": "user", "content": content_to_text(msg)})
continue
role = msg.get("role")
content = msg.get("content", "")
if role == "system":
system_parts.append(content_to_text(content))
continue
if role == "assistant":
fixed.append({"role": "assistant", "content": clean_content(content)})
continue
if role == "user":
fixed.append({"role": "user", "content": clean_content(content)})
continue
fixed.append({
"role": "user",
"content": "[Converted from role " + str(role) + "]\n" + content_to_text(content),
})
if system_parts:
payload["system"] = "\n\n".join(system_parts)
payload["messages"] = fixed
payload.pop("metadata", None)
return payload
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def send_json(self, code, obj):
body = json.dumps(obj).encode("utf-8")
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_HEAD(self):
parsed = urlparse(self.path)
path = parsed.path
if path in ("/", "/health", "/healthz", "/v1/messages", "/v1/models"):
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
return
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
if path in ("/health", "/healthz"):
self.send_json(200, {"ok": True})
return
if path == "/v1/models":
data = {
"object": "list",
"data": [
{
"id": alias,
"type": "model",
"object": "model",
"owned_by": "localclaude",
"display_name": alias,
}
for alias in MODEL_MAP.keys()
],
}
self.send_json(200, data)
return
self.send_json(404, {"error": "not found", "path": self.path})
def do_POST(self):
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length) if length else b""
parsed = urlparse(self.path)
path = parsed.path
if path == "/messages":
path = "/v1/messages"
if path != "/v1/messages":
self.send_json(404, {"error": "not found", "path": self.path})
return
try:
payload = json.loads(raw.decode("utf-8"))
except Exception as e:
self.send_json(400, {"error": "invalid json", "detail": str(e)})
return
overwrite_system_prompt_log(payload)
before_model = payload.get("model")
payload = normalise_payload(payload)
after_model = payload.get("model")
if DEBUG:
roles = [m.get("role") for m in payload.get("messages", []) if isinstance(m, dict)]
log("POST /v1/messages model " + repr(before_model) + " -> " + repr(after_model) + " roles=" + repr(roles))
body = json.dumps(payload).encode("utf-8")
self.proxy_to_lmstudio("POST", LM_BASE_URL + "/v1/messages", body)
def proxy_to_lmstudio(self, method, url, body):
headers = {
"Content-Type": "application/json",
"Accept": self.headers.get("Accept", "application/json"),
}
if LM_AUTH_TOKEN:
headers["Authorization"] = "Bearer " + LM_AUTH_TOKEN
req = urllib.request.Request(url, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=None) as r:
data = r.read()
self.send_response(r.status)
for k, v in r.headers.items():
lk = k.lower()
if lk in ("connection", "transfer-encoding", "content-encoding", "content-length"):
continue
self.send_header(k, v)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
except urllib.error.HTTPError as e:
data = e.read()
self.send_response(e.code)
for k, v in e.headers.items():
lk = k.lower()
if lk in ("connection", "transfer-encoding", "content-encoding", "content-length"):
continue
self.send_header(k, v)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
except Exception as e:
self.send_json(502, {"error": "proxy error", "detail": str(e)})
def log_message(self, fmt, *args):
if DEBUG:
super().log_message(fmt, *args)
def main():
log("model map: " + json.dumps(MODEL_MAP))
server = ThreadingHTTPServer((PROXY_HOST, PROXY_PORT), Handler)
print(
f"localclaude proxy listening on http://{PROXY_HOST}:{PROXY_PORT}, forwarding to {LM_BASE_URL}",
file=sys.stderr,
flush=True,
)
server.serve_forever()
if __name__ == "__main__":
main()
PY
chmod 700 "$proxy_file"
}
proxy_health_ok() {
curl -fsS --max-time 1 "${PROXY_BASE_URL}/health" >/dev/null 2>&1
}
port_has_listener() {
if command -v lsof >/dev/null 2>&1; then
lsof -tiTCP:"$PROXY_PORT" -sTCP:LISTEN >/dev/null 2>&1 && return 0
fi
if command -v nc >/dev/null 2>&1; then
nc -z "$PROXY_HOST" "$PROXY_PORT" >/dev/null 2>&1 && return 0
fi
return 1
}
find_existing_proxy_pids() {
local pids=""
if command -v lsof >/dev/null 2>&1; then
pids="$(lsof -tiTCP:"$PROXY_PORT" -sTCP:LISTEN 2>/dev/null || true)"
fi
if [[ -z "$pids" ]]; then
pids="$(pgrep -f 'localclaude_proxy_.*\.py' 2>/dev/null || true)"
fi
echo "$pids"
}
kill_old_proxies() {
local pids pid cmd
pids="$(find_existing_proxy_pids)"
for pid in ${(f)pids}; do
[[ -z "$pid" ]] && continue
[[ "$pid" == "$$" ]] && continue
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
# Be aggressive only with our generated proxy. Do not kill LM Studio or random services.
if printf "%s" "$cmd" | grep -q 'localclaude_proxy_.*\.py'; then
kill "$pid" 2>/dev/null || true
fi
done
sleep 0.2
pids="$(find_existing_proxy_pids)"
for pid in ${(f)pids}; do
[[ -z "$pid" ]] && continue
[[ "$pid" == "$$" ]] && continue
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
if printf "%s" "$cmd" | grep -q 'localclaude_proxy_.*\.py'; then
kill -9 "$pid" 2>/dev/null || true
fi
done
}
ask_existing_proxy_action() {
local pids pid cmd answer
# First and most important test: does the proxy itself answer?
if ! proxy_health_ok && ! port_has_listener; then
echo "none"
return 0
fi
echo
echo "Existing service detected on ${PROXY_BASE_URL}."
if proxy_health_ok; then
echo "It answers /health, so it looks like an existing localclaude proxy."
else
echo "Port ${PROXY_PORT} is occupied, but /health did not answer."
fi
pids="$(find_existing_proxy_pids)"
if [[ -n "$pids" ]]; then
echo
echo "Detected listener/processes:"
for pid in ${(f)pids}; do
[[ -z "$pid" ]] && continue
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
echo " PID $pid: $cmd"
done
fi
echo
echo "Choose:"
echo " k = kill/restart proxy and reload selected LM Studio model/context"
echo " u = use existing proxy as-is, do NOT reload model/context"
echo " a = abort"
echo
while true; do
printf "Action [k/u/a]: "
IFS= read -r answer
case "$answer" in
k|K)
echo "kill"
return 0
;;
u|U|"")
echo "use"
return 0
;;
a|A|q|Q)
echo "abort"
return 0
;;
*)
echo "Please type k, u, or a."
;;
esac
done
}
start_proxy() {
local proxy_file="/tmp/localclaude_proxy_$$.py"
local proxy_log="/tmp/localclaude_proxy_$$.log"
local pairs=()
local map_json
local action
USING_EXISTING_PROXY=0
action="$(ask_existing_proxy_action)"
case "$action" in
use)
if proxy_health_ok; then
USING_EXISTING_PROXY=1
PROXY_PID=""
LOCALCLAUDE_PROXY_LOG="<existing proxy>"
export LOCALCLAUDE_PROXY_LOG
echo
echo "Using existing proxy. LM Studio model/context will be left alone."
echo
return 0
else
echo "Cannot use existing process because ${PROXY_BASE_URL}/health does not answer." >&2
echo "Choose kill/restart instead." >&2
return 1
fi
;;
kill)
kill_old_proxies
sleep 0.2
;;
abort)
return 1
;;
none)
;;
esac
for i in {1..${#MODEL_ALIASES[@]}}; do
pairs+=("${MODEL_ALIASES[$i]}"$'\t'"${MODEL_REAL_IDS[$i]}")
done
map_json="$(model_map_json "${pairs[@]}")"
write_proxy "$proxy_file"
LM_BASE_URL="$LM_BASE_URL" \
LM_AUTH_TOKEN="$LM_AUTH_TOKEN" \
PROXY_HOST="$PROXY_HOST" \
PROXY_PORT="$PROXY_PORT" \
LOCALCLAUDE_MODEL_MAP="$map_json" \
LOCALCLAUDE_PROXY_DEBUG="${LOCALCLAUDE_PROXY_DEBUG:-1}" \
LOCALCLAUDE_SYSTEM_PROMPT_LOG="${LOCALCLAUDE_SYSTEM_PROMPT_LOG:-$HOME/.localclaude_system_prompt.txt}" \
python3 "$proxy_file" > "$proxy_log" 2>&1 &
PROXY_PID=$!
sleep 0.4
if ! kill -0 "$PROXY_PID" 2>/dev/null; then
# If bind failed because an old proxy exists, offer to reuse it instead of just failing.
if proxy_health_ok; then
echo "New proxy failed to start, but an existing proxy is healthy. Reusing it."
PROXY_PID=""
USING_EXISTING_PROXY=1
LOCALCLAUDE_PROXY_LOG="<existing proxy>"
export LOCALCLAUDE_PROXY_LOG
return 0
fi
echo "Proxy failed to start. Log:"
cat "$proxy_log" 2>/dev/null
return 1
fi
if ! proxy_health_ok; then
echo "Proxy did not answer health check. Log:"
cat "$proxy_log" 2>/dev/null
kill "$PROXY_PID" 2>/dev/null
sleep 0.1
kill -9 "$PROXY_PID" 2>/dev/null || true
return 1
fi
export LOCALCLAUDE_PROXY_LOG="$proxy_log"
return 0
}
early_existing_proxy_check() {
local answer pids pid cmd
# This runs BEFORE model discovery/UI. It must not touch LM Studio models.
if ! curl -fsS --max-time 1 "${PROXY_BASE_URL}/health" >/dev/null 2>&1; then
return 0
fi
echo
echo "Existing localclaude proxy is already running at ${PROXY_BASE_URL}."
echo
if command -v lsof >/dev/null 2>&1; then
pids="$(lsof -tiTCP:"$PROXY_PORT" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "$pids" ]]; then
echo "Detected listener/processes:"
for pid in ${(f)pids}; do
[[ -z "$pid" ]] && continue
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
echo " PID $pid: $cmd"
done
echo
fi
fi
echo "Choose:"
echo " u = use existing proxy now, skip model selection, do NOT reload LM Studio"
echo " k = kill existing proxy and continue to model selection"
echo " a = abort"
echo
while true; do
printf "Action [u/k/a]: "
IFS= read -r answer
case "$answer" in
u|U|"")
EARLY_PROXY_MODE="use"
USING_EXISTING_PROXY=1
LOCALCLAUDE_PROXY_LOG="<existing proxy>"
export LOCALCLAUDE_PROXY_LOG
return 0
;;
k|K)
EARLY_PROXY_MODE="kill"
kill_old_proxies 2>/dev/null || true
# fallback kill by port if kill_old_proxies misses it
if command -v lsof >/dev/null 2>&1; then
pids="$(lsof -tiTCP:"$PROXY_PORT" -sTCP:LISTEN 2>/dev/null || true)"
for pid in ${(f)pids}; do
[[ -z "$pid" ]] && continue
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
if printf "%s" "$cmd" | grep -q 'localclaude_proxy_.*\.py'; then
kill "$pid" 2>/dev/null || true
sleep 0.1
kill -9 "$pid" 2>/dev/null || true
fi
done
fi
return 0
;;
a|A|q|Q)
cleanup
echo "Cancelled."
exit 0
;;
*)
echo "Please type u, k, or a."
;;
esac
done
}
launch_existing_proxy_now() {
cleanup
local model_alias="$LAST_MODEL"
echo
echo "Launching Claude Code using existing localclaude proxy:"
echo " Claude Code base: $PROXY_BASE_URL"
echo " LM Studio model/context: left exactly as currently loaded"
if [[ -n "$model_alias" ]]; then
echo " Claude Code model alias: $model_alias"
else
echo " Claude Code model alias: <not set; Claude Code default will be used>"
fi
echo
if [[ -n "$model_alias" ]]; then
env \
-u ANTHROPIC_API_KEY \
-u ANTHROPIC_SMALL_FAST_MODEL \
-u ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION \
-u CLAUDE_CODE_USE_BEDROCK \
-u CLAUDE_CODE_USE_VERTEX \
-u ANTHROPIC_VERTEX_PROJECT_ID \
-u ANTHROPIC_VERTEX_REGION \
-u API_TIMEOUT_MS \
-u AWS_ACCESS_KEY_ID \
-u AWS_SECRET_ACCESS_KEY \
-u AWS_SESSION_TOKEN \
-u GOOGLE_APPLICATION_CREDENTIALS \
ANTHROPIC_BASE_URL="$PROXY_BASE_URL" \
ANTHROPIC_AUTH_TOKEN="localclaude-proxy-token" \
ANTHROPIC_MODEL="$model_alias" \
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 \
CLAUDE_CODE_ATTRIBUTION_HEADER=0 \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
DISABLE_TELEMETRY=1 \
DISABLE_LOGIN_COMMAND=1 \
claude --model "$model_alias"
else
env \
-u ANTHROPIC_API_KEY \
-u ANTHROPIC_SMALL_FAST_MODEL \
-u ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION \
-u CLAUDE_CODE_USE_BEDROCK \
-u CLAUDE_CODE_USE_VERTEX \
-u ANTHROPIC_VERTEX_PROJECT_ID \
-u ANTHROPIC_VERTEX_REGION \
-u API_TIMEOUT_MS \
-u AWS_ACCESS_KEY_ID \
-u AWS_SECRET_ACCESS_KEY \
-u AWS_SESSION_TOKEN \
-u GOOGLE_APPLICATION_CREDENTIALS \
ANTHROPIC_BASE_URL="$PROXY_BASE_URL" \
ANTHROPIC_AUTH_TOKEN="localclaude-proxy-token" \
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 \
CLAUDE_CODE_ATTRIBUTION_HEADER=0 \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
DISABLE_TELEMETRY=1 \
DISABLE_LOGIN_COMMAND=1 \
claude
fi
exit $?
}
providers=("real claude" "local lm studio")
provider_idx=1
if [[ "$LAST_PROVIDER" == "real" ]]; then
provider_idx=0
elif [[ "$LAST_PROVIDER" == "local" ]]; then
provider_idx=1
fi
early_existing_proxy_check
if [[ "$EARLY_PROXY_MODE" == "use" ]]; then
launch_existing_proxy_now
fi
load_models || abort_script 1
model_idx=0
if [[ -n "$LAST_MODEL" ]]; then
for i in {1..${#MODEL_ALIASES[@]}}; do
if [[ "${MODEL_ALIASES[$i]}" == "$LAST_MODEL" ]]; then
model_idx=$((i - 1))
break
fi
done
fi
context_value=""
clamp_context_to_model "$model_idx"
active_col=0
draw() {
local cols provider_w context_w gap model_w line max gap_s
cols="$(term_cols)"
gap=3
gap_s="$(repeat_char " " "$gap")"
provider_w=$(( cols / 4 ))
(( provider_w < 22 )) && provider_w=22
(( provider_w > 34 )) && provider_w=34
context_w=18
model_w=$(( cols - provider_w - context_w - (gap * 2) - 2 ))
(( model_w < 34 )) && model_w=34
line="$(repeat_char "─" "$cols")"
printf "\033[2J\033[H\033[?25l"
printf "%sClaude Code launcher%s\n\n" "$CYAN" "$RESET"
printf "LM Studio: %s\n" "$LM_BASE_URL"
printf "Proxy: %s -> %s\n\n" "$PROXY_BASE_URL" "$LM_BASE_URL"
local h0="$DIM" h1="$DIM" h2="$DIM"
(( active_col == 0 )) && h0="$YELLOW"
(( active_col == 1 )) && h1="$YELLOW"
(( active_col == 2 )) && h2="$YELLOW"
local hp hm hc
hp="$(pad_plain "Provider" "$provider_w")"
hm="$(pad_plain "Local model" "$model_w")"
hc="$(pad_plain "Context" "$context_w")"
printf "%s%s%s%s%s%s%s%s%s\n" \
"$h0" "$hp" "$RESET" "$gap_s" \
"$h1" "$hm" "$RESET" "$gap_s" \
"$h2$hc$RESET"
printf "%s\n" "$line"
max=${#MODEL_ALIASES[@]}
(( max < ${#providers[@]} )) && max=${#providers[@]}
for ((i=0; i<max; i++)); do
local p_text="" m_text="" c_text="" p_prefix=" " m_prefix=" " c_prefix=" "
local p_cell="" m_cell="" c_cell=""
if (( i < ${#providers[@]} )); then
p_text="${providers[$((i + 1))]}"
(( i == provider_idx )) && p_prefix="› "
fi
if (( i < ${#MODEL_ALIASES[@]} )); then
m_text="${MODEL_NAMES[$((i + 1))]} [${MODEL_ALIASES[$((i + 1))]} -> ${MODEL_REAL_IDS[$((i + 1))]}]"
(( i == model_idx )) && m_prefix="› "
fi
# Context is a single editable value, shown only on the selected model row.
if (( i == model_idx )); then
c_text="$context_value"
(( active_col == 2 )) && c_prefix="› "
fi
p_text="$(truncate_plain "$p_text" $((provider_w - 2)))"
m_text="$(truncate_plain "$m_text" $((model_w - 2)))"
c_text="$(truncate_plain "$c_text" $((context_w - 2)))"
p_cell="$(pad_plain "${p_prefix}${p_text}" "$provider_w")"
m_cell="$(pad_plain "${m_prefix}${m_text}" "$model_w")"
c_cell="$(pad_plain "${c_prefix}${c_text}" "$context_w")"
if (( active_col == 0 && i == provider_idx )); then
printf "%s%s%s%s%s%s%s\n" "$REV" "$p_cell" "$RESET" "$gap_s" "$m_cell" "$gap_s" "$c_cell"
elif (( active_col == 1 && i == model_idx )); then
printf "%s%s%s%s%s%s%s\n" "$p_cell" "$gap_s" "$REV" "$m_cell" "$RESET" "$gap_s" "$c_cell"
elif (( active_col == 2 && i == model_idx )); then
printf "%s%s%s%s%s%s%s%s%s\n" "$p_cell" "$gap_s" "$m_cell" "$gap_s" "$REV" "$c_cell" "$RESET" "" ""
else
printf "%s%s%s%s%s\n" "$p_cell" "$gap_s" "$m_cell" "$gap_s" "$c_cell"
fi
done
printf "\n"
if (( provider_idx == 1 )); then
printf "%sSelected:%s local LM Studio through proxy\n" "$GREEN" "$RESET"
printf "Claude-visible model: %s\n" "${MODEL_ALIASES[$((model_idx + 1))]}"
printf "LM Studio model: %s\n" "${MODEL_REAL_IDS[$((model_idx + 1))]}"
printf "Context length: %s / max %s\n" "$context_value" "${MODEL_CONTEXTS[$((model_idx + 1))]}"
printf "Claude Code base URL: %s\n" "$PROXY_BASE_URL"
else
printf "%sSelected:%s real/default Claude Code\n" "$GREEN" "$RESET"
printf "Command: claude\n"
fi
}
read_key() {
local k rest
IFS= read -rs -k1 k
if [[ "$k" == $'\x03' ]]; then
echo CTRL_C
return
fi
if [[ "$k" == $'\e' ]]; then
IFS= read -rs -k2 -t 0.05 rest
case "$rest" in
"[A") echo UP ;;
"[B") echo DOWN ;;
"[C") echo RIGHT ;;
"[D") echo LEFT ;;
"") echo ESC ;;
*) echo OTHER ;;
esac
return
fi
if [[ "$k" == $'\r' || "$k" == $'\n' ]]; then
echo ENTER
return
fi
if [[ "$k" == $'\x7f' || "$k" == $'\b' ]]; then
echo BACKSPACE
return
fi
case "$k" in
[0-9])
echo "DIGIT_$k"
return
;;
q|Q)
echo QUIT
return
;;
m|M)
echo MAX_CONTEXT
return
;;
esac
echo OTHER
}
launch_selected() {
cleanup
echo
if (( provider_idx == 1 )); then
local alias="${MODEL_ALIASES[$((model_idx + 1))]}"
local real="${MODEL_REAL_IDS[$((model_idx + 1))]}"
local ctx="$context_value"
save_choice "local" "$alias" "$ctx"
start_proxy || return 1
if [[ "${USING_EXISTING_PROXY:-0}" != "1" ]]; then
load_lmstudio_model_context "$real" "$ctx" || return 1
remember_context_for_model "$alias" "$ctx"
else
echo "Skipping LM Studio model/context reload because existing proxy is being reused."
echo
fi
echo "Launching Claude Code via LM Studio proxy:"
echo " Claude Code base: $PROXY_BASE_URL"
echo " Claude model alias: $alias"
echo " LM Studio model: $real"
echo " Context length: $ctx"
echo " Proxy log: $LOCALCLAUDE_PROXY_LOG"
echo
env \
-u ANTHROPIC_API_KEY \
-u ANTHROPIC_SMALL_FAST_MODEL \
-u ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION \
-u CLAUDE_CODE_USE_BEDROCK \
-u CLAUDE_CODE_USE_VERTEX \
-u ANTHROPIC_VERTEX_PROJECT_ID \
-u ANTHROPIC_VERTEX_REGION \
-u API_TIMEOUT_MS \
-u AWS_ACCESS_KEY_ID \
-u AWS_SECRET_ACCESS_KEY \
-u AWS_SESSION_TOKEN \
-u GOOGLE_APPLICATION_CREDENTIALS \
ANTHROPIC_BASE_URL="$PROXY_BASE_URL" \
ANTHROPIC_AUTH_TOKEN="localclaude-proxy-token" \
ANTHROPIC_MODEL="$alias" \
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 \
CLAUDE_CODE_ATTRIBUTION_HEADER=0 \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
DISABLE_TELEMETRY=1 \
DISABLE_LOGIN_COMMAND=1 \
claude --model "$alias"
local code=$?
if [[ -n "$PROXY_PID" && "${USING_EXISTING_PROXY:-0}" != "1" ]]; then
kill "$PROXY_PID" 2>/dev/null
wait "$PROXY_PID" 2>/dev/null
fi
echo
echo "Proxy log was: $LOCALCLAUDE_PROXY_LOG"
return "$code"
else
save_choice "real" "${MODEL_ALIASES[$((model_idx + 1))]}" "$context_value"
echo "Launching real/default Claude Code."
echo
env \
-u ANTHROPIC_BASE_URL \
-u ANTHROPIC_AUTH_TOKEN \
-u ANTHROPIC_API_KEY \
-u ANTHROPIC_MODEL \
-u ANTHROPIC_SMALL_FAST_MODEL \
-u ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION \
-u CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY \
-u CLAUDE_CODE_USE_BEDROCK \
-u CLAUDE_CODE_USE_VERTEX \
-u ANTHROPIC_VERTEX_PROJECT_ID \
-u ANTHROPIC_VERTEX_REGION \
-u API_TIMEOUT_MS \
claude
fi
}
stty -echo -icanon min 1 time 0
while true; do
draw
key="$(read_key)"
case "$key" in
LEFT)
(( active_col-- ))
(( active_col < 0 )) && active_col=2
;;
RIGHT)
(( active_col++ ))
(( active_col > 2 )) && active_col=0
;;
UP)
if (( active_col == 0 )); then
(( provider_idx-- ))
(( provider_idx < 0 )) && provider_idx=$((${#providers[@]} - 1))
elif (( active_col == 1 )); then
(( model_idx-- ))
(( model_idx < 0 )) && model_idx=$((${#MODEL_ALIASES[@]} - 1))
set_context_to_model_max "$model_idx"
else
max_ctx="${MODEL_CONTEXTS[$((model_idx + 1))]:-65536}"
step=4096
(( context_value += step ))
(( context_value > max_ctx )) && context_value="$max_ctx"
fi
;;
DOWN)
if (( active_col == 0 )); then
(( provider_idx++ ))
(( provider_idx >= ${#providers[@]} )) && provider_idx=0
elif (( active_col == 1 )); then
(( model_idx++ ))
(( model_idx >= ${#MODEL_ALIASES[@]} )) && model_idx=0
set_context_to_model_max "$model_idx"
else
step=4096
(( context_value -= step ))
(( context_value < step )) && context_value="$step"
fi
;;
DIGIT_*)
if (( active_col == 2 )); then
d="${key#DIGIT_}"
max_ctx="${MODEL_CONTEXTS[$((model_idx + 1))]:-65536}"
if [[ "$context_value" == "$max_ctx" ]]; then
context_value="$d"
else
context_value="${context_value}${d}"
fi
context_value="${context_value##+(0)}"
[[ -z "$context_value" ]] && context_value=0
(( context_value > max_ctx )) && context_value="$max_ctx"
fi
;;
BACKSPACE)
if (( active_col == 2 )); then
context_value="${context_value[1,-2]}"
[[ -z "$context_value" ]] && set_context_to_model_max "$model_idx"
fi
;;
MAX_CONTEXT)
if (( active_col == 2 )); then
set_context_to_model_max "$model_idx"
fi
;;
ENTER)
launch_selected
break
;;
QUIT|ESC|CTRL_C)
cleanup
echo "Cancelled."
break
;;
esac
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment