├── LICENSE ├── README.md └── clother.sh /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 jolehuit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clother 2 | 3 | ``` 4 | ____ _ _ _ 5 | / ___| | ___ | |_| |__ ___ _ __ 6 | | | | |/ _ \| __| '_ \ / _ \ '__| 7 | | |___| | (_) | |_| | | | __/ | 8 | \____|_|\___/ \__|_| |_|\___|_| 9 | ``` 10 | 11 | **One CLI to switch between Claude Code providers instantly.** 12 | 13 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) 14 | [![Shell](https://img.shields.io/badge/Shell-Bash-green.svg)](https://www.gnu.org/software/bash/) 15 | [![Platform](https://img.shields.io/badge/Platform-macOS%20|%20Linux-lightgrey.svg)](#platform-support) 16 | 17 | 🔒 Secure • 🚀 Fast • 📦 XDG Compliant 18 | 19 | ## Installation 20 | 21 | **Requirements:** Claude Code CLI, Go 1.16+ (for OpenRouter) 22 | 23 | ```bash 24 | # 1. Install Claude Code CLI 25 | curl -fsSL https://claude.ai/install.sh | bash 26 | 27 | # 2. Install Go (for OpenRouter support) 28 | # macOS: brew install go 29 | # Linux: https://go.dev/dl/ 30 | 31 | # 3. Install Clother 32 | curl -fsSL https://raw.githubusercontent.com/jolehuit/clother/main/clother.sh | bash 33 | ``` 34 | 35 | ## Quick Start 36 | 37 | **Got a Claude Pro/Team subscription?** 38 | ```bash 39 | clother-native # Use your subscription, no setup needed! 40 | ``` 41 | 42 | **Want to use alternative models?** 43 | ```bash 44 | clother config # Set up Z.AI, MiniMax, Kimi, etc. 45 | clother-zai # Launch with Z.AI (GLM) 46 | clother-minimax # Launch with MiniMax 47 | ``` 48 | 49 | **Want 100+ models via OpenRouter?** 50 | ```bash 51 | clother config openrouter # Set up OpenRouter + Go proxy 52 | clother-or-devstral # Launch with Devstral 53 | ``` 54 | 55 | ## Providers 56 | 57 | ### Native Anthropic (Your Subscription) 58 | 59 | ```bash 60 | clother-native # Claude Sonnet/Opus/Haiku 61 | # Uses your Claude Pro/Team subscription 62 | # No API key needed 63 | ``` 64 | 65 | ### International 66 | 67 | | Command | Provider | Models | Get API Key | 68 | |---------|----------|--------|-------------| 69 | | `clother-zai` | Z.AI | GLM-4.5-air, GLM-4.6 | [z.ai](https://z.ai) | 70 | | `clother-minimax` | MiniMax | MiniMax-M2 | [minimax.io](https://minimax.io) | 71 | | `clother-kimi` | Kimi | kimi-k2-thinking-turbo | [kimi.com](https://kimi.com) | 72 | | `clother-moonshot` | Moonshot AI | kimi-k2-turbo-preview | [moonshot.ai](https://moonshot.ai) | 73 | | `clother-deepseek` | DeepSeek | deepseek-chat | [deepseek.com](https://platform.deepseek.com) | 74 | | `clother-mimo` | Xiaomi MiMo | mimo-v2-flash | [xiaomimimo.com](https://platform.xiaomimimo.com) | 75 | 76 | ### China Endpoints 🇨🇳 77 | 78 | | Command | Provider | Endpoint | 79 | |---------|----------|----------| 80 | | `clother-zai-cn` | Z.AI (China) | open.bigmodel.cn | 81 | | `clother-minimax-cn` | MiniMax (China) | api.minimaxi.com | 82 | | `clother-ve` | VolcEngine | ark.cn-beijing.volces.com | 83 | 84 | ### Advanced 85 | 86 | | Command | Provider | Description | 87 | |---------|----------|-------------| 88 | | `clother-or-*` | OpenRouter | 100+ models via Go proxy | 89 | | `clother-` | Custom | Any Anthropic-compatible endpoint | 90 | 91 | ## OpenRouter (100+ Models) 92 | 93 | Access GPT-4, Gemini, Llama, Mistral and more through OpenRouter. 94 | 95 | ```bash 96 | clother config openrouter # Enter API key from https://openrouter.ai/keys 97 | 98 | # Add models interactively: 99 | Model ID: mistralai/devstral-2512 100 | Short name: devstral # Creates: clother-or-devstral 101 | 102 | clother-or-devstral # Use it! 103 | ``` 104 | 105 | The Go proxy compiles on first run and handles Anthropic ↔ OpenAI format conversion. 106 | 107 | ## Custom Providers 108 | 109 | Add any Anthropic-compatible endpoint: 110 | 111 | ```bash 112 | clother config # Choose "custom" 113 | clother-myprovider # Ready to use! 114 | ``` 115 | 116 | ## Commands 117 | 118 | | Command | Description | 119 | |---------|-------------| 120 | | `clother config [provider]` | Configure provider (interactive menu if no args) | 121 | | `clother list [--json]` | List configured profiles | 122 | | `clother info ` | Show provider details | 123 | | `clother test [provider]` | Test connectivity | 124 | | `clother status` | Show installation status | 125 | | `clother uninstall` | Remove everything | 126 | 127 | ### Flags 128 | 129 | | Flag | Description | 130 | |------|-------------| 131 | | `-v, --verbose` | Verbose output | 132 | | `-q, --quiet` | Minimal output | 133 | | `-y, --yes` | Auto-confirm prompts | 134 | | `--json` | JSON output | 135 | | `--no-color` | Disable colors | 136 | 137 | ## Examples 138 | 139 | ```bash 140 | # Pass any Claude Code options 141 | clother-zai --dangerously-skip-permissions 142 | 143 | # Check what's configured 144 | clother list 145 | clother info zai 146 | 147 | # Machine-readable output 148 | clother list --json | jq '.profiles[].name' 149 | ``` 150 | 151 | ## How It Works 152 | 153 | Clother creates lightweight launcher scripts that set environment variables: 154 | 155 | ```bash 156 | # When you run: clother-zai 157 | # It does: 158 | export ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic" 159 | export ANTHROPIC_AUTH_TOKEN="$ZAI_API_KEY" 160 | exec claude "$@" 161 | ``` 162 | 163 | API keys stored securely in `~/.local/share/clother/secrets.env` (chmod 600). 164 | 165 | ## File Locations 166 | 167 | Follows [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html): 168 | 169 | ``` 170 | ~/.config/clother/ # Configuration 171 | ~/.local/share/clother/ # Data (secrets, proxy) 172 | ~/bin/clother-* # Launcher scripts 173 | ``` 174 | 175 | ## Troubleshooting 176 | 177 | | Problem | Solution | 178 | |---------|----------| 179 | | `claude: command not found` | Install Claude CLI first | 180 | | `clother: command not found` | Add `~/bin` to PATH | 181 | | `API key not set` | Run `clother config` | 182 | | `Go not installed` (OpenRouter) | Install from [go.dev/dl](https://go.dev/dl/) | 183 | 184 | ## Platform Support 185 | 186 | ✅ macOS (zsh/bash) • ✅ Linux (zsh/bash) • ✅ Windows (WSL) 187 | 188 | **Requirements:** Bash 4.0+, Claude Code CLI, Go 1.16+ (OpenRouter only) 189 | 190 | ## Contributors 191 | 192 | Thanks to everyone who helped improve Clother: 193 | 194 | - [@darkokoa](https://github.com/darkokoa) — China endpoints (zai-cn, minimax-cn, ve) 195 | - [@RawToast](https://github.com/RawToast) — Kimi Coding Plan endpoint fix 196 | 197 | PRs welcome! 🙏 198 | 199 | ## License 200 | 201 | MIT © [jolehuit](https://github.com/jolehuit) 202 | -------------------------------------------------------------------------------- /clother.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # ============================================================================= 3 | # CLOTHER v2.0 - Multi-provider launcher for Claude CLI 4 | # ============================================================================= 5 | # A CLI tool to manage and switch between different LLM providers 6 | # for the Claude Code command-line interface. 7 | # 8 | # Repository: https://github.com/your/clother 9 | # License: MIT 10 | # ============================================================================= 11 | 12 | set -euo pipefail 13 | IFS=$'\n\t' 14 | umask 077 15 | 16 | readonly VERSION="2.0" 17 | readonly CLOTHER_DOCS="https://github.com/your/clother" 18 | 19 | # ============================================================================= 20 | # XDG BASE DIRECTORY SPECIFICATION 21 | # ============================================================================= 22 | 23 | readonly XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" 24 | readonly XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" 25 | readonly XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" 26 | 27 | readonly CONFIG_DIR="${CLOTHER_CONFIG_DIR:-$XDG_CONFIG_HOME/clother}" 28 | readonly DATA_DIR="${CLOTHER_DATA_DIR:-$XDG_DATA_HOME/clother}" 29 | readonly CACHE_DIR="${CLOTHER_CACHE_DIR:-$XDG_CACHE_HOME/clother}" 30 | readonly BIN_DIR="${CLOTHER_BIN:-$HOME/bin}" 31 | 32 | readonly CONFIG_FILE="$CONFIG_DIR/config" 33 | readonly SECRETS_FILE="$DATA_DIR/secrets.env" 34 | readonly PROXY_SOURCE="$DATA_DIR/proxy.go" 35 | readonly PROXY_BIN="$DATA_DIR/openrouter-proxy" 36 | 37 | # ============================================================================= 38 | # GLOBAL FLAGS (can be set via env vars) 39 | # ============================================================================= 40 | 41 | VERBOSE="${CLOTHER_VERBOSE:-0}" 42 | DEBUG="${CLOTHER_DEBUG:-0}" 43 | QUIET="${CLOTHER_QUIET:-0}" 44 | YES_MODE="${CLOTHER_YES:-0}" 45 | NO_INPUT="${CLOTHER_NO_INPUT:-0}" 46 | NO_BANNER="${CLOTHER_NO_BANNER:-0}" 47 | OUTPUT_FORMAT="${CLOTHER_OUTPUT_FORMAT:-human}" # human, json, plain 48 | DEFAULT_PROVIDER="${CLOTHER_DEFAULT_PROVIDER:-}" 49 | 50 | # ============================================================================= 51 | # TTY & COLOR DETECTION 52 | # ============================================================================= 53 | 54 | is_tty() { [[ -t 1 ]]; } 55 | is_stdin_tty() { [[ -t 0 ]]; } 56 | is_interactive() { is_tty && is_stdin_tty && [[ "$NO_INPUT" != "1" ]]; } 57 | 58 | setup_colors() { 59 | if is_tty && [[ -z "${NO_COLOR:-}" ]] && [[ "$OUTPUT_FORMAT" == "human" ]]; then 60 | RED=$'\033[0;31m' 61 | GREEN=$'\033[0;32m' 62 | YELLOW=$'\033[1;33m' 63 | BLUE=$'\033[0;34m' 64 | CYAN=$'\033[0;36m' 65 | MAGENTA=$'\033[0;35m' 66 | BOLD=$'\033[1m' 67 | DIM=$'\033[2m' 68 | NC=$'\033[0m' 69 | else 70 | RED='' GREEN='' YELLOW='' BLUE='' CYAN='' MAGENTA='' BOLD='' DIM='' NC='' 71 | fi 72 | } 73 | 74 | setup_symbols() { 75 | if [[ "${TERM:-}" == "dumb" ]] || [[ -n "${NO_COLOR:-}" ]]; then 76 | SYM_OK="[OK]" SYM_ERR="[X]" SYM_WARN="[!]" SYM_INFO=">" SYM_ARROW="->" 77 | SYM_CHECK="[x]" SYM_UNCHECK="[ ]" 78 | SYM_SPINNER=("-" "\\" "|" "/") 79 | BOX_TL="+" BOX_TR="+" BOX_BL="+" BOX_BR="+" BOX_H="-" BOX_V="|" 80 | else 81 | SYM_OK="✓" SYM_ERR="✗" SYM_WARN="⚠" SYM_INFO="→" SYM_ARROW="→" 82 | SYM_CHECK="✓" SYM_UNCHECK="○" 83 | SYM_SPINNER=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") 84 | BOX_TL="╭" BOX_TR="╮" BOX_BL="╰" BOX_BR="╯" BOX_H="─" BOX_V="│" 85 | fi 86 | } 87 | 88 | setup_colors 89 | setup_symbols 90 | 91 | # ============================================================================= 92 | # LOGGING SYSTEM 93 | # ============================================================================= 94 | 95 | debug() { [[ "$DEBUG" == "1" ]] && echo -e "${DIM}[DEBUG] $*${NC}" >&2 || true; } 96 | verbose() { [[ "$VERBOSE" == "1" || "$DEBUG" == "1" ]] && echo -e "${DIM}$*${NC}" || true; } 97 | log() { [[ "$QUIET" != "1" ]] && echo -e "${BLUE}${SYM_INFO}${NC} $*" || true; } 98 | success() { echo -e "${GREEN}${SYM_OK}${NC} $*"; } 99 | warn() { echo -e "${YELLOW}${SYM_WARN}${NC} $*" >&2; } 100 | error() { echo -e "${RED}${SYM_ERR}${NC} $*" >&2; } 101 | 102 | # Error with context, cause, and solution 103 | error_ctx() { 104 | local code="$1" msg="$2" context="$3" cause="$4" solution="$5" 105 | echo >&2 106 | echo -e "${RED}${BOLD}ERROR${NC} ${DIM}[$code]${NC} ${BOLD}$msg${NC}" >&2 107 | echo -e " ${DIM}Context:${NC} $context" >&2 108 | echo -e " ${DIM}Cause:${NC} $cause" >&2 109 | echo -e " ${CYAN}Fix:${NC} $solution" >&2 110 | echo >&2 111 | } 112 | 113 | # Suggest next steps 114 | suggest_next() { 115 | [[ "$QUIET" == "1" || "$OUTPUT_FORMAT" != "human" ]] && return 116 | echo -e "\n${BOLD}Next:${NC}" 117 | for s in "$@"; do echo -e " ${CYAN}${SYM_ARROW}${NC} $s"; done 118 | } 119 | 120 | # ============================================================================= 121 | # UI COMPONENTS 122 | # ============================================================================= 123 | 124 | draw_box() { 125 | local title="$1" width="${2:-52}" 126 | local inner=$((width - 2)) 127 | local pad=$(( (inner - ${#title}) / 2 )) 128 | 129 | # Use printf repeat instead of tr (tr fails with multi-byte UTF-8 on some Linux) 130 | local hline; printf -v hline "%${inner}s" ""; hline="${hline// /$BOX_H}" 131 | printf "%s%s%s\n" "$BOX_TL" "$hline" "$BOX_TR" 132 | printf "%s%${pad}s${BOLD}%s${NC}%$((inner - pad - ${#title}))s%s\n" "$BOX_V" "" "$title" "" "$BOX_V" 133 | printf "%s%s%s\n" "$BOX_BL" "$hline" "$BOX_BR" 134 | } 135 | 136 | draw_separator() { 137 | local width="${1:-52}" 138 | local hline; printf -v hline "%${width}s" ""; hline="${hline// /$BOX_H}" 139 | printf "${DIM}%s${NC}\n" "$hline" 140 | } 141 | 142 | # Spinner for long operations 143 | SPINNER_PID="" 144 | spinner_start() { 145 | local msg="${1:-Working...}" 146 | ! is_tty && { log "$msg"; return; } 147 | ( 148 | local i=0 149 | while true; do 150 | printf "\r${BLUE}${SYM_SPINNER[$i]}${NC} %s " "$msg" 151 | i=$(( (i + 1) % ${#SYM_SPINNER[@]} )) 152 | sleep 0.1 153 | done 154 | ) & 155 | SPINNER_PID=$! 156 | disown "$SPINNER_PID" 2>/dev/null || true 157 | } 158 | 159 | spinner_stop() { 160 | local status="${1:-0}" msg="${2:-Done}" 161 | if [[ -n "$SPINNER_PID" ]]; then 162 | kill "$SPINNER_PID" 2>/dev/null || true 163 | wait "$SPINNER_PID" 2>/dev/null || true 164 | SPINNER_PID="" 165 | printf "\r\033[K" 166 | fi 167 | [[ "$status" -eq 0 ]] && success "$msg" || error "$msg" 168 | } 169 | 170 | # ============================================================================= 171 | # INPUT & PROMPTS 172 | # ============================================================================= 173 | 174 | prompt() { 175 | local msg="$1" default="${2:-}" var="${3:-REPLY}" 176 | local prompt_text="$msg"; [[ -n "$default" ]] && prompt_text="$msg [$default]" 177 | read -r -p "$prompt_text: " "$var" || true 178 | if [[ -z "${!var}" && -n "$default" ]]; then 179 | printf -v "$var" "%s" "$default" 180 | fi 181 | } 182 | 183 | prompt_secret() { 184 | local msg="$1" var="${2:-REPLY}" 185 | read -rs -p "$msg: " "$var"; echo 186 | } 187 | 188 | confirm() { 189 | local msg="$1" default="${2:-n}" 190 | [[ "$YES_MODE" == "1" ]] && return 0 191 | local hint; [[ "$default" =~ ^[Yy] ]] && hint="[Y/n]" || hint="[y/N]" 192 | local resp; read -r -p "$msg $hint: " resp || true; resp="${resp:-$default}" 193 | [[ "$resp" =~ ^[Yy] ]] && return 0 || return 1 194 | } 195 | 196 | confirm_danger() { 197 | local action="$1" phrase="${2:-yes}" 198 | [[ "$YES_MODE" == "1" ]] && { warn "Auto-confirming: $action"; return 0; } 199 | echo; draw_box "DANGER" 40; echo 200 | echo -e "${RED}${BOLD}$action${NC}"; echo 201 | echo -e "Type ${YELLOW}${BOLD}$phrase${NC} to confirm:" 202 | local resp; read -r resp 203 | [[ "$resp" == "$phrase" ]] 204 | } 205 | 206 | # ============================================================================= 207 | # VALIDATION 208 | # ============================================================================= 209 | 210 | validate_name() { 211 | local name="$1" field="${2:-name}" 212 | if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then 213 | error_ctx "E001" "Invalid $field" "Validating: $name" \ 214 | "Must be lowercase letters, digits, - or _" \ 215 | "Use a valid name like 'my-provider'" 216 | return 1 217 | fi 218 | } 219 | 220 | validate_url() { 221 | local url="$1" 222 | if [[ ! "$url" =~ ^https?:// ]]; then 223 | error_ctx "E002" "Invalid URL" "Validating: $url" \ 224 | "URL must start with http:// or https://" \ 225 | "Provide a valid URL" 226 | return 1 227 | fi 228 | } 229 | 230 | validate_api_key() { 231 | local key="$1" provider="${2:-}" 232 | if [[ -z "$key" ]]; then 233 | error_ctx "E003" "API key is empty" "Configuring $provider" \ 234 | "No API key provided" \ 235 | "Enter your API key from the provider's dashboard" 236 | return 1 237 | fi 238 | if [[ ${#key} -lt 8 ]]; then 239 | error_ctx "E004" "API key too short" "Validating key for $provider" \ 240 | "Key has ${#key} chars, minimum is 8" \ 241 | "Check that you copied the full key" 242 | return 1 243 | fi 244 | } 245 | 246 | # ============================================================================= 247 | # SECRETS MANAGEMENT 248 | # ============================================================================= 249 | 250 | load_secrets() { 251 | [[ ! -f "$SECRETS_FILE" ]] && return 0 252 | # Security checks 253 | if [[ -L "$SECRETS_FILE" ]]; then 254 | error "Secrets file is a symlink - refusing for security"; return 1 255 | fi 256 | local perms 257 | perms=$(stat -f "%Lp" "$SECRETS_FILE" 2>/dev/null || stat -c "%a" "$SECRETS_FILE" 2>/dev/null || echo "000") 258 | if [[ "$perms" != "600" ]]; then 259 | warn "Fixing secrets file permissions"; chmod 600 "$SECRETS_FILE" 260 | fi 261 | source "$SECRETS_FILE" 262 | } 263 | 264 | save_secret() { 265 | local key="$1" value="$2" 266 | mkdir -p "$(dirname "$SECRETS_FILE")" 267 | local tmp; tmp=$(mktemp "${SECRETS_FILE}.XXXXXX") 268 | [[ -f "$SECRETS_FILE" ]] && grep -v "^${key}=" "$SECRETS_FILE" > "$tmp" 2>/dev/null || true 269 | printf '%s=%q\n' "$key" "$value" >> "$tmp" 270 | mv "$tmp" "$SECRETS_FILE" 271 | chmod 600 "$SECRETS_FILE" 272 | } 273 | 274 | mask_key() { 275 | local key="${1:-}" 276 | [[ -z "$key" ]] && { echo ""; return; } 277 | [[ ${#key} -le 8 ]] && { echo "****"; return; } 278 | echo "${key:0:4}****${key: -4}" 279 | } 280 | 281 | # ============================================================================= 282 | # SIGNAL HANDLERS & CLEANUP 283 | # ============================================================================= 284 | 285 | cleanup() { 286 | local exit_code="${1:-$?}" 287 | spinner_stop 1 "Interrupted" 2>/dev/null || true 288 | tput cnorm 2>/dev/null || true # Show cursor 289 | exit "$exit_code" 290 | } 291 | 292 | trap 'cleanup 130' INT 293 | trap 'cleanup 143' TERM 294 | 295 | # ============================================================================= 296 | # PROVIDER DEFINITIONS 297 | # ============================================================================= 298 | 299 | get_provider_def() { 300 | # Format: keyvar|baseurl|model|model_opts|description 301 | case "$1" in 302 | native) echo "|||Native Anthropic" ;; 303 | zai) echo "ZAI_API_KEY|https://api.z.ai/api/anthropic|glm-4.6|haiku=glm-4.5-air,sonnet=glm-4.6,opus=glm-4.6|Z.AI International" ;; 304 | zai-cn) echo "ZAI_CN_API_KEY|https://open.bigmodel.cn/api/anthropic|glm-4.6|haiku=glm-4.5-air,sonnet=glm-4.6,opus=glm-4.6|Z.AI China" ;; 305 | minimax) echo "MINIMAX_API_KEY|https://api.minimax.io/anthropic|MiniMax-M2||MiniMax International" ;; 306 | minimax-cn) echo "MINIMAX_CN_API_KEY|https://api.minimaxi.com/anthropic|MiniMax-M2||MiniMax China" ;; 307 | kimi) echo "KIMI_API_KEY|https://api.kimi.com/coding/|kimi-k2-thinking-turbo|small=kimi-k2-turbo-preview|Kimi K2" ;; 308 | moonshot) echo "MOONSHOT_API_KEY|https://api.moonshot.ai/anthropic|kimi-k2-turbo-preview||Moonshot AI" ;; 309 | ve) echo "ARK_API_KEY|https://ark.cn-beijing.volces.com/api/coding|doubao-seed-code-preview-latest||VolcEngine" ;; 310 | deepseek) echo "DEEPSEEK_API_KEY|https://api.deepseek.com/anthropic|deepseek-chat|small=deepseek-chat|DeepSeek" ;; 311 | mimo) echo "MIMO_API_KEY|https://api.xiaomimimo.com/anthropic|mimo-v2-flash|haiku=mimo-v2-flash,sonnet=mimo-v2-flash,opus=mimo-v2-flash|Xiaomi MiMo" ;; 312 | *) echo "" ;; 313 | esac 314 | } 315 | 316 | is_provider_configured() { 317 | local provider="$1" 318 | local def; def=$(get_provider_def "$provider") 319 | [[ -z "$def" ]] && return 1 320 | IFS='|' read -r keyvar _ _ _ _ <<< "$def" 321 | [[ -z "$keyvar" ]] && return 0 # native 322 | [[ -n "${!keyvar:-}" ]] 323 | } 324 | 325 | # ============================================================================= 326 | # HELP SYSTEM 327 | # ============================================================================= 328 | 329 | show_version() { 330 | echo "Clother v$VERSION" 331 | } 332 | 333 | show_brief_help() { 334 | cat << EOF 335 | ${BOLD}Clother v$VERSION${NC} - Multi-provider launcher for Claude CLI 336 | 337 | ${BOLD}Usage:${NC} clother [options] 338 | 339 | ${BOLD}Commands:${NC} 340 | config Configure a provider 341 | list List profiles 342 | info Provider details 343 | test Test providers 344 | help Show full help 345 | 346 | ${BOLD}Examples:${NC} 347 | ${GREEN}clother config${NC} Setup a provider 348 | ${GREEN}clother-zai${NC} Use Z.AI 349 | 350 | Run ${CYAN}clother --help${NC} for full documentation. 351 | EOF 352 | } 353 | 354 | show_full_help() { 355 | cat << EOF 356 | ${BOLD}Clother v$VERSION${NC} 357 | Multi-provider launcher for Claude CLI 358 | 359 | ${BOLD}USAGE${NC} 360 | clother [options] [args] 361 | 362 | ${BOLD}EXAMPLES${NC} 363 | ${GREEN}clother config${NC} # Interactive provider setup 364 | ${GREEN}clother config zai${NC} # Configure specific provider 365 | ${GREEN}clother list${NC} # Show all profiles 366 | ${GREEN}clother list --json${NC} # Machine-readable output 367 | ${GREEN}clother test${NC} # Verify all providers 368 | ${GREEN}clother-zai${NC} # Launch Claude with Z.AI 369 | ${GREEN}clother-or-gpt4o${NC} # Launch with OpenRouter GPT-4o 370 | 371 | ${BOLD}COMMANDS${NC} 372 | config [provider] Configure a provider (interactive if no provider given) 373 | list List all configured profiles 374 | info Show details for a provider 375 | test [provider] Test provider connectivity 376 | status Show current Clother state 377 | uninstall Remove Clother completely 378 | help [command] Show help (contextual if command given) 379 | 380 | ${BOLD}OPTIONS${NC} 381 | -h, --help Show help 382 | -V, --version Show version 383 | -v, --verbose Verbose output 384 | -d, --debug Debug mode 385 | -q, --quiet Minimal output 386 | -y, --yes Auto-confirm prompts 387 | --no-input Non-interactive mode (for scripts) 388 | --no-color Disable colors 389 | --no-banner Hide ASCII banner 390 | --json JSON output 391 | --plain Plain text output 392 | 393 | ${BOLD}PROVIDERS${NC} 394 | ${DIM}Native${NC} 395 | native Anthropic direct (no config needed) 396 | 397 | ${DIM}China${NC} 398 | zai-cn Z.AI China (GLM-4.6) 399 | minimax-cn MiniMax China (M2) 400 | kimi Kimi (K2 Thinking) 401 | ve VolcEngine (Doubao) 402 | 403 | ${DIM}International${NC} 404 | zai Z.AI (GLM-4.6) 405 | minimax MiniMax (M2) 406 | moonshot Moonshot AI 407 | deepseek DeepSeek 408 | mimo Xiaomi MiMo 409 | 410 | ${DIM}Advanced${NC} 411 | openrouter 100+ models via Go proxy 412 | custom Anthropic-compatible endpoint 413 | 414 | ${BOLD}ENVIRONMENT${NC} 415 | CLOTHER_CONFIG_DIR Config directory (default: ~/.config/clother) 416 | CLOTHER_DATA_DIR Data directory (default: ~/.local/share/clother) 417 | CLOTHER_BIN Binary directory (default: ~/bin) 418 | CLOTHER_DEFAULT_PROVIDER Default provider to use 419 | CLOTHER_VERBOSE Enable verbose mode (1) 420 | CLOTHER_QUIET Enable quiet mode (1) 421 | CLOTHER_YES Auto-confirm prompts (1) 422 | NO_COLOR Disable colors (standard) 423 | 424 | ${BOLD}FILES${NC} 425 | ~/.config/clother/config User configuration 426 | ~/.local/share/clother/secrets.env API keys (chmod 600) 427 | ~/bin/clother-* Provider launchers 428 | 429 | ${DIM}Documentation: $CLOTHER_DOCS${NC} 430 | EOF 431 | } 432 | 433 | show_command_help() { 434 | local cmd="$1" 435 | case "$cmd" in 436 | config) 437 | cat << EOF 438 | ${BOLD}clother config${NC} - Configure a provider 439 | 440 | ${BOLD}USAGE${NC} 441 | clother config # Interactive menu 442 | clother config # Configure specific provider 443 | 444 | ${BOLD}EXAMPLES${NC} 445 | ${GREEN}clother config${NC} # Show provider menu 446 | ${GREEN}clother config zai${NC} # Configure Z.AI 447 | ${GREEN}clother config openrouter${NC} # Configure OpenRouter 448 | 449 | ${BOLD}PROVIDERS${NC} 450 | native, zai, zai-cn, minimax, minimax-cn, kimi, 451 | moonshot, ve, deepseek, mimo, openrouter, custom 452 | EOF 453 | ;; 454 | list) 455 | cat << EOF 456 | ${BOLD}clother list${NC} - List configured profiles 457 | 458 | ${BOLD}USAGE${NC} 459 | clother list [options] 460 | 461 | ${BOLD}OPTIONS${NC} 462 | --json Output as JSON 463 | --plain Plain text (for scripts) 464 | 465 | ${BOLD}EXAMPLES${NC} 466 | ${GREEN}clother list${NC} # Human-readable 467 | ${GREEN}clother list --json${NC} # For scripting 468 | ${GREEN}clother list | grep zai${NC} # Filter 469 | EOF 470 | ;; 471 | *) 472 | show_full_help 473 | ;; 474 | esac 475 | } 476 | 477 | # Command suggestion (Levenshtein-like) 478 | suggest_command() { 479 | local input="$1" 480 | local -a commands=(config list info test status uninstall help) 481 | local best="" best_score=999 482 | 483 | for cmd in "${commands[@]}"; do 484 | # Simple prefix match 485 | if [[ "$cmd" == "$input"* ]]; then 486 | echo "$cmd"; return 487 | fi 488 | # Check if input is substring 489 | if [[ "$cmd" == *"$input"* ]]; then 490 | best="$cmd" 491 | fi 492 | done 493 | [[ -n "$best" ]] && echo "$best" 494 | } 495 | 496 | # ============================================================================= 497 | # COMMANDS 498 | # ============================================================================= 499 | 500 | cmd_config() { 501 | local provider="${1:-}" 502 | 503 | load_secrets 504 | 505 | if [[ -n "$provider" ]]; then 506 | case "$provider" in 507 | openrouter) config_openrouter; return ;; 508 | custom) config_custom; return ;; 509 | *) config_provider "$provider"; return ;; 510 | esac 511 | fi 512 | 513 | # Interactive menu 514 | echo 515 | draw_box "CLOTHER CONFIGURATION" 54 516 | echo 517 | 518 | # Count configured 519 | local configured=0 520 | for p in native zai zai-cn minimax minimax-cn kimi moonshot ve deepseek mimo; do 521 | is_provider_configured "$p" && ((++configured)) || true 522 | done 523 | echo -e "${DIM}$configured providers configured${NC}" 524 | echo 525 | 526 | # Native 527 | echo -e "${BOLD}NATIVE${NC}" 528 | printf " ${CYAN}%-2s${NC} %-12s %-24s %s\n" "1" "native" "Anthropic direct" \ 529 | "$(is_provider_configured native && echo "${GREEN}${SYM_CHECK}${NC}" || echo "${DIM}${SYM_UNCHECK}${NC}")" 530 | echo 531 | 532 | # China 533 | echo -e "${BOLD}CHINA${NC}" 534 | local -a china_providers=(zai-cn minimax-cn ve) 535 | local -a china_names=("Z.AI China" "MiniMax China" "VolcEngine") 536 | for i in "${!china_providers[@]}"; do 537 | local p="${china_providers[$i]}" 538 | local status; is_provider_configured "$p" && status="${GREEN}${SYM_CHECK}${NC}" || status="${DIM}${SYM_UNCHECK}${NC}" 539 | printf " ${CYAN}%-2s${NC} %-12s %-24s %s\n" "$((i+2))" "$p" "${china_names[$i]}" "$status" 540 | done 541 | echo 542 | 543 | # International 544 | echo -e "${BOLD}INTERNATIONAL${NC}" 545 | local -a intl_providers=(zai minimax kimi moonshot deepseek mimo) 546 | local -a intl_names=("Z.AI" "MiniMax" "Kimi K2" "Moonshot AI" "DeepSeek" "Xiaomi MiMo") 547 | for i in "${!intl_providers[@]}"; do 548 | local p="${intl_providers[$i]}" 549 | local status; is_provider_configured "$p" && status="${GREEN}${SYM_CHECK}${NC}" || status="${DIM}${SYM_UNCHECK}${NC}" 550 | printf " ${CYAN}%-2s${NC} %-12s %-24s %s\n" "$((i+5))" "$p" "${intl_names[$i]}" "$status" 551 | done 552 | echo 553 | 554 | # Advanced 555 | echo -e "${BOLD}ADVANCED${NC}" 556 | printf " ${CYAN}%-2s${NC} %-12s %-24s\n" "11" "openrouter" "100+ models (Go proxy)" 557 | printf " ${CYAN}%-2s${NC} %-12s %-24s\n" "12" "custom" "Anthropic-compatible" 558 | echo 559 | 560 | draw_separator 54 561 | echo -e " ${DIM}[t] Test providers [q] Quit${NC}" 562 | echo 563 | 564 | local choice 565 | prompt "Choose" "q" choice 566 | 567 | case "$choice" in 568 | 1) config_provider "native" ;; 569 | 2) config_provider "zai-cn" ;; 570 | 3) config_provider "minimax-cn" ;; 571 | 4) config_provider "ve" ;; 572 | 5) config_provider "zai" ;; 573 | 6) config_provider "minimax" ;; 574 | 7) config_provider "kimi" ;; 575 | 8) config_provider "moonshot" ;; 576 | 9) config_provider "deepseek" ;; 577 | 10) config_provider "mimo" ;; 578 | 11) config_openrouter ;; 579 | 12) config_custom ;; 580 | t|T) cmd_test ;; 581 | q|Q) log "Cancelled" ;; 582 | *) error "Invalid choice: $choice" ;; 583 | esac 584 | } 585 | 586 | config_provider() { 587 | local provider="$1" 588 | local def; def=$(get_provider_def "$provider") 589 | 590 | if [[ -z "$def" ]]; then 591 | error "Unknown provider: $provider" 592 | local suggestion; suggestion=$(suggest_command "$provider") 593 | [[ -n "$suggestion" ]] && echo -e "Did you mean: ${GREEN}$suggestion${NC}?" 594 | return 1 595 | fi 596 | 597 | IFS='|' read -r keyvar baseurl model model_opts description <<< "$def" 598 | 599 | echo 600 | echo -e "${BOLD}Configure: $description${NC}" 601 | [[ -n "$baseurl" ]] && echo -e "${DIM}Endpoint: $baseurl${NC}" 602 | echo 603 | 604 | # Native needs no config 605 | if [[ -z "$keyvar" ]]; then 606 | success "Native Anthropic is ready" 607 | suggest_next "Use it: ${GREEN}clother-native${NC}" 608 | return 0 609 | fi 610 | 611 | # Show current key if set 612 | [[ -n "${!keyvar:-}" ]] && echo -e "Current key: ${DIM}$(mask_key "${!keyvar}")${NC}" 613 | 614 | local key 615 | prompt_secret "API Key" key 616 | validate_api_key "$key" "$provider" || return 1 617 | 618 | save_secret "$keyvar" "$key" 619 | success "API key saved" 620 | 621 | suggest_next \ 622 | "Use it: ${GREEN}clother-$provider${NC}" \ 623 | "Test it: ${GREEN}clother test $provider${NC}" 624 | } 625 | 626 | config_openrouter() { 627 | echo 628 | echo -e "${BOLD}Configure: OpenRouter${NC}" 629 | echo -e "${DIM}Access 100+ models via local Go proxy${NC}" 630 | echo -e "Get API key: ${CYAN}https://openrouter.ai/keys${NC}" 631 | echo 632 | 633 | load_secrets 634 | 635 | # Handle API key 636 | if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then 637 | echo -e "Current key: ${DIM}$(mask_key "$OPENROUTER_API_KEY")${NC}" 638 | if confirm "Change key?" "n"; then 639 | local new_key 640 | prompt_secret "New API Key" new_key 641 | if [[ -n "$new_key" ]]; then 642 | validate_api_key "$new_key" "openrouter" || return 0 643 | save_secret "OPENROUTER_API_KEY" "$new_key" 644 | success "API key saved" 645 | fi 646 | fi 647 | else 648 | local new_key 649 | prompt_secret "API Key" new_key 650 | if [[ -n "$new_key" ]]; then 651 | validate_api_key "$new_key" "openrouter" || return 0 652 | save_secret "OPENROUTER_API_KEY" "$new_key" 653 | success "API key saved" 654 | else 655 | warn "No API key provided" 656 | return 0 657 | fi 658 | fi 659 | 660 | # Check Go 661 | local go_bin 662 | go_bin=$(find_go) 663 | if [[ -z "$go_bin" ]]; then 664 | warn "Go not installed - required for OpenRouter proxy" 665 | echo -e "Install from: ${CYAN}https://go.dev/dl/${NC}" 666 | fi 667 | 668 | # List existing models 669 | echo 670 | echo -e "${BOLD}Configured models:${NC}" 671 | local found=false 672 | for f in "$BIN_DIR"/clother-or-*; do 673 | [[ -x "$f" ]] && { found=true; echo -e " ${GREEN}$(basename "$f")${NC}"; } 674 | done 675 | $found || echo -e " ${DIM}(none)${NC}" 676 | 677 | # Add new model 678 | echo 679 | if confirm "Add a model?"; then 680 | while true; do 681 | local model 682 | prompt "Model ID (e.g. openai/gpt-4o) or 'q'" "" model 683 | [[ "$model" == "q" || -z "$model" ]] && break 684 | 685 | # Get short name 686 | local default_name; default_name=$(echo "$model" | sed 's|.*/||' | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]-') 687 | local name 688 | prompt "Short name" "$default_name" name 689 | validate_name "$name" "model name" || continue 690 | 691 | save_secret "OPENROUTER_MODEL_$(echo "$name" | tr '[:lower:]-' '[:upper:]_')" "$model" 692 | generate_or_launcher "$name" "$model" 693 | success "Created ${GREEN}clother-or-$name${NC}" 694 | echo 695 | done 696 | fi 697 | } 698 | 699 | config_custom() { 700 | echo 701 | echo -e "${BOLD}Configure: Custom Provider${NC}" 702 | echo -e "${DIM}For any Anthropic-compatible endpoint${NC}" 703 | echo 704 | 705 | local name url key 706 | prompt "Provider name (lowercase)" "" name 707 | validate_name "$name" || return 1 708 | 709 | prompt "Base URL" "" url 710 | validate_url "$url" || return 1 711 | 712 | prompt_secret "API Key" key 713 | validate_api_key "$key" "custom" || return 1 714 | 715 | local keyvar; keyvar="$(echo "$name" | tr '[:lower:]-' '[:upper:]_')_API_KEY" 716 | save_secret "$keyvar" "$key" 717 | save_secret "CLOTHER_${keyvar}_BASE_URL" "$url" 718 | 719 | generate_launcher "$name" "$keyvar" "$url" "" "" 720 | success "Created ${GREEN}clother-$name${NC}" 721 | } 722 | 723 | cmd_list() { 724 | load_secrets 725 | 726 | local -a profiles=() 727 | for f in "$BIN_DIR"/clother-*; do 728 | [[ -x "$f" ]] || continue 729 | local name; name=$(basename "$f" | sed 's/^clother-//') 730 | profiles+=("$name") 731 | done 732 | 733 | if [[ "$OUTPUT_FORMAT" == "json" ]]; then 734 | echo -n '{"profiles":[' 735 | local first=true 736 | for p in "${profiles[@]}"; do 737 | $first || echo -n "," 738 | first=false 739 | echo -n "{\"name\":\"$p\",\"command\":\"clother-$p\"}" 740 | done 741 | echo ']}' 742 | return 743 | fi 744 | 745 | if [[ ${#profiles[@]} -eq 0 ]]; then 746 | warn "No profiles configured" 747 | suggest_next "Configure one: ${GREEN}clother config${NC}" 748 | return 749 | fi 750 | 751 | echo -e "${BOLD}Available Profiles (${#profiles[@]}):${NC}" 752 | echo 753 | for p in "${profiles[@]}"; do 754 | local status="${DIM}${SYM_UNCHECK}${NC}" 755 | # Check if configured 756 | local def; def=$(get_provider_def "$p") 757 | if [[ -n "$def" ]]; then 758 | is_provider_configured "$p" && status="${GREEN}${SYM_CHECK}${NC}" 759 | elif [[ "$p" == or-* ]]; then 760 | [[ -n "${OPENROUTER_API_KEY:-}" ]] && status="${GREEN}${SYM_CHECK}${NC}" 761 | fi 762 | echo -e " $status ${YELLOW}$p${NC}" 763 | done 764 | echo 765 | echo -e "${DIM}Run: ${NC}${GREEN}clother-${NC}" 766 | } 767 | 768 | cmd_info() { 769 | local provider="${1:-}" 770 | [[ -z "$provider" ]] && { error "Usage: clother info "; return 1; } 771 | 772 | load_secrets 773 | 774 | local def; def=$(get_provider_def "$provider") 775 | 776 | echo 777 | echo -e "${BOLD}Provider: ${YELLOW}$provider${NC}" 778 | draw_separator 40 779 | 780 | if [[ -n "$def" ]]; then 781 | IFS='|' read -r keyvar baseurl model model_opts description <<< "$def" 782 | echo -e "Description: $description" 783 | echo -e "Base URL: ${baseurl:-default}" 784 | echo -e "Model: ${model:-default}" 785 | if [[ -n "$keyvar" ]]; then 786 | local status; [[ -n "${!keyvar:-}" ]] && status="${GREEN}configured${NC}" || status="${RED}not set${NC}" 787 | echo -e "API Key: $status" 788 | fi 789 | elif [[ "$provider" == or-* ]]; then 790 | local short="${provider#or-}" 791 | local keyvar="OPENROUTER_MODEL_$(echo "$short" | tr '[:lower:]-' '[:upper:]_')" 792 | echo -e "Type: OpenRouter" 793 | echo -e "Model: ${!keyvar:-unknown}" 794 | echo -e "Proxy: localhost:8378" 795 | else 796 | echo -e "Type: Custom/Unknown" 797 | fi 798 | } 799 | 800 | cmd_test() { 801 | local provider="${1:-}" 802 | 803 | load_secrets 804 | 805 | echo 806 | echo -e "${BOLD}Testing Providers${NC}" 807 | draw_separator 40 808 | 809 | local providers_to_test=() 810 | if [[ -n "$provider" ]]; then 811 | providers_to_test=("$provider") 812 | else 813 | # Get all configured providers 814 | for f in "$BIN_DIR"/clother-*; do 815 | [[ -x "$f" ]] || continue 816 | local name; name=$(basename "$f" | sed 's/^clother-//') 817 | [[ "$name" != "native" ]] && providers_to_test+=("$name") 818 | done 819 | fi 820 | 821 | local ok=0 fail=0 822 | for p in "${providers_to_test[@]}"; do 823 | printf " Testing %-15s " "$p" 824 | 825 | local def; def=$(get_provider_def "$p") 826 | if [[ -n "$def" ]]; then 827 | IFS='|' read -r keyvar baseurl _ _ _ <<< "$def" 828 | if [[ -n "$keyvar" && -z "${!keyvar:-}" ]]; then 829 | echo -e "${YELLOW}not configured${NC}" 830 | ((++fail)) || true 831 | continue 832 | fi 833 | fi 834 | 835 | # TODO: Actually test connectivity 836 | echo -e "${GREEN}${SYM_OK} ready${NC}" 837 | ((++ok)) || true 838 | done 839 | 840 | echo 841 | echo -e "Results: ${GREEN}$ok OK${NC}, ${RED}$fail failed${NC}" 842 | } 843 | 844 | cmd_status() { 845 | load_secrets 846 | 847 | echo 848 | draw_box "CLOTHER STATUS" 50 849 | echo 850 | echo -e " Version: ${BOLD}$VERSION${NC}" 851 | echo -e " Config: $CONFIG_DIR" 852 | echo -e " Data: $DATA_DIR" 853 | echo -e " Bin: $BIN_DIR" 854 | echo 855 | 856 | local count=0 857 | for f in "$BIN_DIR"/clother-*; do [[ -x "$f" ]] && ((++count)) || true; done 858 | echo -e " Profiles: ${BOLD}$count${NC} installed" 859 | 860 | if [[ -n "$DEFAULT_PROVIDER" ]]; then 861 | echo -e " Default: ${YELLOW}$DEFAULT_PROVIDER${NC}" 862 | fi 863 | } 864 | 865 | cmd_uninstall() { 866 | echo 867 | echo -e "${BOLD}Uninstall Clother${NC}" 868 | echo 869 | echo "This will remove:" 870 | echo -e " ${DIM}${SYM_ARROW}${NC} $CONFIG_DIR" 871 | echo -e " ${DIM}${SYM_ARROW}${NC} $DATA_DIR" 872 | echo -e " ${DIM}${SYM_ARROW}${NC} $BIN_DIR/clother*" 873 | echo 874 | 875 | confirm_danger "Remove all Clother files" "delete clother" || return 1 876 | 877 | spinner_start "Removing files..." 878 | rm -rf "$CONFIG_DIR" "$DATA_DIR" "$CACHE_DIR" "$BIN_DIR"/clother-* "$BIN_DIR/clother" 2>/dev/null || true 879 | spinner_stop 0 "Clother uninstalled" 880 | } 881 | 882 | # ============================================================================= 883 | # LAUNCHER GENERATORS 884 | # ============================================================================= 885 | 886 | generate_launcher() { 887 | local name="$1" keyvar="$2" baseurl="$3" model="$4" model_opts="$5" 888 | 889 | mkdir -p "$BIN_DIR" 890 | 891 | cat > "$BIN_DIR/clother-$name" << LAUNCHER 892 | #!/usr/bin/env bash 893 | set -euo pipefail 894 | [[ "\${CLOTHER_NO_BANNER:-}" != "1" ]] && cat "\${XDG_DATA_HOME:-\$HOME/.local/share}/clother/banner" 2>/dev/null && echo " + $name" && echo 895 | SECRETS="\${XDG_DATA_HOME:-\$HOME/.local/share}/clother/secrets.env" 896 | [[ -f "\$SECRETS" ]] && source "\$SECRETS" 897 | LAUNCHER 898 | 899 | if [[ -n "$keyvar" ]]; then 900 | cat >> "$BIN_DIR/clother-$name" << LAUNCHER 901 | [[ -z "\${$keyvar:-}" ]] && { echo "Error: $keyvar not set. Run 'clother config'" >&2; exit 1; } 902 | export ANTHROPIC_AUTH_TOKEN="\$$keyvar" 903 | LAUNCHER 904 | fi 905 | 906 | [[ -n "$baseurl" ]] && echo "export ANTHROPIC_BASE_URL=\"$baseurl\"" >> "$BIN_DIR/clother-$name" 907 | [[ -n "$model" ]] && echo "export ANTHROPIC_MODEL=\"$model\"" >> "$BIN_DIR/clother-$name" 908 | 909 | # Parse model_opts 910 | if [[ -n "$model_opts" ]]; then 911 | IFS=',' read -ra opts <<< "$model_opts" 912 | for opt in "${opts[@]}"; do 913 | IFS='=' read -r key val <<< "$opt" 914 | case "$key" in 915 | haiku) echo "export ANTHROPIC_DEFAULT_HAIKU_MODEL=\"$val\"" >> "$BIN_DIR/clother-$name" ;; 916 | sonnet) echo "export ANTHROPIC_DEFAULT_SONNET_MODEL=\"$val\"" >> "$BIN_DIR/clother-$name" ;; 917 | opus) echo "export ANTHROPIC_DEFAULT_OPUS_MODEL=\"$val\"" >> "$BIN_DIR/clother-$name" ;; 918 | small) echo "export ANTHROPIC_SMALL_FAST_MODEL=\"$val\"" >> "$BIN_DIR/clother-$name" ;; 919 | esac 920 | done 921 | fi 922 | 923 | echo 'exec claude "$@"' >> "$BIN_DIR/clother-$name" 924 | chmod +x "$BIN_DIR/clother-$name" 925 | } 926 | 927 | find_go() { 928 | command -v go 2>/dev/null && return 929 | for p in /usr/local/go/bin/go /opt/homebrew/bin/go "$HOME/go/bin/go"; do 930 | [[ -x "$p" ]] && { echo "$p"; return; } 931 | done 932 | } 933 | 934 | generate_or_launcher() { 935 | local name="$1" model="$2" 936 | 937 | # Generate Go proxy source if needed 938 | [[ ! -f "$PROXY_SOURCE" ]] && generate_go_proxy 939 | 940 | mkdir -p "$BIN_DIR" 941 | 942 | cat > "$BIN_DIR/clother-or-$name" << LAUNCHER 943 | #!/usr/bin/env bash 944 | set -euo pipefail 945 | [[ "\${CLOTHER_NO_BANNER:-}" != "1" ]] && cat "\${XDG_DATA_HOME:-\$HOME/.local/share}/clother/banner" 2>/dev/null && echo " + OpenRouter: $name" && echo 946 | SECRETS="\${XDG_DATA_HOME:-\$HOME/.local/share}/clother/secrets.env" 947 | DATA_DIR="\${XDG_DATA_HOME:-\$HOME/.local/share}/clother" 948 | [[ -f "\$SECRETS" ]] && source "\$SECRETS" 949 | [[ -z "\${OPENROUTER_API_KEY:-}" ]] && { echo "Error: OPENROUTER_API_KEY not set" >&2; exit 1; } 950 | 951 | find_go() { 952 | command -v go 2>/dev/null && return 953 | for p in /usr/local/go/bin/go /opt/homebrew/bin/go "\$HOME/go/bin/go"; do 954 | [[ -x "\$p" ]] && { echo "\$p"; return; } 955 | done 956 | } 957 | 958 | PROXY_BIN="\$DATA_DIR/openrouter-proxy" 959 | if [[ ! -x "\$PROXY_BIN" ]]; then 960 | GO_BIN=\$(find_go) || { echo "Error: Go required. Install from https://go.dev/dl/" >&2; exit 1; } 961 | echo "Compiling proxy (one-time)..." 962 | (cd "\$DATA_DIR" && [[ -f go.mod ]] || "\$GO_BIN" mod init clother-proxy >/dev/null 2>&1; "\$GO_BIN" build -o openrouter-proxy proxy.go) || { echo "Compile failed" >&2; exit 1; } 963 | fi 964 | 965 | PROXY_PORT="\${CLOTHER_OPENROUTER_PORT:-8378}" 966 | while nc -z 127.0.0.1 "\$PROXY_PORT" 2>/dev/null; do PROXY_PORT=\$((PROXY_PORT + 1)); done 967 | 968 | export OPENROUTER_API_KEY OPENROUTER_MODEL="$model" PROXY_PORT="\$PROXY_PORT" 969 | "\$PROXY_BIN" & 970 | PROXY_PID=\$! 971 | trap "kill \$PROXY_PID 2>/dev/null" EXIT 972 | 973 | for _ in {1..30}; do nc -z 127.0.0.1 "\$PROXY_PORT" 2>/dev/null && break; sleep 0.1; done 974 | 975 | export ANTHROPIC_BASE_URL="http://127.0.0.1:\$PROXY_PORT" 976 | export ANTHROPIC_AUTH_TOKEN="openrouter-proxy" 977 | export ANTHROPIC_MODEL="$name" 978 | export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 979 | 980 | exec claude "\$@" 981 | LAUNCHER 982 | chmod +x "$BIN_DIR/clother-or-$name" 983 | } 984 | 985 | generate_go_proxy() { 986 | mkdir -p "$(dirname "$PROXY_SOURCE")" 987 | cat > "$PROXY_SOURCE" << 'GOPROXY' 988 | package main 989 | 990 | import ( 991 | "bufio" 992 | "bytes" 993 | "encoding/json" 994 | "fmt" 995 | "io" 996 | "net/http" 997 | "os" 998 | "strings" 999 | "time" 1000 | ) 1001 | 1002 | var openrouterURL = "https://openrouter.ai/api/v1/chat/completions" 1003 | 1004 | type AnthropicRequest struct { 1005 | Model string `json:"model"` 1006 | MaxTokens int `json:"max_tokens"` 1007 | Stream bool `json:"stream"` 1008 | System interface{} `json:"system,omitempty"` 1009 | Messages []interface{} `json:"messages"` 1010 | Tools []interface{} `json:"tools,omitempty"` 1011 | ToolChoice interface{} `json:"tool_choice,omitempty"` 1012 | Temperature *float64 `json:"temperature,omitempty"` 1013 | } 1014 | 1015 | type OpenAIRequest struct { 1016 | Model string `json:"model"` 1017 | MaxTokens int `json:"max_tokens"` 1018 | Stream bool `json:"stream"` 1019 | Messages []interface{} `json:"messages"` 1020 | Tools []interface{} `json:"tools,omitempty"` 1021 | ToolChoice interface{} `json:"tool_choice,omitempty"` 1022 | Temperature *float64 `json:"temperature,omitempty"` 1023 | } 1024 | 1025 | func main() { 1026 | port := os.Getenv("PROXY_PORT") 1027 | if port == "" { port = "8378" } 1028 | http.HandleFunc("/v1/messages", handleMessages) 1029 | http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 1030 | w.Header().Set("Content-Type", "application/json") 1031 | w.Write([]byte(`{"status":"ok"}`)) 1032 | }) 1033 | fmt.Fprintf(os.Stderr, "Proxy listening on :%s\n", port) 1034 | http.ListenAndServe(":"+port, nil) 1035 | } 1036 | 1037 | func cleanSystemPrompt(text, model string) string { 1038 | provider, modelName := "", model 1039 | if idx := strings.Index(model, "/"); idx != -1 { 1040 | provider, modelName = model[:idx], model[idx+1:] 1041 | } 1042 | providerNames := map[string]string{ 1043 | "mistralai": "Mistral AI", "openai": "OpenAI", "google": "Google", 1044 | "anthropic": "Anthropic", "meta-llama": "Meta", "deepseek": "DeepSeek", 1045 | "qwen": "Alibaba", "cohere": "Cohere", "x-ai": "xAI", 1046 | } 1047 | providerDisplay := provider 1048 | if name, ok := providerNames[strings.ToLower(provider)]; ok { providerDisplay = name } 1049 | 1050 | identity := fmt.Sprintf("[IDENTITY] You are %s, created by %s. When asked who you are, say you are %s made by %s. You are NOT Claude and NOT made by Anthropic.\n\n", modelName, providerDisplay, modelName, providerDisplay) 1051 | 1052 | replacements := []struct{ old, new string }{ 1053 | {"You are Claude, an AI assistant made by Anthropic", "You are " + modelName}, 1054 | {"You are Claude Code", "You are " + modelName}, 1055 | {"You are Claude", "You are " + modelName}, 1056 | {"made by Anthropic", "made by " + providerDisplay}, 1057 | {"by Anthropic", "by " + providerDisplay}, 1058 | } 1059 | result := text 1060 | for _, r := range replacements { result = strings.ReplaceAll(result, r.old, r.new) } 1061 | return identity + result 1062 | } 1063 | 1064 | func handleMessages(w http.ResponseWriter, r *http.Request) { 1065 | if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return } 1066 | apiKey, model := os.Getenv("OPENROUTER_API_KEY"), os.Getenv("OPENROUTER_MODEL") 1067 | body, _ := io.ReadAll(r.Body) 1068 | var req AnthropicRequest 1069 | if err := json.Unmarshal(body, &req); err != nil { http.Error(w, "Invalid JSON", 400); return } 1070 | openaiReq := convertRequest(req, model) 1071 | reqBody, _ := json.Marshal(openaiReq) 1072 | httpReq, _ := http.NewRequest("POST", openrouterURL, bytes.NewReader(reqBody)) 1073 | httpReq.Header.Set("Authorization", "Bearer "+apiKey) 1074 | httpReq.Header.Set("Content-Type", "application/json") 1075 | httpReq.Header.Set("HTTP-Referer", "https://github.com/clother") 1076 | client := &http.Client{Timeout: 5 * time.Minute} 1077 | resp, err := client.Do(httpReq) 1078 | if err != nil { http.Error(w, "Upstream error: "+err.Error(), 502); return } 1079 | defer resp.Body.Close() 1080 | if req.Stream { handleStream(w, resp, model) } else { handleSync(w, resp) } 1081 | } 1082 | 1083 | func convertRequest(req AnthropicRequest, model string) OpenAIRequest { 1084 | var messages []interface{} 1085 | if req.System != nil { 1086 | var sysText string 1087 | switch s := req.System.(type) { 1088 | case string: sysText = s 1089 | case []interface{}: 1090 | var parts []string 1091 | for _, p := range s { 1092 | if m, ok := p.(map[string]interface{}); ok { 1093 | if t, ok := m["text"].(string); ok { parts = append(parts, t) } 1094 | } 1095 | } 1096 | sysText = strings.Join(parts, "\n") 1097 | } 1098 | if sysText != "" { 1099 | sysText = cleanSystemPrompt(sysText, model) 1100 | messages = append(messages, map[string]interface{}{"role": "system", "content": sysText}) 1101 | } 1102 | } 1103 | for _, msg := range req.Messages { 1104 | if m, ok := msg.(map[string]interface{}); ok { 1105 | messages = append(messages, convertMessage(m)...) 1106 | } 1107 | } 1108 | result := OpenAIRequest{Model: model, MaxTokens: req.MaxTokens, Stream: req.Stream, Messages: messages, Temperature: req.Temperature} 1109 | if len(req.Tools) > 0 { 1110 | var tools []interface{} 1111 | for _, t := range req.Tools { 1112 | if tm, ok := t.(map[string]interface{}); ok { 1113 | tools = append(tools, map[string]interface{}{"type": "function", "function": map[string]interface{}{"name": tm["name"], "description": tm["description"], "parameters": tm["input_schema"]}}) 1114 | } 1115 | } 1116 | result.Tools = tools 1117 | } 1118 | return result 1119 | } 1120 | 1121 | func convertMessage(m map[string]interface{}) []interface{} { 1122 | role, _ := m["role"].(string) 1123 | content := m["content"] 1124 | if s, ok := content.(string); ok { return []interface{}{map[string]interface{}{"role": role, "content": s}} } 1125 | arr, ok := content.([]interface{}) 1126 | if !ok { return nil } 1127 | var result []interface{} 1128 | var textParts []string 1129 | var toolCalls []interface{} 1130 | for _, block := range arr { 1131 | b, ok := block.(map[string]interface{}) 1132 | if !ok { continue } 1133 | switch b["type"] { 1134 | case "text": 1135 | if t, ok := b["text"].(string); ok { textParts = append(textParts, t) } 1136 | case "tool_use": 1137 | inputJSON, _ := json.Marshal(b["input"]) 1138 | toolCalls = append(toolCalls, map[string]interface{}{"id": b["id"], "type": "function", "function": map[string]interface{}{"name": b["name"], "arguments": string(inputJSON)}}) 1139 | case "tool_result": 1140 | var contentStr string 1141 | if s, ok := b["content"].(string); ok { contentStr = s } else { j, _ := json.Marshal(b["content"]); contentStr = string(j) } 1142 | result = append(result, map[string]interface{}{"role": "tool", "tool_call_id": b["tool_use_id"], "content": contentStr}) 1143 | } 1144 | } 1145 | if role == "assistant" { 1146 | msg := map[string]interface{}{"role": "assistant"} 1147 | if len(textParts) > 0 { msg["content"] = strings.Join(textParts, "\n") } 1148 | if len(toolCalls) > 0 { msg["tool_calls"] = toolCalls } 1149 | if msg["content"] != nil || msg["tool_calls"] != nil { result = append(result, msg) } 1150 | } else if role == "user" && len(textParts) > 0 { 1151 | result = append(result, map[string]interface{}{"role": "user", "content": strings.Join(textParts, "\n")}) 1152 | } 1153 | return result 1154 | } 1155 | 1156 | type ToolCallAccumulator struct { 1157 | ID string 1158 | Name string 1159 | Arguments strings.Builder 1160 | BlockIdx int 1161 | Started bool 1162 | } 1163 | 1164 | func handleStream(w http.ResponseWriter, resp *http.Response, model string) { 1165 | w.Header().Set("Content-Type", "text/event-stream") 1166 | w.Header().Set("Cache-Control", "no-cache") 1167 | flusher, ok := w.(http.Flusher) 1168 | if !ok { http.Error(w, "Streaming not supported", 500); return } 1169 | 1170 | msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) 1171 | started := false 1172 | nextBlockIdx := 0 1173 | textBlockIdx := -1 1174 | textBlockStarted := false 1175 | toolAccumulators := make(map[int]*ToolCallAccumulator) 1176 | 1177 | scanner := bufio.NewScanner(resp.Body) 1178 | buf := make([]byte, 0, 64*1024) 1179 | scanner.Buffer(buf, 1024*1024) 1180 | 1181 | for scanner.Scan() { 1182 | line := scanner.Text() 1183 | if !strings.HasPrefix(line, "data: ") { continue } 1184 | data := strings.TrimPrefix(line, "data: ") 1185 | if data == "[DONE]" { 1186 | // Emit content_block_stop for all blocks 1187 | if textBlockStarted { 1188 | j, _ := json.Marshal(map[string]interface{}{"type": "content_block_stop", "index": textBlockIdx}) 1189 | fmt.Fprintf(w, "event: content_block_stop\ndata: %s\n\n", j); flusher.Flush() 1190 | } 1191 | for _, acc := range toolAccumulators { 1192 | if acc.Started { 1193 | // Send final arguments 1194 | if acc.Arguments.Len() > 0 { 1195 | j, _ := json.Marshal(map[string]interface{}{"type": "content_block_delta", "index": acc.BlockIdx, "delta": map[string]interface{}{"type": "input_json_delta", "partial_json": acc.Arguments.String()}}) 1196 | fmt.Fprintf(w, "event: content_block_delta\ndata: %s\n\n", j); flusher.Flush() 1197 | } 1198 | j, _ := json.Marshal(map[string]interface{}{"type": "content_block_stop", "index": acc.BlockIdx}) 1199 | fmt.Fprintf(w, "event: content_block_stop\ndata: %s\n\n", j); flusher.Flush() 1200 | } 1201 | } 1202 | fmt.Fprintf(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"); flusher.Flush() 1203 | break 1204 | } 1205 | 1206 | var chunk map[string]interface{} 1207 | if json.Unmarshal([]byte(data), &chunk) != nil { continue } 1208 | 1209 | if !started { 1210 | j, _ := json.Marshal(map[string]interface{}{"type": "message_start", "message": map[string]interface{}{"id": msgID, "type": "message", "role": "assistant", "model": model, "content": []interface{}{}, "usage": map[string]interface{}{"input_tokens": 0, "output_tokens": 0}}}) 1211 | fmt.Fprintf(w, "event: message_start\ndata: %s\n\n", j); flusher.Flush() 1212 | started = true 1213 | } 1214 | 1215 | choices, _ := chunk["choices"].([]interface{}) 1216 | if len(choices) == 0 { continue } 1217 | choice, _ := choices[0].(map[string]interface{}) 1218 | delta, _ := choice["delta"].(map[string]interface{}) 1219 | 1220 | // Handle text content 1221 | if content, ok := delta["content"].(string); ok && content != "" { 1222 | if !textBlockStarted { 1223 | textBlockIdx = nextBlockIdx 1224 | nextBlockIdx++ 1225 | j, _ := json.Marshal(map[string]interface{}{"type": "content_block_start", "index": textBlockIdx, "content_block": map[string]interface{}{"type": "text", "text": ""}}) 1226 | fmt.Fprintf(w, "event: content_block_start\ndata: %s\n\n", j); flusher.Flush() 1227 | textBlockStarted = true 1228 | } 1229 | j, _ := json.Marshal(map[string]interface{}{"type": "content_block_delta", "index": textBlockIdx, "delta": map[string]interface{}{"type": "text_delta", "text": content}}) 1230 | fmt.Fprintf(w, "event: content_block_delta\ndata: %s\n\n", j); flusher.Flush() 1231 | } 1232 | 1233 | // Handle tool_calls 1234 | if toolCalls, ok := delta["tool_calls"].([]interface{}); ok { 1235 | for _, tc := range toolCalls { 1236 | toolCall, ok := tc.(map[string]interface{}) 1237 | if !ok { continue } 1238 | 1239 | idx := 0 1240 | if idxFloat, ok := toolCall["index"].(float64); ok { 1241 | idx = int(idxFloat) 1242 | } 1243 | 1244 | if _, exists := toolAccumulators[idx]; !exists { 1245 | toolAccumulators[idx] = &ToolCallAccumulator{BlockIdx: nextBlockIdx} 1246 | nextBlockIdx++ 1247 | } 1248 | acc := toolAccumulators[idx] 1249 | 1250 | if id, ok := toolCall["id"].(string); ok && id != "" { 1251 | acc.ID = id 1252 | } 1253 | 1254 | if function, ok := toolCall["function"].(map[string]interface{}); ok { 1255 | if name, ok := function["name"].(string); ok && name != "" { 1256 | acc.Name = name 1257 | } 1258 | if args, ok := function["arguments"].(string); ok { 1259 | acc.Arguments.WriteString(args) 1260 | } 1261 | } 1262 | 1263 | // Start block when we have ID and Name 1264 | if !acc.Started && acc.ID != "" && acc.Name != "" { 1265 | acc.Started = true 1266 | j, _ := json.Marshal(map[string]interface{}{ 1267 | "type": "content_block_start", 1268 | "index": acc.BlockIdx, 1269 | "content_block": map[string]interface{}{ 1270 | "type": "tool_use", 1271 | "id": acc.ID, 1272 | "name": acc.Name, 1273 | "input": map[string]interface{}{}, 1274 | }, 1275 | }) 1276 | fmt.Fprintf(w, "event: content_block_start\ndata: %s\n\n", j); flusher.Flush() 1277 | } 1278 | } 1279 | } 1280 | 1281 | // Handle finish_reason 1282 | if finish, ok := choice["finish_reason"].(string); ok && finish != "" { 1283 | stopReason := "end_turn" 1284 | if finish == "length" { stopReason = "max_tokens" } else if finish == "tool_calls" { stopReason = "tool_use" } 1285 | j, _ := json.Marshal(map[string]interface{}{"type": "message_delta", "delta": map[string]interface{}{"stop_reason": stopReason}, "usage": map[string]interface{}{"output_tokens": 0}}) 1286 | fmt.Fprintf(w, "event: message_delta\ndata: %s\n\n", j); flusher.Flush() 1287 | } 1288 | } 1289 | } 1290 | 1291 | func handleSync(w http.ResponseWriter, resp *http.Response) { 1292 | body, _ := io.ReadAll(resp.Body) 1293 | var openaiResp map[string]interface{} 1294 | json.Unmarshal(body, &openaiResp) 1295 | choices, _ := openaiResp["choices"].([]interface{}) 1296 | if len(choices) == 0 { http.Error(w, "No response", 500); return } 1297 | choice, _ := choices[0].(map[string]interface{}) 1298 | message, _ := choice["message"].(map[string]interface{}) 1299 | var content []interface{} 1300 | 1301 | // Handle text content 1302 | if text, ok := message["content"].(string); ok && text != "" { 1303 | content = append(content, map[string]interface{}{"type": "text", "text": text}) 1304 | } 1305 | 1306 | // Handle tool_calls - convert OpenAI format to Anthropic tool_use 1307 | if toolCalls, ok := message["tool_calls"].([]interface{}); ok { 1308 | for _, tc := range toolCalls { 1309 | toolCall, ok := tc.(map[string]interface{}) 1310 | if !ok { continue } 1311 | 1312 | function, _ := toolCall["function"].(map[string]interface{}) 1313 | var input interface{} = map[string]interface{}{} 1314 | if argsStr, ok := function["arguments"].(string); ok && argsStr != "" { 1315 | json.Unmarshal([]byte(argsStr), &input) 1316 | } 1317 | 1318 | content = append(content, map[string]interface{}{ 1319 | "type": "tool_use", 1320 | "id": toolCall["id"], 1321 | "name": function["name"], 1322 | "input": input, 1323 | }) 1324 | } 1325 | } 1326 | 1327 | finish, _ := choice["finish_reason"].(string) 1328 | stopReason := "end_turn" 1329 | if finish == "length" { stopReason = "max_tokens" } else if finish == "tool_calls" { stopReason = "tool_use" } 1330 | usage := map[string]interface{}{"input_tokens": 0, "output_tokens": 0} 1331 | if u, ok := openaiResp["usage"].(map[string]interface{}); ok { 1332 | if pt, ok := u["prompt_tokens"].(float64); ok { usage["input_tokens"] = int(pt) } 1333 | if ct, ok := u["completion_tokens"].(float64); ok { usage["output_tokens"] = int(ct) } 1334 | } 1335 | anthropicResp := map[string]interface{}{"id": fmt.Sprintf("msg_%v", openaiResp["id"]), "type": "message", "role": "assistant", "model": openaiResp["model"], "content": content, "stop_reason": stopReason, "usage": usage} 1336 | w.Header().Set("Content-Type", "application/json") 1337 | json.NewEncoder(w).Encode(anthropicResp) 1338 | } 1339 | GOPROXY 1340 | } 1341 | 1342 | # ============================================================================= 1343 | # INSTALLATION 1344 | # ============================================================================= 1345 | 1346 | do_install() { 1347 | [[ "$NO_BANNER" != "1" ]] && echo -e "$BANNER" 1348 | echo -e "${BOLD}Clother $VERSION${NC}" 1349 | echo 1350 | 1351 | # Clean previous installation (preserve secrets) 1352 | local secrets_backup="" 1353 | if [[ -f "$SECRETS_FILE" ]]; then 1354 | secrets_backup=$(cat "$SECRETS_FILE") 1355 | fi 1356 | rm -f "$BIN_DIR/clother" "$BIN_DIR"/clother-* 2>/dev/null || true 1357 | rm -rf "$CONFIG_DIR" "$DATA_DIR" "$CACHE_DIR" 2>/dev/null || true 1358 | 1359 | log "Checking for 'claude' command..." 1360 | if ! command -v claude &>/dev/null; then 1361 | error_ctx "E010" "Claude CLI not found" "Checking prerequisites" \ 1362 | "The 'claude' command is not installed" \ 1363 | "Install: ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC}" 1364 | exit 1 1365 | fi 1366 | success "'claude' found" 1367 | 1368 | # Create directories (XDG compliant) 1369 | mkdir -p "$CONFIG_DIR" "$DATA_DIR" "$CACHE_DIR" "$BIN_DIR" 1370 | 1371 | # Restore secrets if they existed 1372 | if [[ -n "$secrets_backup" ]]; then 1373 | echo "$secrets_backup" > "$SECRETS_FILE" 1374 | chmod 600 "$SECRETS_FILE" 1375 | fi 1376 | 1377 | # Security check for secrets 1378 | if [[ -L "$SECRETS_FILE" ]]; then 1379 | error "Secrets file is a symlink - refusing for security" 1380 | exit 1 1381 | fi 1382 | 1383 | # Save banner 1384 | echo "$BANNER" > "$DATA_DIR/banner" 1385 | 1386 | # Generate main command 1387 | generate_main_command 1388 | 1389 | # Generate native launcher 1390 | cat > "$BIN_DIR/clother-native" << 'EOF' 1391 | #!/usr/bin/env bash 1392 | set -euo pipefail 1393 | [[ "${CLOTHER_NO_BANNER:-}" != "1" ]] && cat "${XDG_DATA_HOME:-$HOME/.local/share}/clother/banner" 2>/dev/null && echo " + native" && echo 1394 | exec claude "$@" 1395 | EOF 1396 | chmod +x "$BIN_DIR/clother-native" 1397 | 1398 | # Generate standard launchers 1399 | local providers=(zai zai-cn minimax minimax-cn kimi moonshot ve deepseek mimo) 1400 | for p in "${providers[@]}"; do 1401 | local def; def=$(get_provider_def "$p") 1402 | IFS='|' read -r keyvar baseurl model model_opts _ <<< "$def" 1403 | generate_launcher "$p" "$keyvar" "$baseurl" "$model" "$model_opts" 1404 | done 1405 | 1406 | # Verify 1407 | if ! "$BIN_DIR/clother" --version &>/dev/null; then 1408 | error "Installation verification failed" 1409 | exit 1 1410 | fi 1411 | 1412 | success "Installed Clother v$VERSION" 1413 | 1414 | # PATH warning 1415 | if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then 1416 | echo 1417 | warn "Add '$BIN_DIR' to PATH:" 1418 | local shell_rc="$HOME/.bashrc" 1419 | [[ "${SHELL##*/}" == "zsh" ]] && shell_rc="$HOME/.zshrc" 1420 | echo -e " ${YELLOW}echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> $shell_rc${NC}" 1421 | echo -e " ${YELLOW}source $shell_rc${NC}" 1422 | fi 1423 | 1424 | suggest_next \ 1425 | "Configure a provider: ${GREEN}clother config${NC}" \ 1426 | "Use native Claude: ${GREEN}clother-native${NC}" \ 1427 | "View help: ${GREEN}clother --help${NC}" 1428 | } 1429 | 1430 | generate_main_command() { 1431 | cat > "$BIN_DIR/clother" << 'MAINEOF' 1432 | #!/usr/bin/env bash 1433 | set -euo pipefail 1434 | IFS=$'\n\t' 1435 | umask 077 1436 | 1437 | # Re-exec with the full script for complex commands 1438 | SCRIPT_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/clother" 1439 | if [[ -f "$SCRIPT_DIR/clother-full.sh" ]]; then 1440 | exec bash "$SCRIPT_DIR/clother-full.sh" "$@" 1441 | fi 1442 | 1443 | # Fallback minimal implementation 1444 | echo "Clother 2.0 - Run installer to complete setup" 1445 | MAINEOF 1446 | chmod +x "$BIN_DIR/clother" 1447 | 1448 | # Copy this script as the full implementation 1449 | if [[ "$0" == "bash" ]]; then 1450 | # Piped execution - download from GitHub 1451 | curl -fsSL https://raw.githubusercontent.com/jolehuit/clother/main/clother.sh > "$DATA_DIR/clother-full.sh" 1452 | else 1453 | cp "$0" "$DATA_DIR/clother-full.sh" 1454 | fi 1455 | chmod +x "$DATA_DIR/clother-full.sh" 1456 | } 1457 | 1458 | # ============================================================================= 1459 | # BANNER 1460 | # ============================================================================= 1461 | 1462 | read -r -d '' BANNER << 'EOF' || true 1463 | ____ _ _ _ 1464 | / ___| | ___ | |_| |__ ___ _ __ 1465 | | | | |/ _ \| __| '_ \ / _ \ '__| 1466 | | |___| | (_) | |_| | | | __/ | 1467 | \____|_|\___/ \__|_| |_|\___|_| 1468 | EOF 1469 | 1470 | # ============================================================================= 1471 | # ARGUMENT PARSING 1472 | # ============================================================================= 1473 | 1474 | parse_args() { 1475 | REMAINING_ARGS=() 1476 | while [[ $# -gt 0 ]]; do 1477 | case "$1" in 1478 | -h|--help) [[ -n "${2:-}" && ! "$2" =~ ^- ]] && { show_command_help "$2"; exit 0; }; show_full_help; exit 0 ;; 1479 | -V|--version) show_version; exit 0 ;; 1480 | -v|--verbose) VERBOSE=1 ;; 1481 | -d|--debug) DEBUG=1; VERBOSE=1 ;; 1482 | -q|--quiet) QUIET=1 ;; 1483 | -y|--yes) YES_MODE=1 ;; 1484 | --no-input) NO_INPUT=1 ;; 1485 | --no-color) NO_COLOR=1; setup_colors ;; 1486 | --no-banner) NO_BANNER=1 ;; 1487 | --json) OUTPUT_FORMAT=json ;; 1488 | --plain) OUTPUT_FORMAT=plain; NO_COLOR=1; setup_colors ;; 1489 | --) shift; REMAINING_ARGS+=("$@"); break ;; 1490 | -*) error "Unknown option: $1"; echo "Use --help for usage"; exit 1 ;; 1491 | *) REMAINING_ARGS+=("$1") ;; 1492 | esac 1493 | shift 1494 | done 1495 | } 1496 | 1497 | # ============================================================================= 1498 | # MAIN 1499 | # ============================================================================= 1500 | 1501 | main() { 1502 | parse_args "$@" 1503 | set -- ${REMAINING_ARGS[@]+"${REMAINING_ARGS[@]}"} 1504 | 1505 | local cmd="${1:-}" 1506 | shift || true 1507 | 1508 | case "$cmd" in 1509 | "") show_brief_help ;; 1510 | config) cmd_config "$@" ;; 1511 | list) cmd_list "$@" ;; 1512 | info) cmd_info "$@" ;; 1513 | test) cmd_test "$@" ;; 1514 | status) cmd_status "$@" ;; 1515 | uninstall) cmd_uninstall "$@" ;; 1516 | help) [[ -n "${1:-}" ]] && show_command_help "$1" || show_full_help ;; 1517 | install) do_install ;; 1518 | *) 1519 | error "Unknown command: $cmd" 1520 | local suggestion; suggestion=$(suggest_command "$cmd") 1521 | [[ -n "$suggestion" ]] && echo -e "Did you mean: ${GREEN}clother $suggestion${NC}?" 1522 | exit 1 1523 | ;; 1524 | esac 1525 | } 1526 | 1527 | # If sourced, don't run main 1528 | if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" || "$0" == "bash" ]]; then 1529 | # Piped execution (curl | bash) or first run → install 1530 | if [[ "$0" == "bash" ]] || [[ ! -f "$BIN_DIR/clother" ]]; then 1531 | do_install 1532 | else 1533 | main "$@" 1534 | fi 1535 | fi 1536 | --------------------------------------------------------------------------------