commit 8d2dd68f3648911b5173f01fc2f952125ffec3b3 Author: Amolith Date: Tue Nov 18 12:14:59 2025 -0700 initial commit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..1859d7f6310c7ca2e6a83f5369d7300ebd31d8a8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,125 @@ +# AGENTS.md + +## Project Overview + +**synclaude.fish** is a Fish shell wrapper for the `claude` CLI that routes requests through [Synthetic.new](https://synthetic.new)'s API. It enables using alternative AI models (like GLM-4.6, Llama, Qwen) through the Claude CLI by proxying requests through Synthetic's Anthropic-compatible endpoint. + +### Key Functionality + +- Defaults all Claude model tiers (Opus, Sonnet, Haiku, Sub-agent) to `hf:zai-org/GLM-4.6` +- Provides interactive TUI for model selection using `gum` +- Supports group-based (`--large`/`--light`) and individual model overrides +- Displays session API usage and remaining quota after each run +- Wraps `claude` CLI completely - all unknown flags pass through + +## Project Structure + +This is a standard Fish plugin with the following structure: + +``` +synclaude.fish/ +├── README.md # User documentation +├── functions/ +│ └── synclaude.fish # Main wrapper function (~156 lines) +└── completions/ + └── synclaude.fish # Tab completions (~15 lines) +``` + +### Architecture + +**Control Flow:** + +1. **Interactive mode** (`synclaude i`): +- Fetches available models from Synthetic API +- Prompts user to choose Groups vs Individual models +- Prompts for which groups/models to override +- For each selection, filters available models with `gum filter` +- Recursively calls `synclaude` with generated flags (lines 83-84) +2. **Flag-based mode** (default): +- Parses custom flags with `argparse --ignore-unknown` (lines 91-94) +- Sets environment variables for Anthropic endpoint and model overrides +- Fetches initial quota from Synthetic API +- Executes `command claude $argv` (line 128) +- Fetches final quota and displays usage stats with color-coding + +**Key Design Patterns:** + +- **Override precedence**: Individual flags override group flags, which override defaults (lines 107-121) +- **Error propagation**: Uses `or return` throughout to exit on command failures + +## Code Conventions + +### Fish Shell Patterns + +- **Local variables**: Always use `set -l` for function-local scope +- **Export variables**: Use `set -lx` for locally-scoped exports (lines 96-104) +- **Variable checking**: Use `set -q variable` to test existence (lines 107-121) +- **Array removal**: Use `set -e argv[1]` to remove elements (line 6) +- **Command substitution**: Use `(command)` not backticks +- **Error handling**: Chain commands with `or return` for early exits +- **Math operations**: Use `math` builtin with `-s0` for integer results (line 139) + +### Code Style + +- **Indentation**: 4 spaces (not tabs) +- **Line length**: No hard limit, but keep logical blocks readable +- **Variable naming**: + - Descriptive names: `models_json`, `initial_requests`, `quota_color` + - No abbreviations except common ones: `args` → `argv`, `arg` → `argv` +- **Comments**: + - Group logic blocks with descriptive comments (lines 88-90) + - Inline comments for non-obvious conditions (lines 125, 132) + - No redundant comments explaining obvious code + +### API Interaction Patterns + +All API calls wrapped in `gum spin` for UX: + +```fish +set -l response (gum spin --spinner dot --title "Message..." -- curl -s -H "Authorization: Bearer $KEY" https://api.url) +``` + +API responses always: +1. Use `jq -r` for raw (unquoted) output +2. Provide fallback values with `// 0` or similar (lines 125, 132-133) +3. Test for empty values after extraction (line 126) +4. Redirect stderr to `/dev/null` if errors are expected (lines 124, 131) + +### Error Handling + +- **API failures**: Use `or return 1` to exit on curl/jq errors +- **Null safety**: Always test extracted values before use (line 126) +- **Propagate exit codes**: Return `$status` from wrapped command (line 84) +- **Silent failures**: Only suppress stderr when errors are expected and handled + +## Completions + +File: `completions/synclaude.fish` + +- **Inherits from wrapped command**: `complete -c synclaude -w claude` (line 2) +- **Subcommand prevention**: Use `-n "not __fish_seen_subcommand_from i"` to prevent completions after `i` (line 5) +- **Flag completions**: `-s` (short), `-l` (long), `-r` (requires argument), `-d` (description) + +## Quota Display Logic + +Post-execution, displays: +- **Session usage**: `final_requests - initial_requests` +- **Remaining quota**: `limit - final_requests` (color-coded) + +Color coding (lines 141-146): +- **Green**: <33% used +- **Yellow**: 33-66% used +- **Red**: >66% used + +Color logic uses `set_color` and `set_color normal` to wrap output (lines 151-153). + +## Common Gotchas + +1. **`argparse --ignore-unknown` is critical**: Without it, unknown flags would error instead of passing through to `claude` (line 91) +2. **Recursive call must use function name, not alias**: Line 83 calls `synclaude`, not `command synclaude` - this works because Fish functions can recursively invoke themselves +3. **`_flag_*` variable naming**: `argparse` creates variables from flag names with `_flag_` prefix - must use exact names (lines 107-121) +4. **Model ID vs Model Name**: Interactive mode fetches model names for display but must extract model IDs for API use (lines 30, 37, 53, 60, 67, 74) +5. **jq requires argument quoting**: In jq, use `--arg name "$var"` then `$name` in filter - don't interpolate directly into filter string +6. **Quota check timing**: Initial quota fetch happens before `claude` runs to calculate session usage. If it fails, script continues anyway +7. **`math` precision**: Use `-s0` for integer output to avoid decimal points in quota percentages +8. **Local exports don't persist**: `set -lx` variables only exist in function scope - won't affect parent shell diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..cfb3fd90bf8c7ab014b166af54f4e54b5d95594b --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# synclaude.fish + +A Fish wrapper for the `claude` CLI that routes requests through +[Synthetic.new](https://synthetic.new), enabling use of models like GLM-4.6 +through `claude`. + +## tl;dr + +- Defaults to GLM 4.6 +- Displays session usage and remaining API quota after each run +- Invoke as `synclaude i` for an interactive model picker +- Configure models by group or individually through the interactive picker or + flags + +## Requirements + +- Fish +- `claude` +- [gum](https://github.com/charmbracelet/gum) - for TUI prompts and spinners +- [jq](https://jqlang.github.io/jq/) - for JSON parsing +- `curl` - for API requests +- `SYNTHETIC_API_KEY` environment variable + +## Installation + +### Using Fundle + +Add to your `~/.config/fish/config.fish`: + +```fish +fundle plugin 'synclaude' --url 'https://git.secluded.site/synclaude.fish' +fundle init +``` + +Then reload your shell or run `fundle install`. + +## Configuration + +Set your Synthetic API key in your `~/.config/fish/config.fish`: + +```fish +set -gx SYNTHETIC_API_KEY your_api_key_here +``` + +## Usage + +### Basic + +Use `synclaude` exactly like you would use `claude`: + +```fish +# TUI +synclaude + +# Non-interactively +synclaude -p "What does functions/synclaude.fish do?" +``` + +By default, all models (Opus, Sonnet, Haiku, and Sub-agent) use +`hf:zai-org/GLM-4.6`. + +### Interactive overrides + +Launch the interactive picker with the `i` subcommand: + +```fish +synclaude i +``` + +1. Select whether to override models by group or by specific model +2. Select which ones to override +3. Select what to override them with + +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--large` | `-L` | Override Opus, Sonnet, and Sub-agent models | +| `--light` | `-l` | Override Haiku | +| `--haiku` | `-H` | Override Haiku | +| `--opus` | `-o` | Override Opus | +| `--sonnet` | `-s` | Override Sonnet | +| `--agent` | `-a` | Override sub-agent | + +All other flags are passed through to the `claude` command. + +### CLI overrides with flags + +#### By group + +```fish +# Set all "large" models (Opus, Sonnet, Sub-agent) to GLM-4.6 +synclaude --large hf:zai-org/GLM-4.6 "Your prompt" + +# Set the "light" model (Haiku) +synclaude --light hf:qwen/Qwen2.5-Coder-32B-Instruct "Your prompt" + +# Combine both +synclaude --large hf:zai-org/GLM-4.6 --light hf:qwen/Qwen2.5-Coder-32B-Instruct "Your prompt" +``` + +#### By individual model + +```fish +# Override specific models +synclaude --opus hf:zai-org/GLM-4.6 "Your prompt" +synclaude --sonnet hf:meta-llama/Llama-3.3-70B-Instruct "Your prompt" +synclaude --haiku hf:qwen/Qwen2.5-Coder-32B-Instruct "Your prompt" +synclaude --agent hf:zai-org/GLM-4.6 "Your prompt" +``` + +Individual overrides take precedence over group overrides. + +## How it works + +`synclaude` configures the following environment variables before invoking +`claude`: + +- `ANTHROPIC_BASE_URL`: Points to Synthetic's Anthropic-compatible endpoint +- `ANTHROPIC_AUTH_TOKEN`: Your Synthetic API key +- `ANTHROPIC_DEFAULT_OPUS_MODEL`: Model to use for Opus-tier requests +- `ANTHROPIC_DEFAULT_SONNET_MODEL`: Model to use for Sonnet-tier requests +- `ANTHROPIC_DEFAULT_HAIKU_MODEL`: Model to use for Haiku-tier requests +- `CLAUDE_CODE_SUBAGENT_MODEL`: Model for sub-agent operations +- `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`: Disabled because telemetry bad + +After each run, it queries the Synthetic API to display: +- Requests used during the session +- Remaining quota (color-coded: green <33%, yellow 33-66%, red >66%) + +## Contributions + +Patch requests are in [amolith/llm-projects] on [pr.pico.sh]. You don't need a +new account to contribute, you don't need to fork this repo, you don't need to +fiddle with `git send-email`, you don't need to faff with your email client to +get `git request-pull` working... + +You just need: + +- Git +- SSH +- An SSH key + +```sh +# Clone this repo, make your changes, and commit them +# Create a new patch request with +git format-patch origin/main --stdout | ssh pr.pico.sh pr create amolith/llm-projects +# After potential feedback, submit a revision to an existing patch request with +git format-patch origin/main --stdout | ssh pr.pico.sh pr add {prID} +# List patch requests +ssh pr.pico.sh pr ls amolith/llm-projects +``` + +See "How do Patch Requests work?" on [pr.pico.sh]'s home page for a more +complete example workflow. + +[amolith/llm-projects]: https://pr.pico.sh/r/amolith/llm-projects +[pr.pico.sh]: https://pr.pico.sh diff --git a/completions/synclaude.fish b/completions/synclaude.fish new file mode 100644 index 0000000000000000000000000000000000000000..4429380a8d2950539a8d5d3c5e9ccbcc333685ba --- /dev/null +++ b/completions/synclaude.fish @@ -0,0 +1,15 @@ +# Inherit all claude completions +complete -c synclaude -w claude + +# Interactive mode +complete -c synclaude -n "not __fish_seen_subcommand_from i" -a i -d "Interactive model selection" + +# Group model overrides +complete -c synclaude -s L -l large -r -d "Override Opus, Sonnet, and Sub-agent models" +complete -c synclaude -s l -l light -r -d "Override Haiku model" + +# Specific model overrides +complete -c synclaude -s o -l opus -r -d "Override Opus model" +complete -c synclaude -s s -l sonnet -r -d "Override Sonnet model" +complete -c synclaude -s H -l haiku -r -d "Override Haiku model" +complete -c synclaude -s a -l agent -r -d "Override Sub-agent model" diff --git a/functions/synclaude.fish b/functions/synclaude.fish new file mode 100644 index 0000000000000000000000000000000000000000..d8f1a175b1d84eefa599b18978f48b38932055e9 --- /dev/null +++ b/functions/synclaude.fish @@ -0,0 +1,156 @@ +function synclaude --wraps=claude --description "Run claude with Synthetic GLM-4.6 backend" + set -l default_model hf:zai-org/GLM-4.6 + + # Handle interactive mode + if test (count $argv) -gt 0 && test "$argv[1]" = "i" + set -e argv[1] # Remove 'i' from args + + # Fetch available models + set -l models_json (gum spin --spinner dot --title "Fetching models..." -- curl -s -H "Authorization: Bearer $SYNTHETIC_API_KEY" https://api.synthetic.new/openai/v1/models) + or return 1 + set -l model_names (echo $models_json | jq -r '.data[].name') + or return 1 + + # Prompt for groups vs individual + set -l mode (gum choose --limit 1 --header "How do you want to select models?" "Groups" "Individual models") + or return 1 + + # Build flags array + set -l flags + + if test "$mode" = "Groups" + # Select which groups to override + set -l groups (gum choose --no-limit --header "Which group(s) do you want to override?" "Large (Opus, Sonnet, Sub-agent)" "Light (Haiku)") + or return 1 + + for group in $groups + if test "$group" = "Large (Opus, Sonnet, Sub-agent)" + set -l model_name (printf "%s\n" $model_names | gum filter --limit 1 --header "Select model for Large group" --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --large=$model_id + end + else if test "$group" = "Light (Haiku)" + set -l model_name (printf "%s\n" $model_names | gum filter --limit 1 --header "Select model for Light group" --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --light=$model_id + end + end + end + else + # Select which individual models to override + set -l models (gum choose --no-limit --header "Which model(s) do you want to override?" "Opus" "Sonnet" "Haiku" "Sub-agent") + or return 1 + + for model_type in $models + switch $model_type + case "Opus" + set -l model_name (printf "%s\n" $model_names | gum filter --limit 1 --header "Select Opus model" --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --opus=$model_id + end + case "Sonnet" + set -l model_name (printf "%s\n" $model_names | gum filter --limit 1 --header "Select Sonnet model" --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --sonnet=$model_id + end + case "Haiku" + set -l model_name (printf "%s\n" $model_names | gum filter --limit 1 --header "Select Haiku model" --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --haiku=$model_id + end + case "Sub-agent" + set -l model_name (printf "%s\n" $model_names | gum filter --limit 1 --header "Select Sub-agent model" --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --agent=$model_id + end + end + end + end + + # Recursively call synclaude with selected flags and remaining args + synclaude $flags $argv + return $status + end + + # Parse arguments + # -L/--large: Sets Opus, Sonnet, and Sub-agent models + # -l/--light: Sets Haiku model + # -o/--opus, -s/--sonnet, -H/--haiku, -a/--agent: Set specific models + argparse --ignore-unknown \ + 'L/large=' 'l/light=' \ + 'o/opus=' 's/sonnet=' 'H/haiku=' 'a/agent=' -- $argv + or return + + set -lx ANTHROPIC_BASE_URL https://api.synthetic.new/anthropic + set -lx ANTHROPIC_AUTH_TOKEN $SYNTHETIC_API_KEY + + # Set defaults + set -lx ANTHROPIC_DEFAULT_OPUS_MODEL $default_model + set -lx ANTHROPIC_DEFAULT_SONNET_MODEL $default_model + set -lx ANTHROPIC_DEFAULT_HAIKU_MODEL $default_model + set -lx CLAUDE_CODE_SUBAGENT_MODEL $default_model + set -lx CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC 1 + + # Apply group overrides + if set -q _flag_large + set ANTHROPIC_DEFAULT_OPUS_MODEL $_flag_large + set ANTHROPIC_DEFAULT_SONNET_MODEL $_flag_large + set CLAUDE_CODE_SUBAGENT_MODEL $_flag_large + end + + if set -q _flag_light + set ANTHROPIC_DEFAULT_HAIKU_MODEL $_flag_light + end + + # Apply specific overrides (taking precedence over groups) + if set -q _flag_opus; set ANTHROPIC_DEFAULT_OPUS_MODEL $_flag_opus; end + if set -q _flag_sonnet; set ANTHROPIC_DEFAULT_SONNET_MODEL $_flag_sonnet; end + if set -q _flag_haiku; set ANTHROPIC_DEFAULT_HAIKU_MODEL $_flag_haiku; end + if set -q _flag_agent; set CLAUDE_CODE_SUBAGENT_MODEL $_flag_agent; end + + # Fetch initial quota + set -l initial_response (gum spin --spinner dot --title "Checking quota..." -- curl -s -H "Authorization: Bearer $SYNTHETIC_API_KEY" https://api.synthetic.new/v2/quotas 2>/dev/null) + set -l initial_requests (echo $initial_response | jq -r '.subscription.requests // 0' 2>/dev/null) + test -n "$initial_requests" || set initial_requests 0 + + command claude $argv + + # Fetch final quota + set -l final_response (gum spin --spinner dot --title "Fetching usage..." -- curl -s -H "Authorization: Bearer $SYNTHETIC_API_KEY" https://api.synthetic.new/v2/quotas 2>/dev/null) + set -l final_requests (echo $final_response | jq -r '.subscription.requests // 0' 2>/dev/null) + set -l limit (echo $final_response | jq -r '.subscription.limit // 0' 2>/dev/null) + + # Calculate and display + if test -n "$limit" && test "$limit" -gt 0 + set -l session_usage (math "$final_requests - $initial_requests") + set -l remaining (math "$limit - $final_requests") + set -l percent_used (math -s0 "($final_requests * 100) / $limit") + + set -l quota_color green + if test "$percent_used" -gt 66 + set quota_color red + else if test "$percent_used" -gt 33 + set quota_color yellow + end + + echo + echo "Session usage: $session_usage requests" + echo -n "Remaining: " + set_color $quota_color + echo -n "$remaining/$limit" + set_color normal + echo " requests" + end +end