| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "strings" |
| 9 | "syscall" |
| 10 | |
| 11 | "sourcecraft.dev/bigbes/claudio/cache" |
| 12 | "sourcecraft.dev/bigbes/claudio/config" |
| 13 | "sourcecraft.dev/bigbes/claudio/copilot" |
| 14 | "sourcecraft.dev/bigbes/claudio/provider" |
| 15 | "sourcecraft.dev/bigbes/claudio/proxy" |
| 16 | "sourcecraft.dev/bigbes/claudio/tui" |
| 17 | tea "github.com/charmbracelet/bubbletea" |
| 18 | ) |
| 19 | |
| 20 | func main() { |
| 21 | if err := run(); err != nil { |
| 22 | fmt.Fprintf(os.Stderr, "claudio: %v\n", err) |
| 23 | os.Exit(1) |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | func run() error { |
| 28 | cfg, err := config.Load() |
| 29 | if err != nil { |
| 30 | return fmt.Errorf("loading config: %w", err) |
| 31 | } |
| 32 | |
| 33 | cdb, _ := cache.Open() |
| 34 | if cdb != nil { |
| 35 | defer cdb.Close() |
| 36 | } |
| 37 | |
| 38 | initFlag, helpFlag, completionsShell, modelName, claudeArgs := parseArgs() |
| 39 | |
| 40 | if helpFlag { |
| 41 | fmt.Printf(`Usage: claudio [flags] [-- claude-args...] |
| 42 |
|
| 43 | Flags: |
| 44 | --configure Open the configuration screen |
| 45 | --model <name> Switch the active provider |
| 46 | -h, --help Show this help message |
| 47 |
|
| 48 | Subcommands: |
| 49 | completions <shell> Print shell completions (zsh, bash) |
| 50 |
|
| 51 | Config: %s |
| 52 | `, config.Path()) |
| 53 | return nil |
| 54 | } |
| 55 | |
| 56 | if completionsShell != "" { |
| 57 | return printCompletions(completionsShell) |
| 58 | } |
| 59 | |
| 60 | // --configure: open config screen and exit. |
| 61 | if initFlag { |
| 62 | _, quit := showTUI(cfg, cdb) |
| 63 | if quit { |
| 64 | return nil |
| 65 | } |
| 66 | if err := config.Save(cfg); err != nil { |
| 67 | return fmt.Errorf("saving config: %w", err) |
| 68 | } |
| 69 | // Copilot needs OAuth before we can use it. |
| 70 | if cfg.ActiveProvider == "copilot" { |
| 71 | pc := cfg.Providers["copilot"] |
| 72 | if pc.APIKey == "" { |
| 73 | fmt.Println("Authenticating with GitHub Copilot...") |
| 74 | token, err := copilot.DeviceAuth() |
| 75 | if err != nil { |
| 76 | return fmt.Errorf("copilot auth: %w", err) |
| 77 | } |
| 78 | pc.APIKey = token |
| 79 | cfg.Providers["copilot"] = pc |
| 80 | if err := config.Save(cfg); err != nil { |
| 81 | return fmt.Errorf("saving config: %w", err) |
| 82 | } |
| 83 | // After fresh auth, show model picker. |
| 84 | if err := showCopilotModelPicker(cfg, cdb); err != nil { |
| 85 | return err |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | return nil |
| 90 | } |
| 91 | |
| 92 | // --model <name>: switch active provider and persist. |
| 93 | if modelName != "" { |
| 94 | cfg.ActiveProvider = modelName |
| 95 | if err := config.Save(cfg); err != nil { |
| 96 | return fmt.Errorf("saving config: %w", err) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | // First run: no active provider → show TUI, then launch. |
| 101 | if cfg.ActiveProvider == "" { |
| 102 | _, quit := showTUI(cfg, cdb) |
| 103 | if quit || cfg.ActiveProvider == "" { |
| 104 | return nil |
| 105 | } |
| 106 | if err := config.Save(cfg); err != nil { |
| 107 | return fmt.Errorf("saving config: %w", err) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | pc, ok := cfg.Providers[cfg.ActiveProvider] |
| 112 | // For NoAuth providers (e.g. local Ollama), use a dummy API key. |
| 113 | if reg, regOK := provider.Registry[cfg.ActiveProvider]; regOK && reg.NoAuth && pc.APIKey == "" { |
| 114 | pc.APIKey = "ollama" |
| 115 | cfg.Providers[cfg.ActiveProvider] = pc |
| 116 | } |
| 117 | if !ok || pc.APIKey == "" { |
| 118 | if cfg.ActiveProvider == "copilot" { |
| 119 | fmt.Println("Authenticating with GitHub Copilot...") |
| 120 | token, err := copilot.DeviceAuth() |
| 121 | if err != nil { |
| 122 | return fmt.Errorf("copilot auth: %w", err) |
| 123 | } |
| 124 | pc.APIKey = token |
| 125 | cfg.Providers["copilot"] = pc |
| 126 | if err := config.Save(cfg); err != nil { |
| 127 | return fmt.Errorf("saving config: %w", err) |
| 128 | } |
| 129 | } else { |
| 130 | return fmt.Errorf("no API key for %q — run 'claudio --configure' to configure", cfg.ActiveProvider) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | prov := config.ResolveProvider(cfg.ActiveProvider, pc) |
| 135 | |
| 136 | // Resolve proxy URLs. "proxy" is used by the Go translation proxy for |
| 137 | // upstream requests (supports SOCKS5/HTTP/HTTPS). "claude_proxy" is an |
| 138 | // HTTP(S) proxy set as HTTP_PROXY/HTTPS_PROXY for Claude Code (Node.js |
| 139 | // does not support SOCKS5 proxies). Per-provider overrides global. |
| 140 | proxyURL := cfg.Proxy |
| 141 | if pc.Proxy != "" { |
| 142 | proxyURL = pc.Proxy |
| 143 | } |
| 144 | if proxyURL == "none" { |
| 145 | proxyURL = "" |
| 146 | } |
| 147 | |
| 148 | claudeProxyURL := cfg.ClaudeProxy |
| 149 | if pc.ClaudeProxy != "" { |
| 150 | claudeProxyURL = pc.ClaudeProxy |
| 151 | } |
| 152 | if claudeProxyURL == "none" { |
| 153 | claudeProxyURL = "" |
| 154 | } |
| 155 | |
| 156 | var needLocalProc bool |
| 157 | // For OpenAI-compatible providers, start local translation proxy. |
| 158 | if prov.Compat == "openai" { |
| 159 | tokenFunc := proxy.StaticToken(pc.APIKey) |
| 160 | var extraHeaders map[string]string |
| 161 | if cfg.ActiveProvider == "copilot" { |
| 162 | tokenFunc = copilot.NewTokenSource(pc.APIKey).Token |
| 163 | extraHeaders = copilot.Headers() |
| 164 | } |
| 165 | addr, shutdown, err := proxy.Start(prov.BaseURL, tokenFunc, extraHeaders, proxyURL) |
| 166 | if err != nil { |
| 167 | return fmt.Errorf("starting proxy: %w", err) |
| 168 | } |
| 169 | defer shutdown() |
| 170 | os.Setenv("ANTHROPIC_BASE_URL", "http://"+addr) |
| 171 | os.Setenv("ANTHROPIC_AUTH_TOKEN", "proxy") |
| 172 | // Claude Code connects to our local proxy; no external proxy needed. |
| 173 | os.Setenv("NO_PROXY", "127.0.0.1") |
| 174 | needLocalProc = true |
| 175 | } else { |
| 176 | os.Setenv("ANTHROPIC_BASE_URL", prov.BaseURL) |
| 177 | os.Setenv(prov.AuthEnvVar, pc.APIKey) |
| 178 | } |
| 179 | |
| 180 | // For Anthropic-compat providers with a SOCKS5 proxy, start a local |
| 181 | // HTTP CONNECT proxy that bridges to SOCKS5. |
| 182 | if !needLocalProc && proxyURL != "" && strings.HasPrefix(proxyURL, "socks5") && claudeProxyURL == "" { |
| 183 | bridgeAddr, bridgeShutdown, err := proxy.StartSOCKSBridge(proxyURL) |
| 184 | if err != nil { |
| 185 | return fmt.Errorf("starting SOCKS bridge: %w", err) |
| 186 | } |
| 187 | defer bridgeShutdown() |
| 188 | claudeProxyURL = "http://" + bridgeAddr |
| 189 | needLocalProc = true |
| 190 | } |
| 191 | |
| 192 | // Set HTTP(S) proxy for Claude Code if configured. |
| 193 | if claudeProxyURL != "" { |
| 194 | os.Setenv("HTTP_PROXY", claudeProxyURL) |
| 195 | os.Setenv("HTTPS_PROXY", claudeProxyURL) |
| 196 | } |
| 197 | |
| 198 | os.Setenv("ANTHROPIC_MODEL", prov.Model) |
| 199 | os.Setenv("ANTHROPIC_SMALL_FAST_MODEL", prov.SmallModel) |
| 200 | os.Setenv("ANTHROPIC_DEFAULT_SONNET_MODEL", prov.SonnetModel) |
| 201 | os.Setenv("ANTHROPIC_DEFAULT_OPUS_MODEL", prov.OpusModel) |
| 202 | os.Setenv("ANTHROPIC_DEFAULT_HAIKU_MODEL", prov.HaikuModel) |
| 203 | os.Setenv("API_TIMEOUT_MS", prov.TimeoutMS) |
| 204 | os.Setenv("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") |
| 205 | os.Setenv("CLAUDE_CODE_LOG_LEVEL", "debug") |
| 206 | os.Setenv("ENABLE_LSP_TOOL", "1") |
| 207 | |
| 208 | for k, v := range pc.Env { |
| 209 | os.Setenv(k, v) |
| 210 | } |
| 211 | |
| 212 | // Claude Code assumes a 200k context window and auto-compacts at ~95%. |
| 213 | // For models with smaller windows, lower the compaction threshold so |
| 214 | // Claude Code never tries to fill beyond the real limit. |
| 215 | const defaultCtx = 200_000 |
| 216 | if prov.ContextWindow > 0 && prov.ContextWindow < defaultCtx { |
| 217 | pct := prov.ContextWindow * 95 / defaultCtx |
| 218 | os.Setenv("CLAUDE_AUTOCOMPACT_PCT_OVERRIDE", fmt.Sprintf("%d", pct)) |
| 219 | } |
| 220 | |
| 221 | claudeBin, err := exec.LookPath("claude") |
| 222 | if err != nil { |
| 223 | return fmt.Errorf("claude not found in PATH: %w", err) |
| 224 | } |
| 225 | |
| 226 | if needLocalProc { |
| 227 | cmd := exec.Command(claudeBin, claudeArgs...) |
| 228 | cmd.Stdin = os.Stdin |
| 229 | cmd.Stdout = os.Stdout |
| 230 | cmd.Stderr = os.Stderr |
| 231 | cmd.Env = os.Environ() |
| 232 | return cmd.Run() |
| 233 | } |
| 234 | |
| 235 | return syscall.Exec(claudeBin, append([]string{"claude"}, claudeArgs...), os.Environ()) |
| 236 | } |
| 237 | |
| 238 | func showCopilotModelPicker(cfg *config.Config, cdb *cache.DB) error { |
| 239 | pc := cfg.Providers["copilot"] |
| 240 | models, err := fetchCopilotModels(cdb, pc.APIKey) |
| 241 | if err != nil { |
| 242 | fmt.Fprintf(os.Stderr, "Warning: could not fetch models: %v\n", err) |
| 243 | return nil |
| 244 | } |
| 245 | if len(models) == 0 { |
| 246 | return nil |
| 247 | } |
| 248 | m := tui.NewModelPicker(cfg, cdb, models) |
| 249 | result, err := tea.NewProgram(m).Run() |
| 250 | if err != nil { |
| 251 | return nil |
| 252 | } |
| 253 | final := result.(tui.Model) |
| 254 | if final.Quitting() { |
| 255 | return nil |
| 256 | } |
| 257 | return config.Save(final.Cfg()) |
| 258 | } |
| 259 | |
| 260 | func fetchCopilotModels(cdb *cache.DB, oauthToken string) ([]copilot.CopilotModel, error) { |
| 261 | if cdb != nil { |
| 262 | if data, ok := cdb.GetModels("copilot"); ok { |
| 263 | var models []copilot.CopilotModel |
| 264 | if json.Unmarshal(data, &models) == nil && len(models) > 0 { |
| 265 | return models, nil |
| 266 | } |
| 267 | } |
| 268 | } |
| 269 | fmt.Println("Fetching available models...") |
| 270 | models, err := copilot.ListModels(oauthToken) |
| 271 | if err != nil { |
| 272 | return nil, err |
| 273 | } |
| 274 | if cdb != nil { |
| 275 | if data, err := json.Marshal(models); err == nil { |
| 276 | cdb.SetModels("copilot", data) |
| 277 | } |
| 278 | } |
| 279 | return models, nil |
| 280 | } |
| 281 | |
| 282 | func showTUI(cfg *config.Config, cdb *cache.DB) (*config.Config, bool) { |
| 283 | m := tui.New(cfg, cdb) |
| 284 | result, err := tea.NewProgram(m).Run() |
| 285 | if err != nil { |
| 286 | fmt.Fprintf(os.Stderr, "TUI: %v\n", err) |
| 287 | return cfg, true |
| 288 | } |
| 289 | final := result.(tui.Model) |
| 290 | return final.Cfg(), final.Quitting() |
| 291 | } |
| 292 | |
| 293 | func parseArgs() (initFlag, helpFlag bool, completionsShell, model string, claudeArgs []string) { |
| 294 | args := os.Args[1:] |
| 295 | for i := 0; i < len(args); i++ { |
| 296 | if args[i] == "--" { |
| 297 | claudeArgs = append(claudeArgs, args[i+1:]...) |
| 298 | return |
| 299 | } |
| 300 | if args[i] == "completions" && i+1 < len(args) { |
| 301 | completionsShell = args[i+1] |
| 302 | return |
| 303 | } |
| 304 | if args[i] == "--configure" { |
| 305 | initFlag = true |
| 306 | continue |
| 307 | } |
| 308 | if args[i] == "--help" || args[i] == "-h" { |
| 309 | helpFlag = true |
| 310 | continue |
| 311 | } |
| 312 | if args[i] == "--model" && i+1 < len(args) { |
| 313 | model = args[i+1] |
| 314 | i++ |
| 315 | continue |
| 316 | } |
| 317 | claudeArgs = append(claudeArgs, args[i]) |
| 318 | } |
| 319 | return |
| 320 | } |
| 321 | |
| 322 | func printCompletions(shell string) error { |
| 323 | switch shell { |
| 324 | case "zsh": |
| 325 | fmt.Print(zshCompletion) |
| 326 | case "bash": |
| 327 | fmt.Print(bashCompletion) |
| 328 | default: |
| 329 | return fmt.Errorf("unsupported shell %q (supported: zsh, bash)", shell) |
| 330 | } |
| 331 | return nil |
| 332 | } |
| 333 | |
| 334 | const zshCompletion = `#compdef claudio |
| 335 |
|
| 336 | _claudio() { |
| 337 | local -a commands |
| 338 | commands=( |
| 339 | 'completions:Print shell completions' |
| 340 | ) |
| 341 |
|
| 342 | _arguments -s \ |
| 343 | '--configure[Open the configuration screen]' \ |
| 344 | '--model[Switch the active provider]:provider name:' \ |
| 345 | '(-h --help)'{-h,--help}'[Show help message]' \ |
| 346 | '1:command:->cmd' \ |
| 347 | '*:: :->args' |
| 348 |
|
| 349 | case "$state" in |
| 350 | cmd) |
| 351 | _describe -t commands 'claudio commands' commands |
| 352 | ;; |
| 353 | args) |
| 354 | case "$words[1]" in |
| 355 | completions) |
| 356 | _values 'shell' zsh bash |
| 357 | ;; |
| 358 | esac |
| 359 | ;; |
| 360 | esac |
| 361 | } |
| 362 |
|
| 363 | _claudio "$@" |
| 364 | ` |
| 365 | |
| 366 | const bashCompletion = `# bash completion for claudio |
| 367 |
|
| 368 | _claudio() { |
| 369 | local cur prev |
| 370 | cur="${COMP_WORDS[COMP_CWORD]}" |
| 371 | prev="${COMP_WORDS[COMP_CWORD-1]}" |
| 372 |
|
| 373 | case "$prev" in |
| 374 | completions) |
| 375 | COMPREPLY=($(compgen -W "zsh bash" -- "$cur")) |
| 376 | return |
| 377 | ;; |
| 378 | --model) |
| 379 | return |
| 380 | ;; |
| 381 | esac |
| 382 |
|
| 383 | if [[ "$cur" == -* ]]; then |
| 384 | COMPREPLY=($(compgen -W "--configure --model --help -h" -- "$cur")) |
| 385 | else |
| 386 | COMPREPLY=($(compgen -W "completions" -- "$cur")) |
| 387 | fi |
| 388 | } |
| 389 |
|
| 390 | complete -F _claudio claudio |
| 391 | ` |
| 392 | |