feat(zsh): add complete zsh implementation

Amolith created

Ports the entire synu wrapper to Zsh with equivalent functionality:

- Plugin entry point (synu.plugin.zsh) with standard $0 handling
- Main wrapper function with quota tracking and agent dispatch
- Cache system for model preferences (models.conf format)
- All five agent configurations: claude, opencode, aider, llxprt, qwen
- Interactive model selection using gum
- Shell completions for agents and flags
- Taskfile for testing both shell implementations

Updates documentation to reflect multi-shell support.

Assisted-by: Claude Opus 4.5 via Crush

Change summary

AGENTS.md                               | 444 ++------------------------
README.md                               |  47 ++
Taskfile.yml                            |  12 
zsh/completions/_synu.zsh               | 130 +++++++
zsh/functions/_synu_agents/aider.zsh    | 161 +++++++++
zsh/functions/_synu_agents/claude.zsh   | 266 ++++++++++++++++
zsh/functions/_synu_agents/llxprt.zsh   | 103 ++++++
zsh/functions/_synu_agents/opencode.zsh | 100 ++++++
zsh/functions/_synu_agents/qwen.zsh     | 104 ++++++
zsh/functions/_synu_cache.zsh           |  65 +++
zsh/functions/_synu_get_quota.zsh       |  33 ++
zsh/functions/synu.zsh                  | 255 +++++++++++++++
zsh/synu.plugin.zsh                     |  27 +
13 files changed, 1,332 insertions(+), 415 deletions(-)

Detailed changes

AGENTS.md 🔗

@@ -10,7 +10,7 @@ This document helps AI agents work effectively with the synu codebase.
 
 ## Project Overview
 
-**synu** is a universal wrapper for AI agents that tracks quota usage for [Synthetic](https://synthetic.new) API calls. It's written in Fish shell and provides:
+**synu** is a wrapper for AI agents that tracks quota usage for [Synthetic](https://synthetic.new) API calls. It provides:
 
 - Transparent quota tracking before/after agent execution
 - Agent-specific configuration system (model routing, API endpoints)
@@ -20,21 +20,25 @@ This document helps AI agents work effectively with the synu codebase.
 
 ### Architecture
 
+Both shell implementations follow the same structure:
+
 ```
-fish/
-└─ functions/
-   ├─ synu.fish (main wrapper)
-   ├─ _synu_get_quota.fish (API quota fetching)
-   ├─ _synu_cache.fish (model preference persistence)
-   └─ _synu_agents/
-       ├─ claude.fish  (Claude Code: env vars, multi-model)
-       ├─ opencode.fish (OpenCode: CLI args)
-       ├─ aider.fish    (Aider: env vars + CLI args)
-       ├─ llxprt.fish   (llxprt: CLI args only)
-       └─ qwen.fish     (Qwen Code: env vars)
+fish/                                 zsh/
+└─ functions/                         ├─ synu.plugin.zsh (entry point)
+   ├─ synu.fish (main wrapper)        └─ functions/
+   ├─ _synu_get_quota.fish               ├─ synu.zsh (main wrapper)
+   ├─ _synu_cache.fish                   ├─ _synu_get_quota.zsh
+   └─ _synu_agents/                      ├─ _synu_cache.zsh
+       ├─ claude.fish                    └─ _synu_agents/
+       ├─ opencode.fish                      ├─ claude.zsh
+       ├─ aider.fish                         ├─ opencode.zsh
+       ├─ llxprt.fish                        ├─ aider.zsh
+       └─ qwen.fish                          ├─ llxprt.zsh
+                                             └─ qwen.zsh
 ```
 
 **Control Flow:**
+
 1. Parse arguments (check for interactive mode, agent name)
 2. Load agent definition from `fish/functions/_synu_agents/<agent>.fish` if it exists
 3. Parse agent-specific flags using `argparse` with `--ignore-unknown` for passthrough
@@ -45,6 +49,7 @@ fish/
 8. Fetch final quota and calculate/display session usage
 
 **Data Flow:**
+
 - Quota API → `_synu_get_quota` → space-separated "requests limit" string
 - Agent flags → `argparse` → `_flag_*` variables → agent `_configure` function
 - Cache file → `_synu_cache_get` → default model values
@@ -59,46 +64,28 @@ fish/
 │   │   ├── _synu_get_quota.fish   # Private: Fetch quota from API
 │   │   ├── _synu_cache.fish       # Private: Model preference cache
 │   │   └── _synu_agents/
-│   │       ├── claude.fish        # Claude Code agent configuration
-│   │       ├── opencode.fish      # OpenCode agent configuration
-│   │       ├── aider.fish         # Aider agent configuration
-│   │       ├── llxprt.fish        # llxprt agent configuration
-│   │       └── qwen.fish          # Qwen Code agent configuration
 │   └── completions/
-│       └── synu.fish              # Shell completions
-├── crush.json                     # LSP configuration (fish-lsp)
-├── README.md                      # User documentation
-└── .gitignore                     # Ignore agent working directories
+│       └── synu.fish              # Fish completions
+├── zsh/
+│   ├── synu.plugin.zsh            # Zsh plugin entry point
+│   ├── functions/
+│   │   ├── synu.zsh               # Main wrapper function
+│   │   ├── _synu_get_quota.zsh    # Private: Fetch quota from API
+│   │   ├── _synu_cache.zsh        # Private: Model preference cache
+│   │   └── _synu_agents/
+│   └── completions/
+│       └── _synu.zsh              # Zsh completions
 ```
 
 ## Essential Commands
 
-This is a Fish shell library with no build system. Key commands:
-
-### Testing Manually
+This is a shell library with no build system. Key commands:
 
-```fish
-# Source the function (from repo root)
-source fish/functions/synu.fish
-source fish/functions/_synu_get_quota.fish
-source fish/functions/_synu_cache.fish
-
-# Check quota only
-synu
-
-# Test with an agent
-synu claude "What does this function do?"
-
-# Test interactive mode (requires gum)
-synu i claude "prompt"
-synu i opencode "prompt"
-synu i aider "prompt"
+### Testing In one line
 
-# Test with model override flags
-synu claude --opus hf:some/model "prompt"
-synu claude --large hf:some/model "prompt"
-synu opencode --model hf:some/model "prompt"
-synu aider --model hf:some/model --editor-model hf:other/model "prompt"
+```
+fish -c 'set fish_function_path fish/functions fish/functions/_synu_agents \$fish_function_path; synu'
+zsh -c 'source zsh/synu.plugin.zsh; synu'
 ```
 
 ### Installation Testing
@@ -110,113 +97,8 @@ fundle install
 fundle init
 ```
 
-### Checking for Syntax Errors
-
-```fish
-# Validate Fish syntax
-fish -n fish/functions/synu.fish
-fish -n fish/functions/_synu_get_quota.fish
-fish -n fish/functions/_synu_cache.fish
-fish -n fish/functions/_synu_agents/*.fish
-fish -n fish/completions/synu.fish
-```
-
-### LSP Integration
-
-The project includes `crush.json` configuring `fish-lsp` for Fish language support. The LSP should automatically provide diagnostics when editing `.fish` files.
-
-## Code Conventions
-
-### Fish Shell Style
-
-- **Indentation**: 4 spaces (no tabs)
-- **Variable naming**: `snake_case` with descriptive names
-  - Private/local vars: `set -l var_name`
-  - Global vars: `set -g var_name` (agent defaults, selected models)
-  - Exported vars: `set -gx VAR_NAME` (environment)
-- **Function naming**:
-  - Public: `synu`
-  - Private/helper: `_synu_*`
-  - Agent-specific: `_synu_agent_<name>_<action>`
-  - Agent internal helpers: `_synu_<agent>_<helper>` (e.g., `_synu_claude_default`)
-- **Comments**: Use `#` for explanatory comments focusing on *why* not *what*
-- **Error handling**: Check `$status` after critical operations, return non-zero on errors
-- **Error output**: Use `>&2` for all error messages
-- **SPDX headers**: All files must include SPDX copyright and license headers
-
-### Agent Configuration Pattern
-
-Each agent definition in `fish/functions/_synu_agents/<agent>.fish` can provide five functions:
-
-1. **`_synu_agent_<name>_flags`** - Returns argparse flag specification (one per line)
-   ```fish
-   function _synu_agent_myagent_flags
-       echo "m/model="
-       echo "e/extra="
-   end
-   ```
-
-2. **`_synu_agent_<name>_configure`** - Sets environment variables and/or global state
-   ```fish
-   function _synu_agent_myagent_configure
-       argparse 'm/model=' -- $argv
-       or return 1
-       
-       # Configure based on flags (use cache or fallback for defaults)
-       set -g _synu_myagent_selected_model (_synu_myagent_default)
-       if set -q _flag_model
-           set -g _synu_myagent_selected_model $_flag_model
-       end
-       
-       # Export env vars if agent uses them
-       set -gx AGENT_MODEL $_synu_myagent_selected_model
-   end
-   ```
-
-3. **`_synu_agent_<name>_args`** - Returns CLI arguments to inject (for agents that don't use env vars)
-   ```fish
-   function _synu_agent_myagent_args
-       echo --model
-       echo $_synu_myagent_selected_model
-   end
-   ```
-
-4. **`_synu_agent_<name>_env_vars`** - Returns list of environment variables to clean up after execution
-   ```fish
-   function _synu_agent_myagent_env_vars
-       echo AGENT_MODEL
-       echo AGENT_CONFIG
-   end
-   ```
-
-5. **`_synu_agent_<name>_interactive`** - Implements interactive model selection
-   ```fish
-   function _synu_agent_myagent_interactive
-       # Use gum to get user input
-       set -l selection (gum choose "option1" "option2")
-       or return 1
-       
-       # Optionally save to cache
-       if gum confirm "Save as default?"
-           _synu_cache_set myagent model $selection
-       end
-       
-       # Return flags to pass to main synu call
-       echo --model=$selection
-   end
-   ```
-
-**Agent Configuration Patterns:**
+## Important patterns
 
-| Agent | Configuration Method | Notes |
-|-------|---------------------|-------|
-| claude | Environment variables | Multi-model (opus, sonnet, haiku, subagent) |
-| qwen | Environment variables | Single model |
-| aider | Env vars + CLI args | Env for API, CLI for models |
-| opencode | CLI args only | Uses `synthetic/` prefix in model |
-| llxprt | CLI args only | Needs --provider, --baseurl flags |
-
-**Important patterns:**
 - Agents without definitions work as passthrough (no special config)
 - Use `argparse --ignore-unknown` in main wrapper so agent-native flags pass through
 - Interactive functions return flags that trigger recursive `synu` call
@@ -224,188 +106,10 @@ Each agent definition in `fish/functions/_synu_agents/<agent>.fish` can provide
 - Environment variables listed in `_env_vars` are automatically unset after agent exits
 - Use `_synu_cache_get`/`_synu_cache_set` for persistent model preferences
 
-### Cache System
-
-The cache stores model preferences in `$XDG_CONFIG_HOME/synu/models.conf` (or `~/.config/synu/models.conf`).
-
-**Format:**
-```
-# synu model preferences
-# Format: agent.slot = model_id
-
-claude.opus = hf:moonshotai/Kimi-K2-Thinking
-claude.sonnet = hf:MiniMaxAI/MiniMax-M2
-opencode.model = hf:MiniMaxAI/MiniMax-M2
-```
-
-**Functions:**
-- `_synu_cache_get agent slot` - Returns cached value or fails
-- `_synu_cache_set agent slot value` - Creates/updates cache entry
-
-**Pattern for using cache with fallbacks:**
-```fish
-function _synu_myagent_default --description "Get default model"
-    set -l cached (_synu_cache_get myagent model)
-    if test $status -eq 0
-        echo $cached
-    else
-        echo $_synu_myagent_fallback_model  # Hardcoded fallback
-    end
-end
-```
-
-### API Integration
-
-**Synthetic API endpoints:**
-- Quota: `GET https://api.synthetic.new/v2/quotas`
-- Models: `GET https://api.synthetic.new/openai/v1/models`
-- Claude routing: `https://api.synthetic.new/anthropic`
-- OpenAI-compatible: `https://api.synthetic.new/openai/v1`
-
-**Authentication**: Bearer token via `Authorization: Bearer $SYNTHETIC_API_KEY`
-
-**Response parsing**: Use `jq` with fallbacks (`// 0`) for missing fields
-
-**Error handling**: Check `curl` exit status and response emptiness before parsing
-
-## Important Gotchas
-
-### Argument Passing and argparse
-
-1. **Passthrough requires `--ignore-unknown`**: When parsing agent flags, use `argparse --ignore-unknown` so agent-native flags aren't consumed by synu's parser.
-
-2. **Flag variables after argparse**: After `argparse`, the `$argv` variable contains only non-flag arguments. Rebuild flag arguments from `_flag_*` variables to pass to configure function.
-
-3. **Preserve argument order**: When executing the agent with `command $agent $extra_args $agent_args`, all original arguments (except synu-specific flags) must pass through unchanged.
-
-4. **The `_args` function output is inserted before user args**: This allows agent-specific flags to be set while still allowing user to pass additional flags.
-
-### Quota Tracking Edge Cases
-
-1. **API failures are non-fatal**: If quota fetch fails before/after execution, warn but continue. The agent should still run.
-
-2. **Default to zeros on failure**: If pre-execution quota fetch fails, use `"0 0"` to avoid math errors in post-execution display.
-
-3. **Exit status preservation**: Always return the agent's exit status (`$exit_status`), not the quota fetch status.
-
-### Environment Variable Scoping
-
-- Use `set -gx` for environment variables that need to be visible to child processes (the actual agent binary)
-- Agent defaults and selected models use `set -g` (global but not exported)
-- Local calculations use `set -l`
-- Environment variables are cleaned up after execution via `_env_vars`
-
-### Interactive Mode Flow
-
-Interactive mode (`synu i <agent> [args...]`) works by:
-1. Calling agent's `_interactive` function to get flags
-2. Optionally saving selections to cache (user prompted)
-3. Recursively calling `synu <agent> <flags> [args...]` (non-interactive)
-4. The recursive call then follows normal path with pre-parsed flags
-
-This means the `_configure` function must handle both direct flag input and flags from interactive mode identically.
-
-### Model ID Prefixes
-
-Different agents expect different model ID formats:
-- **opencode**: Prefix with `synthetic/` (e.g., `synthetic/hf:moonshotai/Kimi-K2`)
-- **aider**: Prefix with `openai/` (e.g., `openai/hf:moonshotai/Kimi-K2`)
-- **claude, qwen, llxprt**: Use raw model ID (e.g., `hf:moonshotai/Kimi-K2`)
-
-### Fish-specific Considerations
-
-- **Status checks**: Always use `test $status -ne 0` immediately after command, as any subsequent command overwrites `$status`
-- **Array slicing**: Use `$argv[2..-1]` syntax (1-indexed, inclusive)
-- **Command substitution**: Use `(command)` not `$(command)`
-- **String operations**: Use `string` builtin (`string split`, `string match`)
-- **No `local` keyword**: Use `set -l` for local scope
-- **Variable indirection**: Use `$$var_name` to get value of variable named by `$var_name`
-
-## Testing Approach
-
-This project has no automated test suite. Testing is manual:
-
-1. **Syntax validation**: Run `fish -n` on all `.fish` files
-2. **Quota tracking**: Run `synu` alone, check quota display with color
-3. **Passthrough**: Test with unknown agent (`synu echo "hello"`)
-4. **Agent config**: Test each agent with/without flags
-5. **Interactive mode**: Test `synu i <agent>` with gum installed
-6. **Cache persistence**: Test that interactive selections save correctly
-7. **Error cases**: Test without `SYNTHETIC_API_KEY`, with invalid API key
-
-**When making changes:**
-- Validate syntax with `fish -n`
-- Source the function and test manually with different argument combinations
-- Check that exit status is preserved
-- Verify quota display shows correct before/after values
-- Test cache file creation and updates
-
-## Common Tasks
-
-### Adding a New Agent Configuration
-
-1. Create `fish/functions/_synu_agents/<agent>.fish`
-2. Add SPDX header
-3. Source cache functions: `source (status dirname)/../_synu_cache.fish`
-4. Define fallback default: `set -g _synu_<agent>_fallback_model "hf:..."`
-5. Define `_synu_<agent>_default` helper (uses cache with fallback)
-6. Define `_synu_agent_<agent>_flags` (return argparse flag specs)
-7. Define `_synu_agent_<agent>_configure` (set state/env vars)
-8. Define `_synu_agent_<agent>_args` (if agent uses CLI args for model)
-9. Define `_synu_agent_<agent>_env_vars` (if agent uses env vars)
-10. Optionally define `_synu_agent_<agent>_interactive` (for `synu i <agent>`)
-11. Update `fish/completions/synu.fish` to add the new agent to suggestions
-12. Test manually
-
-### Modifying Quota Display
-
-Quota display logic is in two places:
-- **No args case**: `synu.fish` lines ~7-29
-- **Post-execution**: `synu.fish` lines ~162-180
-
-Both use the same color thresholds:
-- Green: < 33% used
-- Yellow: 33-66% used
-- Red: > 66% used
-
-### Debugging API Issues
-
-```fish
-# Test quota fetch directly
-source fish/functions/_synu_get_quota.fish
-set -gx SYNTHETIC_API_KEY your_key_here
-_synu_get_quota
-# Should output: "requests_used limit"
-
-# Test with curl directly
-curl -s -f -H "Authorization: Bearer $SYNTHETIC_API_KEY" \
-    "https://api.synthetic.new/v2/quotas" | jq .
-
-# Test model listing
-curl -s -H "Authorization: Bearer $SYNTHETIC_API_KEY" \
-    "https://api.synthetic.new/openai/v1/models" | jq '.data[].name'
-```
-
-### Debugging Cache Issues
-
-```fish
-# Check cache file location
-echo $XDG_CONFIG_HOME/synu/models.conf
-# or
-echo ~/.config/synu/models.conf
-
-# View cache contents
-cat ~/.config/synu/models.conf
-
-# Test cache functions
-source fish/functions/_synu_cache.fish
-_synu_cache_set test model hf:test/model
-_synu_cache_get test model
-```
-
 ### Understanding Model Configuration Flow
 
 **Example: Claude with flag override**
+
 1. User runs: `synu claude --large hf:some/model "prompt"`
 2. `synu.fish` loads `fish/functions/_synu_agents/claude.fish`
 3. Calls `_synu_agent_claude_flags` to get flag spec
@@ -419,87 +123,9 @@ _synu_cache_get test model
 11. After exit, env vars are cleaned up via `_env_vars`
 
 **Example: OpenCode with CLI args**
+
 1. User runs: `synu opencode "prompt"`
 2. `synu.fish` loads `fish/functions/_synu_agents/opencode.fish`
 3. `_configure` sets `_synu_opencode_selected_model` from cache/fallback
 4. `_args` returns: `--model synthetic/hf:MiniMaxAI/MiniMax-M2`
 5. Executes: `command opencode -m "synthetic/hf:..." "prompt"`
-
-## Commit Conventions
-
-Use **Conventional Commits** with type and scope:
-
-**Types:** `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
-
-**Scopes:**
-- `synu` - main wrapper function
-- `quota` - quota fetching/display
-- `cache` - model preference caching
-- `agents/<name>` - specific agent configuration (e.g., `agents/claude`)
-- `completions` - shell completions
-- `docs` - documentation
-
-**Examples:**
-```
-feat(synu): add support for model override flags
-fix(quota): handle API errors gracefully
-feat(agents/claude): add interactive model selection
-feat(cache): implement persistent model preferences
-docs(readme): document interactive mode
-```
-
-**Trailers:**
-- Use `References: HASH` for related commits/bugs
-- Always include `Assisted-by: [Model Name] via [Tool Name]` when AI-assisted
-
-## Project-Specific Context
-
-### Why Fish Shell?
-
-This is Amolith's personal project and Fish is their shell of choice. Fish provides:
-- Clean syntax without bashisms
-- Better error handling than POSIX shells
-- Native arrays and proper scoping
-- Built-in string manipulation
-
-### Why Synthetic API?
-
-Synthetic provides a unified API for multiple LLM providers with quota tracking. This wrapper makes quota visibility automatic and consistent across different agent tools.
-
-### Design Philosophy
-
-- **Minimal interference**: Pass through as much as possible to the underlying agent
-- **Graceful degradation**: If quota tracking fails, still run the agent
-- **Extensible**: Easy to add new agent configurations without modifying core wrapper
-- **Interactive when helpful**: Support both CLI flags and TUI selection
-- **Persistent preferences**: Save model selections for future sessions
-
-### Configured Agents
-
-Agents with specific configurations in `fish/functions/_synu_agents/`:
-
-| Agent | Models | Configuration Method | Interactive |
-|-------|--------|---------------------|-------------|
-| claude | opus, sonnet, haiku, subagent | Environment variables | Yes |
-| opencode | single | CLI args | Yes |
-| aider | main, editor | Env + CLI args | Yes |
-| llxprt | single | CLI args | Yes |
-| qwen | single | Environment variables | Yes |
-
-Passthrough agents (no config, just quota tracking): crush, amp, octo, codex
-
-Any command can be wrapped; the above are just agents with explicit configuration.
-
-## Future Considerations
-
-Potential enhancements (not currently implemented):
-- Support for other quota tracking APIs besides Synthetic
-- Agent-specific usage tracking/history
-- Budget warnings/limits
-- More agent configurations
-
-When implementing features, maintain the core principles:
-- Don't break passthrough mode
-- Keep it simple and transparent
-- Graceful degradation
-- Follow Fish idioms

README.md 🔗

@@ -12,11 +12,11 @@ supported agents for currently-available models (so you can try a new
 one as soon as they support it, without waiting for the agent itself to
 gain support!)
 
-![Invoking synclaude with the interactive i subcommand to override the default models. First question is how to override the models, selecting each individually or by group. After selecting group, the next question is which group, large -L or light -l or both. After selecting large, the next question is which model to use, presented as a list of IDs with fuzzy filtering. After typing "mmm2" for MiniMax M2, last question is whether to save those as default. After selecting No, Claude Code starts. After typing Hi and getting a response and quitting Claude Code, synclaude shows how many requests were used during the session and the overall usage as percent used followed by N out of X remaining in parenthises.](https://vhs.charm.sh/vhs-3KmXLzKNOpUNFsxOu2za4q.gif)
+![Invoking synu with the interactive i subcommand to override the default models. First question is how to override the models, selecting each individually or by group. After selecting group, the next question is which group, large -L or light -l or both. After selecting large, the next question is which model to use, presented as a list of IDs with fuzzy filtering. After typing "mmm2" for MiniMax M2, last question is whether to save those as default. After selecting No, Claude Code starts. After typing Hi and getting a response and quitting Claude Code, synclaude shows how many requests were used during the session and the overall usage as percent used followed by N out of X remaining in parenthises.](https://vhs.charm.sh/vhs-3KmXLzKNOpUNFsxOu2za4q.gif)
 
 ## Requirements
 
-- Fish
+- Fish **or** Zsh
 - `curl` - for API requests
 - `jq` - for JSON parsing
 - `gum` - for interactive model selection
@@ -26,7 +26,7 @@ gain support!)
 
 ## Installation
 
-### Using Fundle
+### Fish
 
 Add to your `~/.config/fish/config.fish`:
 
@@ -37,15 +37,49 @@ fundle init
 
 Then reload your shell and run `fundle install`.
 
+### Zsh
+
+Using [zinit](https://github.com/zdharma-continuum/zinit):
+
+```zsh
+zinit ice from"git.secluded.site" pick"zsh/synu.plugin.zsh"
+zinit load synu
+```
+
+Using [antigen](https://github.com/zsh-users/antigen):
+
+```zsh
+antigen bundle https://git.secluded.site/synu.git zsh
+```
+
+Using [sheldon](https://sheldon.cli.rs/) (in `~/.config/sheldon/plugins.toml`):
+
+```toml
+[plugins.synu]
+git = "https://git.secluded.site/synu"
+use = ["zsh/synu.plugin.zsh"]
+```
+
+Or source manually:
+
+```zsh
+source /path/to/synu/zsh/synu.plugin.zsh
+```
+
 ## Configuration
 
-Set your Synthetic API key in your `~/.config/fish/config.fish`:
+Set your Synthetic API key in your shell configuration:
 
 ```fish
-# For Synthetic API quota tracking
+# Fish: ~/.config/fish/config.fish
 set -gx SYNTHETIC_API_KEY your_api_key_here
 ```
 
+```zsh
+# Zsh: ~/.zshrc
+export SYNTHETIC_API_KEY=your_api_key_here
+```
+
 ## Usage
 
 Use `synu` as a wrapper for any AI agent:
@@ -183,7 +217,8 @@ tracking, but still attempt to run the agent.
 
 ## Shell completions
 
-Synu includes fish shell completions for configured agents and their flags.
+Synu includes completions for configured agents and their flags in both
+Fish and Zsh.
 
 ## Contributions
 

Taskfile.yml 🔗

@@ -0,0 +1,12 @@
+version: '3'
+
+tasks:
+  fish:
+    desc: Test synu with Fish shell
+    cmds:
+      - fish -c 'source fish/functions/synu.fish; source fish/functions/_synu_get_quota.fish; source fish/functions/_synu_cache.fish; synu {{.CLI_ARGS}}'
+
+  zsh:
+    desc: Test synu with Zsh
+    cmds:
+      - zsh -c 'source zsh/synu.plugin.zsh; synu {{.CLI_ARGS}}'

zsh/completions/_synu.zsh 🔗

@@ -0,0 +1,130 @@
+#compdef synu
+
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: Unlicense
+
+# Completion for synu - Universal agent wrapper with Synthetic API quota tracking
+
+local curcontext="$curcontext" state line
+typeset -A opt_args
+
+local -a agents=(
+    'claude:Claude Code agent'
+    'opencode:OpenCode agent'
+    'aider:Aider agent'
+    'llxprt:llxprt agent'
+    'qwen:Qwen Code agent'
+    'crush:Crush agent (passthrough)'
+    'amp:Amp agent (passthrough)'
+    'octo:Octo agent (passthrough)'
+    'codex:Codex agent (passthrough)'
+)
+
+local -a first_args=(
+    'i:Interactive model selection'
+    ${agents[@]}
+)
+
+_arguments -C \
+    '1: :->first' \
+    '2: :->second' \
+    '*:: :->args' && return 0
+
+case $state in
+    first)
+        _describe -t commands 'synu commands' first_args
+        ;;
+    second)
+        if [[ "$words[2]" == "i" ]]; then
+            _describe -t agents 'agents' agents
+        else
+            # After agent name, provide agent-specific options
+            case "$words[2]" in
+                claude)
+                    _arguments \
+                        '-L[Override Opus, Sonnet, and Sub-agent models]:model:' \
+                        '--large[Override Opus, Sonnet, and Sub-agent models]:model:' \
+                        '-l[Override Haiku model]:model:' \
+                        '--light[Override Haiku model]:model:' \
+                        '-o[Override Opus model]:model:' \
+                        '--opus[Override Opus model]:model:' \
+                        '-s[Override Sonnet model]:model:' \
+                        '--sonnet[Override Sonnet model]:model:' \
+                        '-H[Override Haiku model]:model:' \
+                        '--haiku[Override Haiku model]:model:' \
+                        '-a[Override Sub-agent model]:model:' \
+                        '--agent[Override Sub-agent model]:model:' \
+                        '*::claude arguments:_command_names -e'
+                    ;;
+                opencode)
+                    _arguments \
+                        '-m[Override model]:model:' \
+                        '--model[Override model]:model:' \
+                        '*::opencode arguments:_command_names -e'
+                    ;;
+                aider)
+                    _arguments \
+                        '-m[Main model]:model:' \
+                        '--model[Main model]:model:' \
+                        '-e[Editor model (enables architect + editor mode)]:model:' \
+                        '--editor-model[Editor model (enables architect + editor mode)]:model:' \
+                        '*::aider arguments:_command_names -e'
+                    ;;
+                llxprt)
+                    _arguments \
+                        '-m[Override model]:model:' \
+                        '--model[Override model]:model:' \
+                        '*::llxprt arguments:_command_names -e'
+                    ;;
+                qwen)
+                    _arguments \
+                        '-m[Override model]:model:' \
+                        '--model[Override model]:model:' \
+                        '*::qwen arguments:_command_names -e'
+                    ;;
+                *)
+                    # Passthrough agents - no special options
+                    _command_names -e
+                    ;;
+            esac
+        fi
+        ;;
+    args)
+        # After "i agent", provide agent-specific options
+        if [[ "$words[2]" == "i" ]]; then
+            case "$words[3]" in
+                claude)
+                    _arguments \
+                        '-L[Override Opus, Sonnet, and Sub-agent models]:model:' \
+                        '--large[Override Opus, Sonnet, and Sub-agent models]:model:' \
+                        '-l[Override Haiku model]:model:' \
+                        '--light[Override Haiku model]:model:' \
+                        '-o[Override Opus model]:model:' \
+                        '--opus[Override Opus model]:model:' \
+                        '-s[Override Sonnet model]:model:' \
+                        '--sonnet[Override Sonnet model]:model:' \
+                        '-H[Override Haiku model]:model:' \
+                        '--haiku[Override Haiku model]:model:' \
+                        '-a[Override Sub-agent model]:model:' \
+                        '--agent[Override Sub-agent model]:model:' \
+                        '*::claude arguments:_command_names -e'
+                    ;;
+                opencode|llxprt|qwen)
+                    _arguments \
+                        '-m[Override model]:model:' \
+                        '--model[Override model]:model:' \
+                        '*::arguments:_command_names -e'
+                    ;;
+                aider)
+                    _arguments \
+                        '-m[Main model]:model:' \
+                        '--model[Main model]:model:' \
+                        '-e[Editor model]:model:' \
+                        '--editor-model[Editor model]:model:' \
+                        '*::aider arguments:_command_names -e'
+                    ;;
+            esac
+        fi
+        ;;
+esac

zsh/functions/_synu_agents/aider.zsh 🔗

@@ -0,0 +1,161 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: Unlicense
+
+# Aider agent definition for synu
+# Provides model configuration for routing through Synthetic API
+# Aider accepts model via CLI flags and API config via environment variables
+
+# Fallback defaults (used when no cache entry exists)
+typeset -g _SYNU_AIDER_FALLBACK_MODEL="hf:zai-org/GLM-4.6"
+typeset -g _SYNU_AIDER_FALLBACK_EDITOR_MODEL="hf:deepseek-ai/DeepSeek-V3.1-Terminus"
+
+_synu_aider_default() {
+    local slot=$1
+    local cached
+    cached=$(_synu_cache_get aider "${slot}")
+    if [[ $? -eq 0 ]]; then
+        echo "${cached}"
+    else
+        local var_name="_SYNU_AIDER_FALLBACK_${(U)slot//-/_}"
+        echo "${(P)var_name}"
+    fi
+}
+
+_synu_agent_aider_flags() {
+    echo "m/model="
+    echo "e/editor-model="
+}
+
+_synu_agent_aider_env_vars() {
+    echo OPENAI_API_BASE
+    echo OPENAI_API_KEY
+}
+
+_synu_agent_aider_configure() {
+    local -A opts
+
+    # Parse flags passed from main synu
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --model=*) opts[model]="${1#*=}" ;;
+            --editor-model=*) opts[editor_model]="${1#*=}" ;;
+        esac
+        shift
+    done
+
+    # Start with defaults (from cache or fallback)
+    typeset -g _SYNU_AIDER_SELECTED_MODEL=$(_synu_aider_default model)
+    typeset -g _SYNU_AIDER_SELECTED_EDITOR_MODEL=$(_synu_aider_default editor_model)
+
+    # Apply overrides if provided
+    [[ -n "${opts[model]}" ]] && typeset -g _SYNU_AIDER_SELECTED_MODEL="${opts[model]}"
+    [[ -n "${opts[editor_model]}" ]] && typeset -g _SYNU_AIDER_SELECTED_EDITOR_MODEL="${opts[editor_model]}"
+
+    # Export environment variables for Aider
+    export OPENAI_API_BASE="https://api.synthetic.new/openai/v1"
+    export OPENAI_API_KEY="${SYNTHETIC_API_KEY}"
+}
+
+_synu_agent_aider_args() {
+    # Always return --model
+    echo --model
+    echo "openai/${_SYNU_AIDER_SELECTED_MODEL}"
+
+    # Return --editor-model if set
+    if [[ -n "${_SYNU_AIDER_SELECTED_EDITOR_MODEL}" ]]; then
+        echo --editor-model
+        echo "openai/${_SYNU_AIDER_SELECTED_EDITOR_MODEL}"
+    fi
+}
+
+_synu_agent_aider_interactive() {
+    # Check for gum
+    if ! (( $+commands[gum] )); then
+        print -u2 "Error: gum is required for interactive mode. Install: https://github.com/charmbracelet/gum"
+        return 1
+    fi
+
+    # Fetch available models
+    local models_json
+    models_json=$(gum spin --spinner dot --title "Fetching models..." -- \
+        curl -s -H "Authorization: Bearer ${SYNTHETIC_API_KEY}" \
+        "https://api.synthetic.new/openai/v1/models")
+    [[ $? -ne 0 ]] && return 1
+
+    local -a model_names
+    model_names=("${(@f)$(echo "${models_json}" | jq -r '.data[].name')}")
+    [[ $? -ne 0 ]] && return 1
+
+    # Ask if editor model should be set
+    local use_editor_model
+    use_editor_model=$(gum choose --limit 1 \
+        --header "Aider has two modes. Set editor model?" \
+        "No (single model mode)" "Yes (architect + editor mode)")
+    [[ $? -ne 0 ]] && return 1
+
+    # Build flags array
+    local -a flags=()
+
+    # Get current models for display
+    local current_model_id=$(_synu_aider_default model)
+    local current_model_name=$(echo "${models_json}" | \
+        jq -r --arg id "${current_model_id}" '.data[] | select(.id == $id) | .name // "unknown"')
+    local current_editor_id=$(_synu_aider_default editor_model)
+    local current_editor_name=$(echo "${models_json}" | \
+        jq -r --arg id "${current_editor_id}" '.data[] | select(.id == $id) | .name // "unknown"')
+
+    # Select main model
+    local model_name
+    model_name=$(printf "%s\n" "${model_names[@]}" | \
+        gum filter --limit 1 --header "Select main model for Aider (current: ${current_model_name})" \
+        --placeholder "Filter models...")
+    [[ $? -ne 0 ]] && return 1
+
+    local model_id
+    model_id=$(echo "${models_json}" | \
+        jq -r --arg name "${model_name}" '.data[] | select(.name == $name) | .id')
+
+    if [[ -z "${model_id}" ]]; then
+        print -u2 "Error: Could not find model ID"
+        return 1
+    fi
+
+    flags+=(--model="${model_id}")
+
+    # Select editor model if requested
+    if [[ "${use_editor_model}" == "Yes (architect + editor mode)" ]]; then
+        local editor_model_name
+        editor_model_name=$(printf "%s\n" "${model_names[@]}" | \
+            gum filter --limit 1 --header "Select editor model for Aider (current: ${current_editor_name})" \
+            --placeholder "Filter models...")
+        [[ $? -ne 0 ]] && return 1
+
+        local editor_model_id
+        editor_model_id=$(echo "${models_json}" | \
+            jq -r --arg name "${editor_model_name}" '.data[] | select(.name == $name) | .id')
+
+        if [[ -z "${editor_model_id}" ]]; then
+            print -u2 "Error: Could not find editor model ID"
+            return 1
+        fi
+
+        flags+=(--editor-model="${editor_model_id}")
+    fi
+
+    # Offer to save as defaults
+    if gum confirm "Save as default for 'aider'?"; then
+        local flag
+        for flag in "${flags[@]}"; do
+            local key="${flag%%=*}"
+            key="${key#--}"
+            local value="${flag#*=}"
+            # Replace hyphens with underscores for cache keys
+            key="${key//-/_}"
+            _synu_cache_set aider "${key}" "${value}"
+        done
+    fi
+
+    # Output flags for caller to use
+    printf '%s\n' "${flags[@]}"
+}

zsh/functions/_synu_agents/claude.zsh 🔗

@@ -0,0 +1,266 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: Unlicense
+
+# Claude Code agent definition for synu
+# Provides model configuration for routing through Synthetic API
+
+# Fallback defaults (used when no cache entry exists)
+typeset -g _SYNU_CLAUDE_FALLBACK_OPUS="hf:moonshotai/Kimi-K2-Thinking"
+typeset -g _SYNU_CLAUDE_FALLBACK_SONNET="hf:zai-org/GLM-4.6"
+typeset -g _SYNU_CLAUDE_FALLBACK_HAIKU="hf:deepseek-ai/DeepSeek-V3.1-Terminus"
+typeset -g _SYNU_CLAUDE_FALLBACK_AGENT="hf:zai-org/GLM-4.6"
+
+_synu_claude_default() {
+    local slot=$1
+    local cached
+    cached=$(_synu_cache_get claude "${slot}")
+    if [[ $? -eq 0 ]]; then
+        echo "${cached}"
+    else
+        local var_name="_SYNU_CLAUDE_FALLBACK_${(U)slot}"
+        echo "${(P)var_name}"
+    fi
+}
+
+_synu_agent_claude_flags() {
+    echo "L/large="
+    echo "l/light="
+    echo "o/opus="
+    echo "s/sonnet="
+    echo "H/haiku="
+    echo "a/agent="
+}
+
+_synu_agent_claude_env_vars() {
+    echo ANTHROPIC_BASE_URL
+    echo ANTHROPIC_AUTH_TOKEN
+    echo ANTHROPIC_DEFAULT_OPUS_MODEL
+    echo ANTHROPIC_DEFAULT_SONNET_MODEL
+    echo ANTHROPIC_DEFAULT_HAIKU_MODEL
+    echo CLAUDE_CODE_SUBAGENT_MODEL
+    echo CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
+}
+
+_synu_agent_claude_configure() {
+    local -A opts
+    local -a args
+
+    # Parse flags passed from main synu
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --large=*) opts[large]="${1#*=}" ;;
+            --light=*) opts[light]="${1#*=}" ;;
+            --opus=*) opts[opus]="${1#*=}" ;;
+            --sonnet=*) opts[sonnet]="${1#*=}" ;;
+            --haiku=*) opts[haiku]="${1#*=}" ;;
+            --agent=*) opts[agent]="${1#*=}" ;;
+            *) args+=("$1") ;;
+        esac
+        shift
+    done
+
+    # Start with defaults (from cache or fallback)
+    local opus_model=$(_synu_claude_default opus)
+    local sonnet_model=$(_synu_claude_default sonnet)
+    local haiku_model=$(_synu_claude_default haiku)
+    local subagent_model=$(_synu_claude_default agent)
+
+    # Apply group overrides
+    if [[ -n "${opts[large]}" ]]; then
+        opus_model="${opts[large]}"
+        sonnet_model="${opts[large]}"
+        subagent_model="${opts[large]}"
+    fi
+
+    if [[ -n "${opts[light]}" ]]; then
+        haiku_model="${opts[light]}"
+    fi
+
+    # Apply specific overrides (take precedence over groups)
+    [[ -n "${opts[opus]}" ]] && opus_model="${opts[opus]}"
+    [[ -n "${opts[sonnet]}" ]] && sonnet_model="${opts[sonnet]}"
+    [[ -n "${opts[haiku]}" ]] && haiku_model="${opts[haiku]}"
+    [[ -n "${opts[agent]}" ]] && subagent_model="${opts[agent]}"
+
+    # Export environment variables for Claude Code
+    export ANTHROPIC_BASE_URL="https://api.synthetic.new/anthropic"
+    export ANTHROPIC_AUTH_TOKEN="${SYNTHETIC_API_KEY}"
+    export ANTHROPIC_DEFAULT_OPUS_MODEL="${opus_model}"
+    export ANTHROPIC_DEFAULT_SONNET_MODEL="${sonnet_model}"
+    export ANTHROPIC_DEFAULT_HAIKU_MODEL="${haiku_model}"
+    export CLAUDE_CODE_SUBAGENT_MODEL="${subagent_model}"
+    export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
+}
+
+_synu_agent_claude_interactive() {
+    # Check for gum
+    if ! (( $+commands[gum] )); then
+        print -u2 "Error: gum is required for interactive mode. Install: https://github.com/charmbracelet/gum"
+        return 1
+    fi
+
+    # Fetch available models
+    local models_json
+    models_json=$(gum spin --spinner dot --title "Fetching models..." -- \
+        curl -s -H "Authorization: Bearer ${SYNTHETIC_API_KEY}" \
+        "https://api.synthetic.new/openai/v1/models")
+    [[ $? -ne 0 ]] && return 1
+
+    local -a model_names
+    model_names=("${(@f)$(echo "${models_json}" | jq -r '.data[].name')}")
+    [[ $? -ne 0 ]] && return 1
+
+    # Get current models for display
+    local current_opus_id=$(_synu_claude_default opus)
+    local current_sonnet_id=$(_synu_claude_default sonnet)
+    local current_haiku_id=$(_synu_claude_default haiku)
+    local current_agent_id=$(_synu_claude_default agent)
+
+    local current_opus_name=$(echo "${models_json}" | \
+        jq -r --arg id "${current_opus_id}" '.data[] | select(.id == $id) | .name // "unknown"')
+    local current_sonnet_name=$(echo "${models_json}" | \
+        jq -r --arg id "${current_sonnet_id}" '.data[] | select(.id == $id) | .name // "unknown"')
+    local current_haiku_name=$(echo "${models_json}" | \
+        jq -r --arg id "${current_haiku_id}" '.data[] | select(.id == $id) | .name // "unknown"')
+    local current_agent_name=$(echo "${models_json}" | \
+        jq -r --arg id "${current_agent_id}" '.data[] | select(.id == $id) | .name // "unknown"')
+
+    # Prompt for groups vs individual
+    local mode
+    mode=$(gum choose --limit 1 --header "How do you want to select models?" \
+        "Groups" "Individual models")
+    [[ $? -ne 0 ]] && return 1
+
+    # Build flags array
+    local -a flags=()
+
+    if [[ "${mode}" == "Groups" ]]; then
+        # Select which groups to override
+        local -a groups
+        groups=("${(@f)$(gum choose --no-limit \
+            --header "Which group(s) do you want to override?" \
+            "Large (Opus, Sonnet, Sub-agent)" "Light (Haiku)")}")
+        [[ $? -ne 0 ]] && return 1
+
+        local group
+        for group in "${groups[@]}"; do
+            if [[ "${group}" == "Large (Opus, Sonnet, Sub-agent)" ]]; then
+                local model_name
+                model_name=$(printf "%s\n" "${model_names[@]}" | \
+                    gum filter --limit 1 --header "Select model for Large group (opus: ${current_opus_name}, sonnet: ${current_sonnet_name}, agent: ${current_agent_name})" \
+                    --placeholder "Filter models...")
+                [[ $? -ne 0 ]] && return 1
+
+                local model_id
+                model_id=$(echo "${models_json}" | \
+                    jq -r --arg name "${model_name}" '.data[] | select(.name == $name) | .id')
+                [[ -n "${model_id}" ]] && flags+=(--large="${model_id}")
+
+            elif [[ "${group}" == "Light (Haiku)" ]]; then
+                local model_name
+                model_name=$(printf "%s\n" "${model_names[@]}" | \
+                    gum filter --limit 1 --header "Select model for Light group (haiku: ${current_haiku_name})" \
+                    --placeholder "Filter models...")
+                [[ $? -ne 0 ]] && return 1
+
+                local model_id
+                model_id=$(echo "${models_json}" | \
+                    jq -r --arg name "${model_name}" '.data[] | select(.name == $name) | .id')
+                [[ -n "${model_id}" ]] && flags+=(--light="${model_id}")
+            fi
+        done
+    else
+        # Select which individual models to override
+        local -a models
+        models=("${(@f)$(gum choose --no-limit \
+            --header "Which model(s) do you want to override?" \
+            "Opus" "Sonnet" "Haiku" "Sub-agent")}")
+        [[ $? -ne 0 ]] && return 1
+
+        local model_type
+        for model_type in "${models[@]}"; do
+            case "${model_type}" in
+                "Opus")
+                    local model_name
+                    model_name=$(printf "%s\n" "${model_names[@]}" | \
+                        gum filter --limit 1 --header "Select Opus model (current: ${current_opus_name})" \
+                        --placeholder "Filter models...")
+                    [[ $? -ne 0 ]] && return 1
+
+                    local model_id
+                    model_id=$(echo "${models_json}" | \
+                        jq -r --arg name "${model_name}" '.data[] | select(.name == $name) | .id')
+                    [[ -n "${model_id}" ]] && flags+=(--opus="${model_id}")
+                    ;;
+                "Sonnet")
+                    local model_name
+                    model_name=$(printf "%s\n" "${model_names[@]}" | \
+                        gum filter --limit 1 --header "Select Sonnet model (current: ${current_sonnet_name})" \
+                        --placeholder "Filter models...")
+                    [[ $? -ne 0 ]] && return 1
+
+                    local model_id
+                    model_id=$(echo "${models_json}" | \
+                        jq -r --arg name "${model_name}" '.data[] | select(.name == $name) | .id')
+                    [[ -n "${model_id}" ]] && flags+=(--sonnet="${model_id}")
+                    ;;
+                "Haiku")
+                    local model_name
+                    model_name=$(printf "%s\n" "${model_names[@]}" | \
+                        gum filter --limit 1 --header "Select Haiku model (current: ${current_haiku_name})" \
+                        --placeholder "Filter models...")
+                    [[ $? -ne 0 ]] && return 1
+
+                    local model_id
+                    model_id=$(echo "${models_json}" | \
+                        jq -r --arg name "${model_name}" '.data[] | select(.name == $name) | .id')
+                    [[ -n "${model_id}" ]] && flags+=(--haiku="${model_id}")
+                    ;;
+                "Sub-agent")
+                    local model_name
+                    model_name=$(printf "%s\n" "${model_names[@]}" | \
+                        gum filter --limit 1 --header "Select Sub-agent model (current: ${current_agent_name})" \
+                        --placeholder "Filter models...")
+                    [[ $? -ne 0 ]] && return 1
+
+                    local model_id
+                    model_id=$(echo "${models_json}" | \
+                        jq -r --arg name "${model_name}" '.data[] | select(.name == $name) | .id')
+                    [[ -n "${model_id}" ]] && flags+=(--agent="${model_id}")
+                    ;;
+            esac
+        done
+    fi
+
+    # Offer to save as defaults
+    if (( ${#flags} > 0 )); then
+        if gum confirm "Save as default for 'claude'?"; then
+            local flag
+            for flag in "${flags[@]}"; do
+                # Parse --key=value format
+                local key="${flag%%=*}"
+                key="${key#--}"
+                local value="${flag#*=}"
+
+                # Expand group flags to individual slots
+                case "${key}" in
+                    large)
+                        _synu_cache_set claude opus "${value}"
+                        _synu_cache_set claude sonnet "${value}"
+                        _synu_cache_set claude agent "${value}"
+                        ;;
+                    light)
+                        _synu_cache_set claude haiku "${value}"
+                        ;;
+                    *)
+                        _synu_cache_set claude "${key}" "${value}"
+                        ;;
+                esac
+            done
+        fi
+    fi
+
+    # Output flags for caller to use (one per line for proper array capture)
+    printf '%s\n' "${flags[@]}"
+}

zsh/functions/_synu_agents/llxprt.zsh 🔗

@@ -0,0 +1,103 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: Unlicense
+
+# llxprt agent definition for synu
+# Provides model configuration for routing through Synthetic API
+# llxprt only accepts configuration via CLI flags
+
+# Fallback default (used when no cache entry exists)
+typeset -g _SYNU_LLXPRT_FALLBACK_MODEL="hf:zai-org/GLM-4.6"
+
+_synu_llxprt_default() {
+    local cached
+    cached=$(_synu_cache_get llxprt model)
+    if [[ $? -eq 0 ]]; then
+        echo "${cached}"
+    else
+        echo "${_SYNU_LLXPRT_FALLBACK_MODEL}"
+    fi
+}
+
+_synu_agent_llxprt_flags() {
+    echo "m/model="
+}
+
+_synu_agent_llxprt_configure() {
+    local -A opts
+
+    # Parse flags passed from main synu
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --model=*) opts[model]="${1#*=}" ;;
+        esac
+        shift
+    done
+
+    # Start with default (from cache or fallback)
+    typeset -g _SYNU_LLXPRT_SELECTED_MODEL=$(_synu_llxprt_default)
+
+    # Apply override if provided
+    [[ -n "${opts[model]}" ]] && typeset -g _SYNU_LLXPRT_SELECTED_MODEL="${opts[model]}"
+    return 0
+}
+
+_synu_agent_llxprt_args() {
+    echo --provider
+    echo Synthetic
+    echo --model
+    echo "${_SYNU_LLXPRT_SELECTED_MODEL}"
+}
+
+_synu_agent_llxprt_interactive() {
+    # Check for gum
+    if ! (( $+commands[gum] )); then
+        print -u2 "Error: gum is required for interactive mode. Install: https://github.com/charmbracelet/gum"
+        return 1
+    fi
+
+    # Fetch available models
+    local models_json
+    models_json=$(gum spin --spinner dot --title "Fetching models..." -- \
+        curl -s -H "Authorization: Bearer ${SYNTHETIC_API_KEY}" \
+        "https://api.synthetic.new/openai/v1/models")
+    [[ $? -ne 0 ]] && return 1
+
+    local -a model_names
+    model_names=("${(@f)$(echo "${models_json}" | jq -r '.data[].name')}")
+    [[ $? -ne 0 ]] && return 1
+
+    # Get current model for display
+    local current_id=$(_synu_llxprt_default)
+    local current_name=$(echo "${models_json}" | \
+        jq -r --arg id "${current_id}" '.data[] | select(.id == $id) | .name // "unknown"')
+
+    # Select model
+    local model_name
+    model_name=$(printf "%s\n" "${model_names[@]}" | \
+        gum filter --limit 1 --header "Select model for llxprt (current: ${current_name})" \
+        --placeholder "Filter models...")
+    [[ $? -ne 0 ]] && return 1
+
+
+    local model_id
+    model_id=$(echo "${models_json}" | \
+        jq -r --arg name "${model_name}" '.data[] | select(.name == $name) | .id')
+
+
+    if [[ -z "${model_id}" ]]; then
+        print -u2 "Error: Could not find model ID"
+        return 1
+    fi
+
+    # Build flags
+    local flags="--model=${model_id}"
+
+    # Offer to save as default
+    if gum confirm "Save as default for 'llxprt'?"; then
+        _synu_cache_set llxprt model "${model_id}"
+    fi
+
+    # Output flags for caller to use
+    printf '%s\n' "${flags}"
+}

zsh/functions/_synu_agents/opencode.zsh 🔗

@@ -0,0 +1,100 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: Unlicense
+
+# OpenCode agent definition for synu
+# Provides model configuration for routing through Synthetic API
+# OpenCode only accepts model via -m flag, not environment variables
+
+# Fallback default (used when no cache entry exists)
+typeset -g _SYNU_OPENCODE_FALLBACK_MODEL="hf:zai-org/GLM-4.6"
+
+_synu_opencode_default() {
+    local cached
+    cached=$(_synu_cache_get opencode model)
+    if [[ $? -eq 0 ]]; then
+        echo "${cached}"
+    else
+        echo "${_SYNU_OPENCODE_FALLBACK_MODEL}"
+    fi
+}
+
+_synu_agent_opencode_flags() {
+    echo "m/model="
+}
+
+_synu_agent_opencode_configure() {
+    local -A opts
+
+    # Parse flags passed from main synu
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --model=*) opts[model]="${1#*=}" ;;
+        esac
+        shift
+    done
+
+    # Start with default (from cache or fallback)
+    typeset -g _SYNU_OPENCODE_SELECTED_MODEL=$(_synu_opencode_default)
+
+    # Apply override if provided
+    [[ -n "${opts[model]}" ]] && typeset -g _SYNU_OPENCODE_SELECTED_MODEL="${opts[model]}"
+    return 0
+}
+
+_synu_agent_opencode_args() {
+    # Return -m flag with selected model, prefixed with synthetic/ for provider routing
+    echo -m
+    echo "synthetic/${_SYNU_OPENCODE_SELECTED_MODEL}"
+}
+
+_synu_agent_opencode_interactive() {
+    # Check for gum
+    if ! (( $+commands[gum] )); then
+        print -u2 "Error: gum is required for interactive mode. Install: https://github.com/charmbracelet/gum"
+        return 1
+    fi
+
+    # Fetch available models
+    local models_json
+    models_json=$(gum spin --spinner dot --title "Fetching models..." -- \
+        curl -s -H "Authorization: Bearer ${SYNTHETIC_API_KEY}" \
+        "https://api.synthetic.new/openai/v1/models")
+    [[ $? -ne 0 ]] && return 1
+
+    local -a model_names
+    model_names=("${(@f)$(echo "${models_json}" | jq -r '.data[].name')}")
+    [[ $? -ne 0 ]] && return 1
+
+    # Get current model for display
+    local current_id=$(_synu_opencode_default)
+    local current_name=$(echo "${models_json}" | \
+        jq -r --arg id "${current_id}" '.data[] | select(.id == $id) | .name // "unknown"')
+
+    # Select model
+    local model_name
+    model_name=$(printf "%s\n" "${model_names[@]}" | \
+        gum filter --limit 1 --header "Select model for OpenCode (current: ${current_name})" \
+        --placeholder "Filter models...")
+    [[ $? -ne 0 ]] && return 1
+
+    local model_id
+    model_id=$(echo "${models_json}" | \
+        jq -r --arg name "${model_name}" '.data[] | select(.name == $name) | .id')
+
+    if [[ -z "${model_id}" ]]; then
+        print -u2 "Error: Could not find model ID"
+        return 1
+    fi
+
+    # Build flags
+    local flags="--model=${model_id}"
+
+    # Offer to save as default
+    if gum confirm "Save as default for 'opencode'?"; then
+        _synu_cache_set opencode model "${model_id}"
+    fi
+
+    # Output flags for caller to use
+    printf '%s\n' "${flags}"
+}

zsh/functions/_synu_agents/qwen.zsh 🔗

@@ -0,0 +1,104 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: Unlicense
+
+# Qwen Code agent definition for synu
+# Provides model configuration for routing through Synthetic API
+# Qwen Code only accepts configuration via environment variables
+
+# Fallback default (used when no cache entry exists)
+typeset -g _SYNU_QWEN_FALLBACK_MODEL="hf:zai-org/GLM-4.6"
+
+_synu_qwen_default() {
+    local cached
+    cached=$(_synu_cache_get qwen model)
+    if [[ $? -eq 0 ]]; then
+        echo "${cached}"
+    else
+        echo "${_SYNU_QWEN_FALLBACK_MODEL}"
+    fi
+}
+
+_synu_agent_qwen_flags() {
+    echo "m/model="
+}
+
+_synu_agent_qwen_env_vars() {
+    echo OPENAI_API_KEY
+    echo OPENAI_BASE_URL
+    echo OPENAI_MODEL
+}
+
+_synu_agent_qwen_configure() {
+    local -A opts
+
+    # Parse flags passed from main synu
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --model=*) opts[model]="${1#*=}" ;;
+        esac
+        shift
+    done
+
+    # Start with default (from cache or fallback)
+    local model=$(_synu_qwen_default)
+
+    # Apply override if provided
+    [[ -n "${opts[model]}" ]] && model="${opts[model]}"
+
+    # Export environment variables for Qwen Code
+    export OPENAI_API_KEY="${SYNTHETIC_API_KEY}"
+    export OPENAI_BASE_URL="https://api.synthetic.new/openai/v1"
+    export OPENAI_MODEL="${model}"
+}
+
+_synu_agent_qwen_interactive() {
+    # Check for gum
+    if ! (( $+commands[gum] )); then
+        print -u2 "Error: gum is required for interactive mode. Install: https://github.com/charmbracelet/gum"
+        return 1
+    fi
+
+    # Fetch available models
+    local models_json
+    models_json=$(gum spin --spinner dot --title "Fetching models..." -- \
+        curl -s -H "Authorization: Bearer ${SYNTHETIC_API_KEY}" \
+        "https://api.synthetic.new/openai/v1/models")
+    [[ $? -ne 0 ]] && return 1
+
+    local -a model_names
+    model_names=("${(@f)$(echo "${models_json}" | jq -r '.data[].name')}")
+    [[ $? -ne 0 ]] && return 1
+
+    # Get current model for display
+    local current_id=$(_synu_qwen_default)
+    local current_name=$(echo "${models_json}" | \
+        jq -r --arg id "${current_id}" '.data[] | select(.id == $id) | .name // "unknown"')
+
+    # Select model
+    local model_name
+    model_name=$(printf "%s\n" "${model_names[@]}" | \
+        gum filter --limit 1 --header "Select model for Qwen Code (current: ${current_name})" \
+        --placeholder "Filter models...")
+    [[ $? -ne 0 ]] && return 1
+
+    local model_id
+    model_id=$(echo "${models_json}" | \
+        jq -r --arg name "${model_name}" '.data[] | select(.name == $name) | .id')
+
+    if [[ -z "${model_id}" ]]; then
+        print -u2 "Error: Could not find model ID"
+        return 1
+    fi
+
+    # Build flags
+    local flags="--model=${model_id}"
+
+    # Offer to save as default
+    if gum confirm "Save as default for 'qwen'?"; then
+        _synu_cache_set qwen model "${model_id}"
+    fi
+
+    # Output flags for caller to use
+    printf '%s\n' "${flags}"
+}

zsh/functions/_synu_cache.zsh 🔗

@@ -0,0 +1,65 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: Unlicense
+
+# Cache management for synu model preferences
+# File format: agent.slot = model_id (one per line, # comments allowed)
+
+typeset -g _SYNU_CACHE_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/synu/models.conf"
+
+_synu_cache_get() {
+    local agent=$1
+    local slot=$2
+    local key="${agent}.${slot}"
+
+    if [[ ! -f "${_SYNU_CACHE_FILE}" ]]; then
+        return 1
+    fi
+
+    # Match "key = value" or "key=value", ignoring comments and whitespace
+    local line value
+    while IFS= read -r line; do
+        # Skip comments and empty lines
+        [[ "${line}" =~ ^[[:space:]]*# ]] && continue
+        [[ -z "${line}" ]] && continue
+
+        if [[ "${line}" =~ ^${key}[[:space:]]*=[[:space:]]*(.+)$ ]]; then
+            value="${match[1]}"
+            # Trim whitespace
+            echo "${value## }"
+            return 0
+        fi
+    done < "${_SYNU_CACHE_FILE}"
+
+    return 1
+}
+
+_synu_cache_set() {
+    local agent=$1
+    local slot=$2
+    local value=$3
+    local key="${agent}.${slot}"
+
+    # Ensure directory exists
+    mkdir -p "${_SYNU_CACHE_FILE:h}"
+
+    if [[ ! -f "${_SYNU_CACHE_FILE}" ]]; then
+        # Create new file with header
+        cat > "${_SYNU_CACHE_FILE}" <<'EOF'
+# synu model preferences
+# Format: agent.slot = model_id
+
+EOF
+    fi
+
+    # Check if key already exists
+    if grep -q "^${key}[[:space:]]*=" "${_SYNU_CACHE_FILE}" 2>/dev/null; then
+        # Replace existing line
+        local tmp=$(mktemp)
+        sed "s|^${key}[[:space:]]*=.*|${key} = ${value}|" "${_SYNU_CACHE_FILE}" > "${tmp}"
+        mv "${tmp}" "${_SYNU_CACHE_FILE}"
+    else
+        # Append new line
+        echo "${key} = ${value}" >> "${_SYNU_CACHE_FILE}"
+    fi
+}

zsh/functions/_synu_get_quota.zsh 🔗

@@ -0,0 +1,33 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: Unlicense
+
+# Private function to fetch quota from Synthetic API
+
+_synu_get_quota() {
+    # Check if API key is set
+    if [[ -z "${SYNTHETIC_API_KEY}" ]]; then
+        print -u2 "Error: SYNTHETIC_API_KEY environment variable not set"
+        print -u2 "Set it with: export SYNTHETIC_API_KEY=your_api_key"
+        return 1
+    fi
+
+    # Fetch quota from API with fresh request (no caching)
+    local response
+    response=$(curl -s -f -H "Authorization: Bearer ${SYNTHETIC_API_KEY}" \
+        "https://api.synthetic.new/v2/quotas" 2>/dev/null)
+
+    # Check if curl failed or response is empty
+    if [[ $? -ne 0 || -z "${response}" ]]; then
+        return 1
+    fi
+
+    # Parse JSON response safely with jq
+    # Use fallback to 0 if field is missing/empty
+    local requests limit
+    requests=$(echo "${response}" | jq -r '.subscription.requests // 0')
+    limit=$(echo "${response}" | jq -r '.subscription.limit // 0')
+
+    # Return the values as space-separated string
+    echo "${requests} ${limit}"
+}

zsh/functions/synu.zsh 🔗

@@ -0,0 +1,255 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: Unlicense
+
+# Universal agent wrapper with Synthetic API quota tracking
+
+synu() {
+    emulate -L zsh
+    setopt extended_glob
+
+    # If no arguments, just print the quota
+    if [[ $# -lt 1 ]]; then
+        local quota
+        quota=$(_synu_get_quota)
+        if [[ $? -ne 0 ]]; then
+            print -u2 "Error: Could not fetch quota"
+            return 1
+        fi
+        local requests=${quota%% *}
+        local limit=${quota##* }
+        local remaining=$(( limit - requests ))
+        local percent_used=$(( requests * 100.0 / limit ))
+        local percent_int remaining_fmt
+        printf -v percent_int "%.0f" "${percent_used}"
+        printf -v remaining_fmt "%.2f" "${remaining}"
+
+        printf "Usage: "
+        if (( percent_int < 33 )); then
+            print -Pn "%K{green}%F{black}"
+        elif (( percent_int < 67 )); then
+            print -Pn "%K{yellow}%F{black}"
+        else
+            print -Pn "%K{red}%F{black}"
+        fi
+        printf " %s%% " "${percent_int}"
+        print -Pn "%k%f"
+        printf " (%s/%s remaining)\n" "${remaining_fmt}" "${limit}"
+        return 0
+    fi
+
+    # Check for interactive mode: synu i <agent> [args...]
+    if [[ "$1" == "i" ]]; then
+        if [[ $# -lt 2 ]]; then
+            print -u2 "Error: Interactive mode requires an agent name"
+            print -u2 "Usage: synu i <agent> [args...]"
+            return 1
+        fi
+
+        local agent=$2
+        shift 2
+        local -a agent_args=("$@")
+
+        # Source agent definition if it exists
+        local agent_file="${SYNU_PLUGIN_DIR}/functions/_synu_agents/${agent}.zsh"
+        if [[ -f "${agent_file}" ]]; then
+            source "${agent_file}"
+        fi
+
+        # Check for interactive function
+        if ! (( $+functions[_synu_agent_${agent}_interactive] )); then
+            print -u2 "Error: Agent '${agent}' does not support interactive mode"
+            return 1
+        fi
+
+        # Get flags from interactive selection
+        local -a interactive_flags
+        interactive_flags=("${(@f)$(_synu_agent_${agent}_interactive)}")
+        local interactive_status=$?
+        if [[ ${interactive_status} -ne 0 ]]; then
+            return ${interactive_status}
+        fi
+
+
+        # Recursively call synu with selected flags
+        synu "${agent}" ${interactive_flags[@]} ${agent_args[@]}
+        return $?
+    fi
+
+    # Extract agent name (first argument) and remaining args
+    local agent=$1
+    shift
+    local -a agent_args=("$@")
+
+    # Source agent definition if it exists
+    local agent_file="${SYNU_PLUGIN_DIR}/functions/_synu_agents/${agent}.zsh"
+    if [[ -f "${agent_file}" ]]; then
+        source "${agent_file}"
+    fi
+
+    # Check if agent has a configuration function
+    if (( $+functions[_synu_agent_${agent}_configure] )); then
+        # Get flag specification
+        local -a flag_spec
+        if (( $+functions[_synu_agent_${agent}_flags] )); then
+            flag_spec=($(_synu_agent_${agent}_flags))
+        fi
+
+        # Manually extract synu-specific flags while preserving agent flags
+        local -a parsed_flags=()
+        local -a remaining_args=()
+        local -A flag_values=()
+
+        # Known synu flags across all agents (short and long forms)
+        local -a known_short=(L l o s H a m e)
+        local -a known_long=(large light opus sonnet haiku agent model editor-model)
+
+        local i=1
+        while (( i <= ${#agent_args} )); do
+            local arg="${agent_args[i]}"
+            local consumed=0
+
+            # Check for --flag=value format
+            if [[ "${arg}" == --*=* ]]; then
+                local flag_name="${arg%%=*}"
+                flag_name="${flag_name#--}"
+                local flag_value="${arg#*=}"
+
+                if [[ " ${known_long[*]} " == *" ${flag_name} "* ]]; then
+                    flag_values[${flag_name}]="${flag_value}"
+                    parsed_flags+=(--${flag_name}="${flag_value}")
+                    consumed=1
+                fi
+            # Check for --flag value format
+            elif [[ "${arg}" == --* ]]; then
+                local flag_name="${arg#--}"
+
+                if [[ " ${known_long[*]} " == *" ${flag_name} "* ]]; then
+                    (( i++ ))
+                    if (( i <= ${#agent_args} )); then
+                        flag_values[${flag_name}]="${agent_args[i]}"
+                        parsed_flags+=(--${flag_name}="${agent_args[i]}")
+                    fi
+                    consumed=1
+                fi
+            # Check for -f value format (short flags)
+            elif [[ "${arg}" == -? ]]; then
+                local flag_char="${arg#-}"
+
+                if [[ " ${known_short[*]} " == *" ${flag_char} "* ]]; then
+                    (( i++ ))
+                    if (( i <= ${#agent_args} )); then
+                        # Map short to long for storage
+                        local long_name
+                        case "${flag_char}" in
+                            L) long_name="large" ;;
+                            l) long_name="light" ;;
+                            o) long_name="opus" ;;
+                            s) long_name="sonnet" ;;
+                            H) long_name="haiku" ;;
+                            a) long_name="agent" ;;
+                            m) long_name="model" ;;
+                            e) long_name="editor-model" ;;
+                        esac
+                        flag_values[${long_name}]="${agent_args[i]}"
+                        parsed_flags+=(--${long_name}="${agent_args[i]}")
+                    fi
+                    consumed=1
+                fi
+            fi
+
+            if (( ! consumed )); then
+                remaining_args+=("${arg}")
+            fi
+            (( i++ ))
+        done
+
+        agent_args=("${remaining_args[@]}")
+
+        # Configure the agent environment
+        _synu_agent_${agent}_configure ${parsed_flags[@]}
+        if [[ $? -ne 0 ]]; then
+            return 1
+        fi
+    fi
+
+    # Check if agent provides extra CLI arguments (for agents that don't use env vars)
+    local -a extra_args=()
+    if (( $+functions[_synu_agent_${agent}_args] )); then
+        extra_args=($(_synu_agent_${agent}_args))
+    fi
+
+    # Fetch quota before agent execution
+    local quota_before
+    quota_before=$(_synu_get_quota)
+    if [[ $? -ne 0 ]]; then
+        # If quota fetch fails, still execute the agent but warn
+        print -u2 "Warning: Could not fetch quota before execution"
+        quota_before="0 0"
+    fi
+
+    # Parse pre-execution quota values
+    local requests_before=${quota_before%% *}
+    local limit=${quota_before##* }
+
+    # Execute the agent with all arguments passed through unchanged
+    # Use 'command' to bypass function recursion and call the actual binary
+    # extra_args contains agent-specific CLI flags (e.g., -m for opencode)
+    command ${agent} ${extra_args[@]} ${agent_args[@]}
+    local exit_status=$?
+
+    # Clean up environment variables set by agent
+    if (( $+functions[_synu_agent_${agent}_env_vars] )); then
+        local var
+        for var in $(_synu_agent_${agent}_env_vars); do
+            unset "${var}"
+        done
+    fi
+
+    # Fetch quota after agent execution
+    local quota_after
+    quota_after=$(_synu_get_quota)
+    if [[ $? -ne 0 ]]; then
+        # If quota fetch fails, still exit but warn
+        print -u2 "Warning: Could not fetch quota after execution"
+        return ${exit_status}
+    fi
+
+    # Parse post-execution quota values
+    local requests_after=${quota_after%% *}
+    # Note: limit is the same before and after, using pre-execution value
+
+    # Calculate session usage: final_requests - initial_requests
+    local session_usage=$(( requests_after - requests_before ))
+
+    # Calculate remaining quota: limit - requests_after
+    local remaining=$(( limit - requests_after ))
+
+    # Calculate percentage used: (requests_after * 100) / limit
+    local percent_used=$(( requests_after * 100.0 / limit ))
+    local percent_int remaining_fmt session_fmt
+    printf -v percent_int "%.0f" "${percent_used}"
+    printf -v remaining_fmt "%.2f" "${remaining}"
+    printf -v session_fmt "%.1f" "${session_usage}"
+
+    # Display session usage and remaining quota with color
+    # Force a newline first for proper separation from agent output
+    printf "\n"
+    printf "Session: %s requests\n" "${session_fmt}"
+    printf "Overall: "
+
+    # Determine color based on usage percentage
+    # Green: <33%, Yellow: 33-66%, Red: >66%
+    if (( percent_int < 33 )); then
+        print -Pn "%K{green}%F{black}"
+    elif (( percent_int < 67 )); then
+        print -Pn "%K{yellow}%F{black}"
+    else
+        print -Pn "%K{red}%F{black}"
+    fi
+    printf " %s%% " "${percent_int}"
+    print -Pn "%k%f"
+    printf " (%s/%s remaining)\n" "${remaining_fmt}" "${limit}"
+
+    return ${exit_status}
+}

zsh/synu.plugin.zsh 🔗

@@ -0,0 +1,27 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: Unlicense
+
+# synu - Universal agent wrapper with Synthetic API quota tracking
+# Zsh plugin entry point
+
+# Standard $0 handling per Zsh Plugin Standard
+0="${ZERO:-${${0:#$ZSH_ARGZERO}:-${(%):-%N}}}"
+0="${${(M)0:#/*}:-$PWD/$0}"
+
+typeset -g SYNU_PLUGIN_DIR="${0:h}"
+
+# Add functions directory to fpath for autoloading
+if [[ -z "${fpath[(r)${SYNU_PLUGIN_DIR}/functions]}" ]]; then
+    fpath=("${SYNU_PLUGIN_DIR}/functions" "${SYNU_PLUGIN_DIR}/functions/_synu_agents" $fpath)
+fi
+
+# Add completions directory to fpath
+if [[ -z "${fpath[(r)${SYNU_PLUGIN_DIR}/completions]}" ]]; then
+    fpath=("${SYNU_PLUGIN_DIR}/completions" $fpath)
+fi
+
+# Source the main function and helpers (they define functions internally)
+source "${SYNU_PLUGIN_DIR}/functions/synu.zsh"
+source "${SYNU_PLUGIN_DIR}/functions/_synu_get_quota.zsh"
+source "${SYNU_PLUGIN_DIR}/functions/_synu_cache.zsh"