root.go

v0.7.0
Doc Versions Source
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

Source Files