main.go

v0.3.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
	tea "github.com/charmbracelet/bubbletea"
12
	"sourcecraft.dev/bigbes/claudio/cache"
13
	"sourcecraft.dev/bigbes/claudio/config"
14
	"sourcecraft.dev/bigbes/claudio/copilot"
15
	"sourcecraft.dev/bigbes/claudio/provider"
16
	"sourcecraft.dev/bigbes/claudio/proxy"
17
	"sourcecraft.dev/bigbes/claudio/tui"
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
296
	// Find "--" separator: everything before is for claudio, everything after is for claude.
297
	sepIdx := -1
298
	for i, a := range args {
299
		if a == "--" {
300
			sepIdx = i
301
			break
302
		}
303
	}
304
305
	claudioArgs := args
306
	if sepIdx >= 0 {
307
		claudioArgs = args[:sepIdx]
308
		claudeArgs = append(claudeArgs, args[sepIdx+1:]...)
309
	}
310
311
	for i := 0; i < len(claudioArgs); i++ {
312
		if claudioArgs[i] == "completions" {
313
			// Look for --shell=<shell> or positional shell arg in remaining args.
314
			for j := i + 1; j < len(claudioArgs); j++ {
315
				if strings.HasPrefix(claudioArgs[j], "--shell=") {
316
					completionsShell = strings.TrimPrefix(claudioArgs[j], "--shell=")
317
				} else {
318
					completionsShell = claudioArgs[j]
319
				}
320
				break
321
			}
322
			if completionsShell == "" {
323
				fmt.Fprintln(os.Stderr, "claudio: completions requires a shell argument (zsh, bash)")
324
				helpFlag = true
325
			}
326
			return
327
		}
328
		if strings.HasPrefix(claudioArgs[i], "--shell=") {
329
			completionsShell = strings.TrimPrefix(claudioArgs[i], "--shell=")
330
			continue
331
		}
332
		if claudioArgs[i] == "--configure" {
333
			initFlag = true
334
			continue
335
		}
336
		if claudioArgs[i] == "--help" || claudioArgs[i] == "-h" {
337
			helpFlag = true
338
			continue
339
		}
340
		if claudioArgs[i] == "--model" && i+1 < len(claudioArgs) {
341
			model = claudioArgs[i+1]
342
			i++
343
			continue
344
		}
345
		fmt.Fprintf(os.Stderr, "claudio: unknown argument %q (use -- to pass arguments to claude)\n", claudioArgs[i])
346
		helpFlag = true
347
		return
348
	}
349
	return
350
}
351
352
func printCompletions(shell string) error {
353
	switch shell {
354
	case "zsh":
355
		fmt.Print(zshCompletion)
356
	case "bash":
357
		fmt.Print(bashCompletion)
358
	default:
359
		return fmt.Errorf("unsupported shell %q (supported: zsh, bash)", shell)
360
	}
361
	return nil
362
}
363
364
const zshCompletion = `#compdef claudio
365
366
_claudio() {
367
  local -a commands
368
  commands=(
369
    'completions:Print shell completions'
370
  )
371
372
  _arguments -s \
373
    '--configure[Open the configuration screen]' \
374
    '--model[Switch the active provider]:provider name:' \
375
    '(-h --help)'{-h,--help}'[Show help message]' \
376
    '1:command:->cmd' \
377
    '*:: :->args'
378
379
  case "$state" in
380
    cmd)
381
      _describe -t commands 'claudio commands' commands
382
      ;;
383
    args)
384
      case "${words[1]}" in
385
        completions)
386
          _values 'shell' zsh bash
387
          ;;
388
      esac
389
      ;;
390
  esac
391
}
392
393
_claudio "$@"
394
`
395
396
const bashCompletion = `# bash completion for claudio
397
398
_claudio() {
399
  local cur prev
400
  cur="${COMP_WORDS[COMP_CWORD]}"
401
  prev="${COMP_WORDS[COMP_CWORD-1]}"
402
403
  case "$prev" in
404
    completions)
405
      COMPREPLY=($(compgen -W "zsh bash" -- "$cur"))
406
      return
407
      ;;
408
    --model)
409
      return
410
      ;;
411
  esac
412
413
  if [[ "$cur" == -* ]]; then
414
    COMPREPLY=($(compgen -W "--configure --model --help -h" -- "$cur"))
415
  else
416
    COMPREPLY=($(compgen -W "completions" -- "$cur"))
417
  fi
418
}
419
420
complete -F _claudio claudio
421
`
422

Source Files