root.go

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

Source Files