feat(cache): add persistent model preferences

Amolith created

Introduces XDG-compliant cache for storing user model preferences across
sessions. Cache is stored in $XDG_CONFIG_HOME/synu/models.conf using
simple key=value format (agent.slot = model_id).

Claude agent now checks cache before falling back to hardcoded defaults,
and interactive mode can save selections. Defaults renamed to "fallback"
to clarify they're only used when no cache entry exists.

Assisted-by: Claude Sonnet 4.5 via Crush

Change summary

functions/_synu_agents/claude.fish | 59 ++++++++++++++++++++++++++-----
functions/_synu_cache.fish         | 54 +++++++++++++++++++++++++++++
2 files changed, 103 insertions(+), 10 deletions(-)

Detailed changes

functions/_synu_agents/claude.fish 🔗

@@ -5,11 +5,25 @@
 # Claude Code agent definition for synu
 # Provides model configuration for routing through Synthetic API
 
-# Default models
-set -g _synu_claude_default_opus "hf:moonshotai/Kimi-K2-Thinking"
-set -g _synu_claude_default_sonnet "hf:MiniMaxAI/MiniMax-M2"
-set -g _synu_claude_default_haiku "hf:deepseek-ai/DeepSeek-V3.1-Terminus"
-set -g _synu_claude_default_subagent "hf:MiniMaxAI/MiniMax-M2"
+# Source cache functions
+source (status dirname)/../_synu_cache.fish
+
+# Fallback defaults (used when no cache entry exists)
+set -g _synu_claude_fallback_opus "hf:moonshotai/Kimi-K2-Thinking"
+set -g _synu_claude_fallback_sonnet "hf:MiniMaxAI/MiniMax-M2"
+set -g _synu_claude_fallback_haiku "hf:deepseek-ai/DeepSeek-V3.1-Terminus"
+set -g _synu_claude_fallback_agent "hf:MiniMaxAI/MiniMax-M2"
+
+function _synu_claude_default --description "Get default model: _synu_claude_default slot"
+    set -l slot $argv[1]
+    set -l cached (_synu_cache_get claude $slot)
+    if test $status -eq 0
+        echo $cached
+    else
+        set -l var_name _synu_claude_fallback_$slot
+        echo $$var_name
+    end
+end
 
 function _synu_agent_claude_flags --description "Return argparse-compatible flag specification"
     echo "L/large="
@@ -25,11 +39,11 @@ function _synu_agent_claude_configure --description "Configure Claude Code envir
     argparse 'L/large=' 'l/light=' 'o/opus=' 's/sonnet=' 'H/haiku=' 'a/agent=' -- $argv
     or return 1
 
-    # Start with defaults
-    set -l opus_model $_synu_claude_default_opus
-    set -l sonnet_model $_synu_claude_default_sonnet
-    set -l haiku_model $_synu_claude_default_haiku
-    set -l subagent_model $_synu_claude_default_subagent
+    # Start with defaults (from cache or fallback)
+    set -l opus_model (_synu_claude_default opus)
+    set -l sonnet_model (_synu_claude_default sonnet)
+    set -l haiku_model (_synu_claude_default haiku)
+    set -l subagent_model (_synu_claude_default agent)
 
     # Apply group overrides
     if set -q _flag_large
@@ -173,6 +187,31 @@ function _synu_agent_claude_interactive --description "Interactive model selecti
         end
     end
 
+    # Offer to save as defaults
+    if test (count $flags) -gt 0
+        if gum confirm "Save as default for 'claude'?"
+            for flag in $flags
+                # Parse --key=value format
+                set -l parts (string match -r '^--([^=]+)=(.+)$' $flag)
+                if test -n "$parts[2]"
+                    set -l key $parts[2]
+                    set -l value $parts[3]
+                    # Expand group flags to individual slots
+                    switch $key
+                        case large
+                            _synu_cache_set claude opus $value
+                            _synu_cache_set claude sonnet $value
+                            _synu_cache_set claude agent $value
+                        case light
+                            _synu_cache_set claude haiku $value
+                        case '*'
+                            _synu_cache_set claude $key $value
+                    end
+                end
+            end
+        end
+    end
+
     # Output flags for caller to use
     echo $flags
 end

functions/_synu_cache.fish 🔗

@@ -0,0 +1,54 @@
+# 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)
+
+set -g _synu_cache_file (set -q XDG_CONFIG_HOME; and echo "$XDG_CONFIG_HOME"; or echo "$HOME/.config")"/synu/models.conf"
+
+function _synu_cache_get --description "Get a cached value: _synu_cache_get agent slot"
+    set -l agent $argv[1]
+    set -l slot $argv[2]
+    set -l key "$agent.$slot"
+
+    if not test -f "$_synu_cache_file"
+        return 1
+    end
+
+    # Match "key = value" or "key=value", ignoring comments and whitespace
+    set -l match (string match -r "^$key\\s*=\\s*(.+)" < "$_synu_cache_file")
+    if test -n "$match[2]"
+        string trim "$match[2]"
+        return 0
+    end
+    return 1
+end
+
+function _synu_cache_set --description "Set a cached value: _synu_cache_set agent slot value"
+    set -l agent $argv[1]
+    set -l slot $argv[2]
+    set -l value $argv[3]
+    set -l key "$agent.$slot"
+
+    # Ensure directory exists
+    mkdir -p (dirname "$_synu_cache_file")
+
+    if not test -f "$_synu_cache_file"
+        # Create new file with header
+        echo "# synu model preferences" > "$_synu_cache_file"
+        echo "# Format: agent.slot = model_id" >> "$_synu_cache_file"
+        echo "" >> "$_synu_cache_file"
+    end
+
+    # Check if key already exists
+    if string match -rq "^$key\\s*=" < "$_synu_cache_file"
+        # Replace existing line
+        set -l tmp (mktemp)
+        string replace -r "^$key\\s*=.*" "$key = $value" < "$_synu_cache_file" > "$tmp"
+        mv "$tmp" "$_synu_cache_file"
+    else
+        # Append new line
+        echo "$key = $value" >> "$_synu_cache_file"
+    end
+end