| 1 | package cmd |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | "syscall" |
| 11 | |
| 12 | tea "github.com/charmbracelet/bubbletea" |
| 13 | "github.com/spf13/cobra" |
| 14 | "sourcecraft.dev/bigbes/claudio/cache" |
| 15 | "sourcecraft.dev/bigbes/claudio/config" |
| 16 | "sourcecraft.dev/bigbes/claudio/provider" |
| 17 | "sourcecraft.dev/bigbes/claudio/provider/copilot" |
| 18 | "sourcecraft.dev/bigbes/claudio/proxy" |
| 19 | "sourcecraft.dev/bigbes/claudio/tui" |
| 20 | ) |
| 21 | |
| 22 | var ( |
| 23 | Version = "dev" |
| 24 | |
| 25 | flagModel string |
| 26 | flagHTTPDebug bool |
| 27 | flagHTTPDebugOutput string |
| 28 | flagMetrics bool |
| 29 | ) |
| 30 | |
| 31 | var rootCmd = &cobra.Command{ |
| 32 | Use: "claudio [flags] [-- claude-args...]", |
| 33 | Short: "Claude Code provider proxy", |
| 34 | Long: fmt.Sprintf(`Claude Code provider proxy. |
| 35 |
|
| 36 | Config: %s`, config.Path()), |
| 37 | SilenceUsage: true, |
| 38 | SilenceErrors: true, |
| 39 | RunE: runRoot, |
| 40 | // Treat unknown flags after "--" as claude args. |
| 41 | TraverseChildren: true, |
| 42 | } |
| 43 | |
| 44 | func init() { |
| 45 | rootCmd.PersistentFlags().StringVar(&flagModel, "model", "", "Switch the active provider") |
| 46 | rootCmd.Flags().BoolVar(&flagHTTPDebug, "http-debug", false, "Enable proxy request/response logging") |
| 47 | rootCmd.Flags().StringVar(&flagHTTPDebugOutput, "http-debug-output", "./claude-proxy.log", "HTTP debug log file path") |
| 48 | rootCmd.Flags().BoolVar(&flagMetrics, "metrics", false, "Print proxy call metrics summary on exit") |
| 49 | |
| 50 | rootCmd.RegisterFlagCompletionFunc("model", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { |
| 51 | var names []string |
| 52 | for name, p := range provider.Registry { |
| 53 | names = append(names, fmt.Sprintf("%s\t%s", name, p.Description)) |
| 54 | } |
| 55 | return names, cobra.ShellCompDirectiveNoFileComp |
| 56 | }) |
| 57 | } |
| 58 | |
| 59 | func Execute() { |
| 60 | // Handle "--" separator: cobra passes everything after "--" as args. |
| 61 | if err := rootCmd.Execute(); err != nil { |
| 62 | fmt.Fprintf(os.Stderr, "claudio: %v\n", err) |
| 63 | os.Exit(1) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func runRoot(cmd *cobra.Command, args []string) error { |
| 68 | // Only pass args to claude if the user explicitly used "--". |
| 69 | if len(args) > 0 && !hasDoubleDash(os.Args[1:]) { |
| 70 | return fmt.Errorf("unknown argument %q; use \"--\" to pass arguments to claude (e.g. claudio -- %s)", args[0], args[0]) |
| 71 | } |
| 72 | |
| 73 | cfg, err := config.Load() |
| 74 | if err != nil { |
| 75 | return fmt.Errorf("loading config: %w", err) |
| 76 | } |
| 77 | |
| 78 | cdb, _ := cache.Open() |
| 79 | if cdb != nil { |
| 80 | defer cdb.Close() |
| 81 | } |
| 82 | |
| 83 | // --model <name>: switch active provider and persist. |
| 84 | if flagModel != "" { |
| 85 | cfg.ActiveProvider = flagModel |
| 86 | if err := config.Save(cfg); err != nil { |
| 87 | return fmt.Errorf("saving config: %w", err) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // First run: no active provider → show TUI, then launch. |
| 92 | if cfg.ActiveProvider == "" { |
| 93 | quit, _ := showTUI(cfg, cdb) |
| 94 | if quit || cfg.ActiveProvider == "" { |
| 95 | return nil |
| 96 | } |
| 97 | if err := config.Save(cfg); err != nil { |
| 98 | return fmt.Errorf("saving config: %w", err) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | pc := cfg.Providers[cfg.ActiveProvider] |
| 103 | // For NoAuth providers (e.g. local Ollama, claude), use a dummy API key. |
| 104 | if reg, regOK := provider.Registry[cfg.ActiveProvider]; regOK && reg.NoAuth && pc.APIKey == "" { |
| 105 | pc.APIKey = "no-auth" |
| 106 | cfg.Providers[cfg.ActiveProvider] = pc |
| 107 | } |
| 108 | if pc.APIKey == "" { |
| 109 | if cfg.ActiveProvider == "copilot" { |
| 110 | fmt.Println("Authenticating with GitHub Copilot...") |
| 111 | token, err := copilot.DeviceAuth() |
| 112 | if err != nil { |
| 113 | return fmt.Errorf("copilot auth: %w", err) |
| 114 | } |
| 115 | pc.APIKey = token |
| 116 | cfg.Providers["copilot"] = pc |
| 117 | if err := config.Save(cfg); err != nil { |
| 118 | return fmt.Errorf("saving config: %w", err) |
| 119 | } |
| 120 | } else { |
| 121 | return fmt.Errorf("no API key for %q — run 'claudio config' to configure", cfg.ActiveProvider) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | prov := config.ResolveProvider(cfg.ActiveProvider, pc) |
| 126 | |
| 127 | // Resolve proxy URLs. |
| 128 | proxyURL := cfg.Proxy |
| 129 | if pc.Proxy != "" { |
| 130 | proxyURL = pc.Proxy |
| 131 | } |
| 132 | if proxyURL == "none" { |
| 133 | proxyURL = "" |
| 134 | } |
| 135 | |
| 136 | claudeProxyURL := cfg.ClaudeProxy |
| 137 | if pc.ClaudeProxy != "" { |
| 138 | claudeProxyURL = pc.ClaudeProxy |
| 139 | } |
| 140 | if claudeProxyURL == "none" { |
| 141 | claudeProxyURL = "" |
| 142 | } |
| 143 | |
| 144 | var needLocalProc bool |
| 145 | var proxyAddr string |
| 146 | |
| 147 | proxy.SetHTTPDebug(flagHTTPDebug, flagHTTPDebugOutput) |
| 148 | defer proxy.CloseLogger() |
| 149 | |
| 150 | var tokenFunc func() (string, error) |
| 151 | var extraHeaders map[string]string |
| 152 | if cfg.ActiveProvider == "copilot" { |
| 153 | tokenFunc = copilot.NewTokenSource(pc.APIKey).Token |
| 154 | extraHeaders = copilot.Headers() |
| 155 | } else if cfg.ActiveProvider != "claude" { |
| 156 | tokenFunc = proxy.StaticToken(pc.APIKey) |
| 157 | } |
| 158 | |
| 159 | if prov.Compat == "openai" { |
| 160 | addr, shutdown, err := proxy.Start(prov.BaseURL, tokenFunc, extraHeaders, proxyURL, cfg.ActiveProvider) |
| 161 | if err != nil { |
| 162 | return fmt.Errorf("starting proxy: %w", err) |
| 163 | } |
| 164 | defer shutdown() |
| 165 | proxyAddr = addr |
| 166 | } else { |
| 167 | addr, shutdown, err := proxy.StartPassthrough(prov.BaseURL, tokenFunc, proxyURL, cfg.ActiveProvider) |
| 168 | if err != nil { |
| 169 | return fmt.Errorf("starting passthrough proxy: %w", err) |
| 170 | } |
| 171 | defer shutdown() |
| 172 | proxyAddr = addr |
| 173 | } |
| 174 | |
| 175 | if flagMetrics { |
| 176 | defer func() { |
| 177 | if s := proxy.DefaultMetrics.Summary(); s != "" { |
| 178 | fmt.Fprint(os.Stderr, s) |
| 179 | } |
| 180 | }() |
| 181 | } |
| 182 | |
| 183 | os.Setenv("ANTHROPIC_BASE_URL", "http://"+proxyAddr) |
| 184 | os.Setenv("NO_PROXY", "127.0.0.1") |
| 185 | if cfg.ActiveProvider != "claude" { |
| 186 | os.Setenv("ANTHROPIC_AUTH_TOKEN", "proxy") |
| 187 | } |
| 188 | needLocalProc = true |
| 189 | |
| 190 | if !needLocalProc && proxyURL != "" && strings.HasPrefix(proxyURL, "socks5") && claudeProxyURL == "" { |
| 191 | bridgeAddr, bridgeShutdown, err := proxy.StartSOCKSBridge(proxyURL) |
| 192 | if err != nil { |
| 193 | return fmt.Errorf("starting SOCKS bridge: %w", err) |
| 194 | } |
| 195 | defer bridgeShutdown() |
| 196 | claudeProxyURL = "http://" + bridgeAddr |
| 197 | needLocalProc = true |
| 198 | } |
| 199 | |
| 200 | if claudeProxyURL != "" { |
| 201 | os.Setenv("HTTP_PROXY", claudeProxyURL) |
| 202 | os.Setenv("HTTPS_PROXY", claudeProxyURL) |
| 203 | } |
| 204 | |
| 205 | if cfg.ActiveProvider != "claude" { |
| 206 | os.Setenv("ANTHROPIC_MODEL", prov.Model) |
| 207 | os.Setenv("ANTHROPIC_SMALL_FAST_MODEL", prov.SmallModel) |
| 208 | os.Setenv("ANTHROPIC_DEFAULT_SONNET_MODEL", prov.SonnetModel) |
| 209 | os.Setenv("ANTHROPIC_DEFAULT_OPUS_MODEL", prov.OpusModel) |
| 210 | os.Setenv("ANTHROPIC_DEFAULT_HAIKU_MODEL", prov.HaikuModel) |
| 211 | } |
| 212 | os.Setenv("API_TIMEOUT_MS", prov.TimeoutMS) |
| 213 | os.Setenv("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") |
| 214 | os.Setenv("CLAUDE_CODE_LOG_LEVEL", "debug") |
| 215 | os.Setenv("ENABLE_LSP_TOOL", "1") |
| 216 | |
| 217 | for k, v := range pc.Env { |
| 218 | os.Setenv(k, v) |
| 219 | } |
| 220 | |
| 221 | const defaultCtx = 200_000 |
| 222 | if prov.ContextWindow > 0 && prov.ContextWindow < defaultCtx { |
| 223 | pct := prov.ContextWindow * 95 / defaultCtx |
| 224 | os.Setenv("CLAUDE_AUTOCOMPACT_PCT_OVERRIDE", fmt.Sprintf("%d", pct)) |
| 225 | } |
| 226 | |
| 227 | claudeBin, err := exec.LookPath("claude") |
| 228 | if err != nil { |
| 229 | return fmt.Errorf("claude not found in PATH: %w", err) |
| 230 | } |
| 231 | |
| 232 | if needLocalProc { |
| 233 | c := exec.Command(claudeBin, args...) |
| 234 | c.Stdin = os.Stdin |
| 235 | c.Stdout = os.Stdout |
| 236 | c.Stderr = os.Stderr |
| 237 | c.Env = os.Environ() |
| 238 | return c.Run() |
| 239 | } |
| 240 | |
| 241 | return syscall.Exec(claudeBin, append([]string{"claude"}, args...), os.Environ()) |
| 242 | } |
| 243 | |
| 244 | func hasDoubleDash(args []string) bool { |
| 245 | return slices.Contains(args, "--") |
| 246 | } |
| 247 | |
| 248 | func showCopilotModelPicker(cfg *config.Config, cdb *cache.DB) error { |
| 249 | pc := cfg.Providers["copilot"] |
| 250 | models, err := fetchCopilotModels(cdb, pc.APIKey) |
| 251 | if err != nil { |
| 252 | fmt.Fprintf(os.Stderr, "Warning: could not fetch models: %v\n", err) |
| 253 | return nil |
| 254 | } |
| 255 | if len(models) == 0 { |
| 256 | return nil |
| 257 | } |
| 258 | m := tui.NewModelPicker(cfg, cdb, models) |
| 259 | result, err := tea.NewProgram(m, tea.WithAltScreen()).Run() |
| 260 | if err != nil { |
| 261 | return err |
| 262 | } |
| 263 | final := result.(tui.Model) |
| 264 | if final.Quitting() { |
| 265 | return nil |
| 266 | } |
| 267 | return config.Save(final.Cfg()) |
| 268 | } |
| 269 | |
| 270 | func fetchCopilotModels(cdb *cache.DB, oauthToken string) ([]copilot.CopilotModel, error) { |
| 271 | if cdb != nil { |
| 272 | if data, ok := cdb.GetModels("copilot"); ok { |
| 273 | var models []copilot.CopilotModel |
| 274 | if json.Unmarshal(data, &models) == nil && len(models) > 0 { |
| 275 | return models, nil |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | fmt.Println("Fetching available models...") |
| 280 | models, err := copilot.ListModels(oauthToken) |
| 281 | if err != nil { |
| 282 | return nil, err |
| 283 | } |
| 284 | if cdb != nil { |
| 285 | if data, merr := json.Marshal(models); merr == nil { |
| 286 | _ = cdb.SetModels("copilot", data) |
| 287 | } |
| 288 | } |
| 289 | return models, nil |
| 290 | } |
| 291 | |
| 292 | func showTUI(cfg *config.Config, cdb *cache.DB) (quit bool, launch bool) { |
| 293 | m := tui.New(cfg, cdb) |
| 294 | result, err := tea.NewProgram(m, tea.WithAltScreen()).Run() |
| 295 | if err != nil { |
| 296 | fmt.Fprintf(os.Stderr, "TUI: %v\n", err) |
| 297 | return true, false |
| 298 | } |
| 299 | final := result.(tui.Model) |
| 300 | return final.Quitting(), final.LaunchAfterConfig() |
| 301 | } |
| 302 | |