feat: полная изоляция моделей между ai-* и гибридный persistence effort

Раньше все ai-* лаунчеры делили один ~/.claude и общий settings.json, из-за
чего кастомная модель (openai/gpt-5.5) из ai-openrouter протекала в пикер
ai-claude. Теперь каждый сторонний провайдер изолирован в своём
CLAUDE_CONFIG_DIR (~/.config/ai-setup/cfg/<launcher>) - свои settings.json и
.claude.json, ноль протечек. ai-claude остаётся на ~/.claude (нативный логин).

Пикеры /model приведены к требуемому виду:
- ai-deepseek: только DeepSeek V4 Pro (opus) и DeepSeek V4 Flash (haiku),
  дефолт Pro; через availableModels + ANTHROPIC_DEFAULT_*_MODEL_NAME
- ai-kimi: только Kimi K2.6 (opus)
- ai-claude: только нативные модели Claude
Общие skills и CLAUDE.md шарятся симлинком из ~/.claude.

Persistence effort - гибрид:
- low/medium/high/xhigh живут нативно в settings.json лаунчера, /effort
  внутри сессии работает свободно и уровень сохраняется
- max нельзя сохранить в settings.json (session-only), поэтому он
  восстанавливается через CLAUDE_CODE_EFFORT_LEVEL; в такой max-сессии
  /effort залочен (ограничение Claude Code), выход - AI_EFFORT=<lvl> ai-*
Текущий уровень ловит статусбар в ~/.cache/ai-setup/effort_<launcher>.

Удалён устаревший effort-save-hook (заменён нативным persistence + гибридом),
почищен из ~/.claude/settings.json и осиротевший кэш model_*.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 07:33:30 +03:00
parent 07983ea84e
commit f8465580e0
5 changed files with 166 additions and 121 deletions

View File

@@ -683,39 +683,39 @@ else
warn "Файл $STATUSLINE_SRC не найден, пропускаю"
fi
# ── 6.7.0. Хук effort-save (сохраняет effort при завершении сессии) ──
info "Деплою хук effort-save..."
EFFORT_HOOK_SRC="$SCRIPT_DIR/home-configs/claude/hooks/effort-save-hook.sh"
EFFORT_HOOK_DST="$HOME/.claude/hooks/effort-save-hook.sh"
mkdir -p "$HOME/.claude/hooks"
if [ -f "$EFFORT_HOOK_SRC" ]; then
cp "$EFFORT_HOOK_SRC" "$EFFORT_HOOK_DST"
chmod +x "$EFFORT_HOOK_DST"
python3 - "$HOME/.claude/settings.json" "$EFFORT_HOOK_DST" <<'PYEOF'
# ── 6.7.0. Чистка устаревшего хука effort-save ──────────────
# effort/model теперь персистятся нативно в settings.json каждого CLAUDE_CONFIG_DIR
# (полная изоляция лаунчеров), самопальный кэш ~/.cache/ai-setup/{effort,model}_* не нужен.
# Удаляем старый хук с диска и из ~/.claude/settings.json (нотифаер в Stop сохраняем).
info "Удаляю устаревший хук effort-save..."
rm -f "$HOME/.claude/hooks/effort-save-hook.sh"
# Осиротевший кэш моделей (источник старой протечки между ai-*); effort_* НЕ трогаем -
# он снова используется для персиста effort (включая max) через CLAUDE_CODE_EFFORT_LEVEL.
rm -f "$HOME"/.cache/ai-setup/model_*
python3 - "$HOME/.claude/settings.json" <<'PYEOF'
import sys, json, os
settings_path, hook_path = sys.argv[1], sys.argv[2]
data = {}
if os.path.exists(settings_path):
settings_path = sys.argv[1]
if not os.path.exists(settings_path):
sys.exit(0)
try:
with open(settings_path) as f:
try: data = json.load(f)
except json.JSONDecodeError: pass
data.setdefault("hooks", {}).setdefault("Stop", [{"hooks": []}])
hook_cmd = f'bash "{hook_path}"'
stop_hooks = data["hooks"]["Stop"]
already = any(
any(h.get("command", "") == hook_cmd for h in entry.get("hooks", []))
for entry in stop_hooks
)
if not already:
stop_hooks[0]["hooks"].append({"type": "command", "command": hook_cmd})
with open(settings_path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
data = json.load(f)
except Exception:
sys.exit(0)
stop = data.get("hooks", {}).get("Stop")
if isinstance(stop, list):
for entry in stop:
hooks = entry.get("hooks")
if isinstance(hooks, list):
entry["hooks"] = [h for h in hooks if "effort-save-hook" not in h.get("command", "")]
data["hooks"]["Stop"] = [e for e in stop if e.get("hooks")]
if not data["hooks"]["Stop"]:
del data["hooks"]["Stop"]
with open(settings_path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
PYEOF
success "Хук effort-save установлен"
else
warn "Файл $EFFORT_HOOK_SRC не найден, пропускаю"
fi
success "Старый хук effort-save удалён"
# ── 6.7.1. Хук switch-account ───────────────────────────────────
info "Деплою хук switch-account..."
@@ -1077,71 +1077,95 @@ _open_browser() {
else echo "Откройте вручную: $url"; fi
}
# _restore_effort: читает сохранённый effort для текущего AI_LAUNCHER из кэша
# и записывает его в settings.json, чтобы Claude Code подхватил нужный уровень.
# Не передаём --effort через CLI, чтобы /effort внутри сессии работал без блокировки.
_restore_effort() {
local default_effort="${1:-high}"
local launcher="${AI_LAUNCHER:-}"
[ -z "$launcher" ] && return
local effort_file="$HOME/.cache/ai-setup/effort_${launcher}"
local effort
effort=$(cat "$effort_file" 2>/dev/null)
[ -z "$effort" ] && effort="$default_effort"
mkdir -p "$HOME/.cache/ai-setup"
python3 - "$HOME/.claude/settings.json" "$effort" <<'PYEOF'
# _setup_isolated_config: готовит изолированную папку CLAUDE_CONFIG_DIR для лаунчера.
# Каждый сторонний провайдер получает собственные settings.json и .claude.json,
# поэтому выбор модели и кэш кастом-моделей НЕ протекают между ai-* лаунчерами.
# Общие ресурсы (skills, CLAUDE.md) шарятся симлинком из ~/.claude.
# model и effortLevel сидируются как дефолты - выбор юзера через /model и /effort
# (для low/medium/high/xhigh) сохраняется нативно в этом же settings.json.
# Уровень max обрабатывается отдельно в _apply_effort (settings.json его не хранит).
# Использование: _setup_isolated_config <launcher> <default_model> <default_effort> <available_models_json>
_setup_isolated_config() {
local launcher="$1" default_model="$2" default_effort="${3:-high}" avail="${4:-}"
local cfg="$HOME/.config/ai-setup/cfg/$launcher"
mkdir -p "$cfg"
# Общие ресурсы из ~/.claude (единый источник правды)
ln -sfn "$HOME/.claude/skills" "$cfg/skills"
ln -sfn "$HOME/.claude/CLAUDE.md" "$cfg/CLAUDE.md"
[ -e "$HOME/.claude/agents" ] && ln -sfn "$HOME/.claude/agents" "$cfg/agents"
# .claude.json в свежей папке: пропускаем онбординг
[ -f "$cfg/.claude.json" ] || echo '{"hasCompletedOnboarding": true}' > "$cfg/.claude.json"
python3 - "$cfg/settings.json" "$HOME/.claude/statusline-command.sh" \
"$default_model" "$default_effort" "$avail" <<'PYEOF'
import sys, json, os
settings_path, effort = sys.argv[1], sys.argv[2]
cfg_settings, statusline, model, effort, avail = sys.argv[1:6]
data = {}
if os.path.exists(settings_path):
if os.path.exists(cfg_settings):
try:
with open(settings_path) as f:
with open(cfg_settings) as f:
data = json.load(f)
except Exception:
pass
data['effortLevel'] = effort
with open(settings_path, 'w') as f:
# Структурные настройки лаунчера (переустанавливаем всегда)
data["statusLine"] = {"type": "command", "command": f"bash {statusline}"}
data["skipDangerousModePermissionPrompt"] = True
data.setdefault("hooks", {})["SessionStart"] = [{"hooks": [{"type": "command", "command": "true"}]}]
# availableModels - белый список пикера (политика лаунчера)
if avail:
data["availableModels"] = json.loads(avail)
else:
data.pop("availableModels", None)
# model/effortLevel - сидируем дефолты, не перетирая выбор юзера (нативный persistence)
if model:
data.setdefault("model", model)
data.setdefault("effortLevel", effort)
with open(cfg_settings, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write('\n')
f.write("\n")
PYEOF
}
_restore_model() {
local default_model="${1:-}"
local launcher="${AI_LAUNCHER:-}"
[ -z "$launcher" ] && return
local model_file="$HOME/.cache/ai-setup/model_${launcher}"
local model
model=$(cat "$model_file" 2>/dev/null)
[ -z "$model" ] && model="$default_model"
[ -z "$model" ] && return
python3 - "$HOME/.claude/settings.json" "$model" <<'PYEOF'
# _apply_effort: per-launcher persistence уровня effort (гибрид).
# - low/medium/high/xhigh живут нативно в settings.json лаунчера -> /effort работает,
# уровень сохраняется между сессиями, env-переменная НЕ ставится.
# - max единственный нельзя сохранить в settings.json (он session-only), поэтому
# его восстанавливаем через CLAUDE_CODE_EFFORT_LEVEL. В такой max-сессии /effort
# залочен env-переменной (ограничение Claude Code).
# Текущий уровень (вкл. max) ловит статусбар в ~/.cache/ai-setup/effort_<launcher>.
# Сменить уровень из max: AI_EFFORT=<lvl> ai-<launcher>.
# Использование: _apply_effort <launcher> <default_effort>
_apply_effort() {
local launcher="$1" default_effort="${2:-high}"
local f="$HOME/.cache/ai-setup/effort_${launcher}"
local settings="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"
local eff
if [ -n "${AI_EFFORT:-}" ]; then
# Явный override: запоминаем и применяем
eff="$AI_EFFORT"
mkdir -p "$HOME/.cache/ai-setup"
echo "$eff" > "$f"
else
eff=$(cat "$f" 2>/dev/null)
fi
if [ "$eff" = "max" ]; then
# Единственный способ восстановить max между сессиями
export CLAUDE_CODE_EFFORT_LEVEL=max
elif [ -n "${AI_EFFORT:-}" ] && [ -n "$eff" ]; then
# Явный сброс на low/medium/high/xhigh - пишем нативно в settings.json лаунчера
python3 - "$settings" "$eff" <<'PYEOF'
import sys, json, os
settings_path, model = sys.argv[1], sys.argv[2]
data = {}
if os.path.exists(settings_path):
try:
with open(settings_path) as f:
data = json.load(f)
except Exception:
pass
data['model'] = model
with open(settings_path, 'w') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write('\n')
p, eff = sys.argv[1], sys.argv[2]
d = {}
if os.path.exists(p):
try: d = json.load(open(p))
except Exception: pass
d["effortLevel"] = eff
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w") as fp:
json.dump(d, fp, indent=2, ensure_ascii=False); fp.write("\n")
PYEOF
}
# _restore_model_str: возвращает сохранённую модель строкой (для ANTHROPIC_MODEL env var)
_restore_model_str() {
local default_model="${1:-}"
local launcher="${AI_LAUNCHER:-}"
[ -z "$launcher" ] && echo "$default_model" && return
local model_file="$HOME/.cache/ai-setup/model_${launcher}"
local model
model=$(cat "$model_file" 2>/dev/null)
[ -z "$model" ] && model="$default_model"
echo "$model"
fi
# Иначе (≤xhigh без AI_EFFORT): ничего не делаем - effortLevel уже персистнут нативно.
}
_build_ai_sys_prompt() {
@@ -1266,13 +1290,19 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX)
trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM
_build_ai_sys_prompt > "$_PROMPT_FILE"
export AI_LAUNCHER=deepseek
_restore_effort high
export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/deepseek"
# Пикер: только DeepSeek V4 Pro (opus) и DeepSeek V4 Flash (haiku), дефолт - Pro
_setup_isolated_config deepseek opus high '["opus", "haiku"]'
_apply_effort deepseek high
ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic \
ANTHROPIC_AUTH_TOKEN="$api_key" \
ANTHROPIC_MODEL=deepseek-v4-pro \
ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-v4-pro \
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="DeepSeek V4 Pro" \
ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="DeepSeek V4 Pro - флагман для сложных задач" \
ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-v4-pro \
ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-v4-flash \
ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="DeepSeek V4 Flash" \
ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="DeepSeek V4 Flash - быстрый и дешёвый" \
CLAUDE_CODE_SUBAGENT_MODEL=deepseek-v4-flash \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@"
@@ -1340,11 +1370,15 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX)
trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM
_build_ai_sys_prompt > "$_PROMPT_FILE"
export AI_LAUNCHER=kimi
_restore_effort high
export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/kimi"
# Пикер: единственная модель Kimi K2.6 (под алиасом opus)
_setup_isolated_config kimi opus high '["opus"]'
_apply_effort kimi high
ANTHROPIC_BASE_URL=https://api.kimi.com/coding \
ANTHROPIC_AUTH_TOKEN="$api_key" \
ANTHROPIC_MODEL=kimi-k2.6 \
ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.6 \
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="Kimi K2.6" \
ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="Kimi K2.6 (Moonshot AI)" \
ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k2.6 \
ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k2.6 \
CLAUDE_CODE_SUBAGENT_MODEL=kimi-k2.6 \
@@ -1414,11 +1448,16 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX)
trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM
_build_ai_sys_prompt > "$_PROMPT_FILE"
export AI_LAUNCHER=openrouter
_restore_effort high
_MODEL=$(_restore_model_str "openai/gpt-5.5")
export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/openrouter"
# openrouter - гибкий лаунчер: пикер не ограничиваем (availableModels пустой),
# gpt-5.5 добавляем отдельным пунктом и делаем дефолтом
export ANTHROPIC_CUSTOM_MODEL_OPTION="openai/gpt-5.5"
export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="GPT-5.5"
export ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION="OpenRouter: openai/gpt-5.5"
_setup_isolated_config openrouter "openai/gpt-5.5" high ''
_apply_effort openrouter high
ANTHROPIC_BASE_URL=https://openrouter.ai/api \
ANTHROPIC_AUTH_TOKEN="$api_key" \
ANTHROPIC_MODEL=$_MODEL \
ANTHROPIC_DEFAULT_OPUS_MODEL=anthropic/claude-4.8-opus \
ANTHROPIC_DEFAULT_SONNET_MODEL=anthropic/claude-4.6-sonnet \
ANTHROPIC_DEFAULT_HAIKU_MODEL=openai/gpt-5.5 \
@@ -1469,8 +1508,11 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX)
trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM
_build_ai_sys_prompt > "$_PROMPT_FILE"
export AI_LAUNCHER=claude
_restore_effort xhigh
_restore_model "sonnet"
# ai-claude работает в дефолтном ~/.claude (нативный логин и аккаунты).
# Модель хранится нативно в ~/.claude/settings.json; другие ai-* лаунчеры теперь
# изолированы в своих CLAUDE_CONFIG_DIR, поэтому в пикер не протекают чужие модели -
# показываются только нативные модели Claude Code.
_apply_effort claude xhigh
claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@"
CLAUDEEOF
chmod +x "$BIN_DIR/ai-claude"