main.go

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

Source Files