Detailed changes
@@ -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
@@ -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!)
-
+
## 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
@@ -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}}'
@@ -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
@@ -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[@]}"
+}
@@ -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[@]}"
+}
@@ -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}"
+}
@@ -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}"
+}
@@ -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}"
+}
@@ -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
+}
@@ -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}"
+}
@@ -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}
+}
@@ -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"