From 1e75bd7a40380a79cb15d9e7dc6244b8993abe00 Mon Sep 17 00:00:00 2001 From: "fanxiyao.3" Date: Wed, 15 Apr 2026 15:10:27 +0800 Subject: [PATCH 1/8] feat: add backend setup wizard and CLI switch commands Add interactive `inline-cli setup` for first-time backend selection with CLI tool detection, and `inline-cli backend` subcommands (list/show/set) for switching backends at runtime. Rename "cli" backend to "claude" with backward compatibility. Auto-restart daemon on backend switch. Install script now runs setup post-install. --- cmd/inline-cli/backend.go | 187 ++++++++++++++++++++++++++++++++++++++ cmd/inline-cli/main.go | 2 + cmd/inline-cli/setup.go | 116 +++++++++++++++++++++++ internal/config/config.go | 38 ++++++++ internal/daemon/server.go | 4 +- scripts/install.sh | 14 ++- 6 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 cmd/inline-cli/backend.go create mode 100644 cmd/inline-cli/setup.go diff --git a/cmd/inline-cli/backend.go b/cmd/inline-cli/backend.go new file mode 100644 index 0000000..d76e5a4 --- /dev/null +++ b/cmd/inline-cli/backend.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + + "github.com/CCALITA/inline-cli/internal/config" + "github.com/CCALITA/inline-cli/internal/daemon" +) + +// backendInfo describes a supported backend. +type backendInfo struct { + Name string // config value + Desc string // human-readable description + Binary string // CLI binary name to check (empty for API-based) +} + +var backends = []backendInfo{ + {Name: "api", Desc: "Anthropic API (requires API key)", Binary: ""}, + {Name: "claude", Desc: "Claude CLI", Binary: "claude"}, + {Name: "gemini", Desc: "Gemini CLI", Binary: "gemini"}, + {Name: "opencode", Desc: "OpenCode CLI", Binary: "opencode"}, + {Name: "codex", Desc: "Codex CLI", Binary: "codex"}, +} + +// isInstalled checks if a CLI binary is available in PATH. +func (b backendInfo) isInstalled() bool { + if b.Binary == "" { + return true // API backend is always "available" + } + _, err := exec.LookPath(b.Binary) + return err == nil +} + +// validBackendNames returns the list of valid backend name strings. +func validBackendNames() []string { + names := make([]string, len(backends)) + for i, b := range backends { + names[i] = b.Name + } + return names +} + +func newBackendCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "backend", + Short: "Manage backends", + } + + cmd.AddCommand( + newBackendListCmd(), + newBackendShowCmd(), + newBackendSetCmd(), + ) + + return cmd +} + +func newBackendListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List available backends", + RunE: runBackendList, + } +} + +func newBackendShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "Show the current backend", + RunE: runBackendShow, + } +} + +func newBackendSetCmd() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Switch to a different backend", + Args: cobra.ExactArgs(1), + ValidArgs: validBackendNames(), + RunE: runBackendSet, + } +} + +func runBackendList(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + active := cfg.BackendName() + + for _, b := range backends { + marker := " " + if b.Name == active { + marker = "* " + } + + status := "" + if b.Binary != "" { + if b.isInstalled() { + status = " \033[32m✓ installed\033[0m" + } else { + status = " \033[31m✗ not found\033[0m" + } + } + + activeSuffix := "" + if b.Name == active { + activeSuffix = " (active)" + } + + fmt.Printf("%s%-10s — %s%s%s\n", marker, b.Name, b.Desc, status, activeSuffix) + } + + // Warn about env var override. + if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" { + fmt.Printf("\n\033[33mNote: INLINE_CLI_BACKEND=%s is set and overrides the config file.\033[0m\n", v) + } + + return nil +} + +func runBackendShow(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + fmt.Println(cfg.BackendName()) + + if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" { + fmt.Fprintf(os.Stderr, "Note: overridden by INLINE_CLI_BACKEND=%s\n", v) + } + + return nil +} + +func runBackendSet(cmd *cobra.Command, args []string) error { + name := args[0] + + // Validate. + valid := false + for _, b := range backends { + if b.Name == name { + valid = true + break + } + } + if !valid { + return fmt.Errorf("unknown backend %q (supported: %s)", name, strings.Join(validBackendNames(), ", ")) + } + + // Warn about env var override. + if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" { + fmt.Fprintf(os.Stderr, "\033[33mWarning: INLINE_CLI_BACKEND=%s is set and will override this config.\033[0m\n", v) + fmt.Fprintf(os.Stderr, "Run: unset INLINE_CLI_BACKEND\n\n") + } + + // Write config. + if err := config.SaveBackend(name); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("Backend set to %q\n", name) + + // Auto-restart daemon. + cfg, err := config.Load() + if err != nil { + return err + } + + d := daemon.NewDaemon(cfg.PIDFile, cfg.SocketPath) + if d.IsRunning() { + if err := d.Stop(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to stop daemon: %v\n", err) + } else { + fmt.Println("Daemon restarted. Next query will use the new backend.") + } + } + + return nil +} diff --git a/cmd/inline-cli/main.go b/cmd/inline-cli/main.go index 840090a..081eea1 100644 --- a/cmd/inline-cli/main.go +++ b/cmd/inline-cli/main.go @@ -37,6 +37,8 @@ func main() { newStatusCmd(), newStopSessionCmd(), newInitCmd(), + newBackendCmd(), + newSetupCmd(), ) if err := rootCmd.Execute(); err != nil { diff --git a/cmd/inline-cli/setup.go b/cmd/inline-cli/setup.go new file mode 100644 index 0000000..e4ff772 --- /dev/null +++ b/cmd/inline-cli/setup.go @@ -0,0 +1,116 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/CCALITA/inline-cli/internal/config" + "github.com/CCALITA/inline-cli/internal/daemon" +) + +func newSetupCmd() *cobra.Command { + return &cobra.Command{ + Use: "setup", + Short: "Interactive first-time setup", + RunE: runSetup, + } +} + +func runSetup(cmd *cobra.Command, args []string) error { + fmt.Println("Welcome to inline-cli!") + fmt.Println() + fmt.Println("Select a backend:") + fmt.Println() + + for i, b := range backends { + status := "" + if b.Binary != "" { + if b.isInstalled() { + status = " \033[32m✓ installed\033[0m" + } else { + status = " \033[31m✗ not found\033[0m" + } + } + fmt.Printf(" %d) %-10s — %s%s\n", i+1, b.Name, b.Desc, status) + } + + fmt.Println() + + reader := bufio.NewReader(os.Stdin) + var chosen backendInfo + + for { + fmt.Printf("Enter choice [1-%d]: ", len(backends)) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + line = strings.TrimSpace(line) + n, err := strconv.Atoi(line) + if err != nil || n < 1 || n > len(backends) { + fmt.Println("Invalid choice. Please enter a number.") + continue + } + chosen = backends[n-1] + break + } + + // If API backend, prompt for API key. + if chosen.Name == "api" { + existing := os.Getenv("ANTHROPIC_API_KEY") + if existing == "" { + fmt.Println() + fmt.Print("Enter your Anthropic API key (or press Enter to skip): ") + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + key := strings.TrimSpace(line) + if key != "" { + fmt.Println() + fmt.Println("Add this to your shell profile:") + fmt.Printf(" export ANTHROPIC_API_KEY=%s\n", key) + } + } else { + fmt.Println() + fmt.Println("\033[32m✓\033[0m ANTHROPIC_API_KEY is already set") + } + } + + // Warn about env var override. + if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" && v != chosen.Name { + fmt.Println() + fmt.Printf("\033[33mWarning: INLINE_CLI_BACKEND=%s is set and will override this config.\033[0m\n", v) + fmt.Println("Run: unset INLINE_CLI_BACKEND") + } + + // Save config. + if err := config.SaveBackend(chosen.Name); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println() + fmt.Printf("\033[32m✓\033[0m Backend set to %q\n", chosen.Name) + + // Restart daemon if running. + cfg, err := config.Load() + if err != nil { + return nil // non-fatal + } + + d := daemon.NewDaemon(cfg.PIDFile, cfg.SocketPath) + if d.IsRunning() { + d.Stop() + fmt.Println("\033[32m✓\033[0m Daemon restarted") + } + + fmt.Println() + fmt.Println("You're all set! Type something and press Ctrl+J (or Shift+Enter).") + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index b7f7bb6..1e79775 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -110,3 +110,41 @@ func configFilePath() string { homeDir, _ := os.UserHomeDir() return filepath.Join(homeDir, ".inline-cli", "config.toml") } + +// ConfigFilePath returns the path to the config file. +func ConfigFilePath() string { + return configFilePath() +} + +// SaveBackend updates the backend field in the config file, preserving other settings. +// Creates the config directory and file if they don't exist. +func SaveBackend(backend string) error { + path := configFilePath() + + // Ensure directory exists. + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Load existing config to preserve other fields. + cfg := DefaultConfig() + if _, err := os.Stat(path); err == nil { + if _, err := toml.DecodeFile(path, &cfg); err != nil { + return fmt.Errorf("failed to read existing config: %w", err) + } + } + + cfg.Backend = backend + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + defer f.Close() + + if err := toml.NewEncoder(f).Encode(cfg); err != nil { + return fmt.Errorf("failed to encode config: %w", err) + } + + return nil +} diff --git a/internal/daemon/server.go b/internal/daemon/server.go index e5895ca..f929e35 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -42,7 +42,7 @@ func NewServer(cfg config.Config) (*Server, error) { // createBackend creates the appropriate backend based on config. func createBackend(cfg config.Config) (backend.Backend, error) { switch cfg.Backend { - case "cli": + case "claude", "cli": return backend.NewCLIBackend(cfg.CLIPath) case "gemini": return backend.NewGeminiBackend(cfg.GeminiPath) @@ -53,7 +53,7 @@ func createBackend(cfg config.Config) (backend.Backend, error) { case "api", "": return backend.NewAPIBackend(cfg.APIKey, cfg.APIBaseURL) default: - return nil, fmt.Errorf("unknown backend: %q (supported: api, cli, gemini, opencode, acp)", cfg.Backend) + return nil, fmt.Errorf("unknown backend: %q (supported: api, claude, gemini, opencode)", cfg.Backend) } } diff --git a/scripts/install.sh b/scripts/install.sh index e0fdb0d..4687529 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -154,11 +154,19 @@ main() { echo "" info "installation complete!" + echo "" + + # Run interactive setup if stdin is a terminal. + if [ -t 0 ]; then + "$INSTALL_DIR/inline-cli" setup + else + echo "Run 'inline-cli setup' to configure your backend." + fi + echo "" echo "Next steps:" - echo " 1. Set your API key: export ANTHROPIC_API_KEY=sk-ant-..." - echo " 2. Restart your shell: exec \$SHELL" - echo " 3. Type something and press Ctrl+J (or Shift+Enter in supported terminals)" + echo " 1. Restart your shell: exec \$SHELL" + echo " 2. Type something and press Ctrl+J (or Shift+Enter in supported terminals)" echo "" } From ad2e57582a2f3c4e1e2ef607478ac6f63c87e233 Mon Sep 17 00:00:00 2001 From: "fanxiyao.3" Date: Wed, 15 Apr 2026 15:26:55 +0800 Subject: [PATCH 2/8] refactor: simplify backend setup and deduplicate prompt building - Remove codex from backend registry (no server handler exists) - Extract installStatus(), findBackend(), restartDaemonIfRunning() helpers to eliminate duplication between setup.go and backend.go - Extract extractPromptAndHistory() and formatHistory() in backend package to deduplicate prompt-building logic across cli, gemini, and opencode - Fix config file permissions: use 0600 instead of default 0666 - Remove TOCTOU os.Stat guard in SaveBackend, handle os.IsNotExist directly - Remove unused ConfigFilePath() export - Remove redundant comments that restate the code Net -72 lines. --- cmd/inline-cli/backend.go | 85 +++++++++++++++++------------------- cmd/inline-cli/setup.go | 27 +----------- internal/backend/backend.go | 27 ++++++++++++ internal/backend/cli.go | 26 +---------- internal/backend/gemini.go | 23 +--------- internal/backend/opencode.go | 23 +--------- internal/config/config.go | 15 ++----- 7 files changed, 77 insertions(+), 149 deletions(-) diff --git a/cmd/inline-cli/backend.go b/cmd/inline-cli/backend.go index d76e5a4..c6eb71f 100644 --- a/cmd/inline-cli/backend.go +++ b/cmd/inline-cli/backend.go @@ -24,19 +24,35 @@ var backends = []backendInfo{ {Name: "claude", Desc: "Claude CLI", Binary: "claude"}, {Name: "gemini", Desc: "Gemini CLI", Binary: "gemini"}, {Name: "opencode", Desc: "OpenCode CLI", Binary: "opencode"}, - {Name: "codex", Desc: "Codex CLI", Binary: "codex"}, } -// isInstalled checks if a CLI binary is available in PATH. func (b backendInfo) isInstalled() bool { if b.Binary == "" { - return true // API backend is always "available" + return true } _, err := exec.LookPath(b.Binary) return err == nil } -// validBackendNames returns the list of valid backend name strings. +func (b backendInfo) installStatus() string { + if b.Binary == "" { + return "" + } + if b.isInstalled() { + return " \033[32m✓ installed\033[0m" + } + return " \033[31m✗ not found\033[0m" +} + +func findBackend(name string) (backendInfo, bool) { + for _, b := range backends { + if b.Name == name { + return b, true + } + } + return backendInfo{}, false +} + func validBackendNames() []string { names := make([]string, len(backends)) for i, b := range backends { @@ -45,6 +61,21 @@ func validBackendNames() []string { return names } +func restartDaemonIfRunning() { + cfg, err := config.Load() + if err != nil { + return + } + d := daemon.NewDaemon(cfg.PIDFile, cfg.SocketPath) + if d.IsRunning() { + if err := d.Stop(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to stop daemon: %v\n", err) + } else { + fmt.Println("Daemon stopped. Next query will use the new backend.") + } + } +} + func newBackendCmd() *cobra.Command { cmd := &cobra.Command{ Use: "backend", @@ -96,28 +127,14 @@ func runBackendList(cmd *cobra.Command, args []string) error { for _, b := range backends { marker := " " - if b.Name == active { - marker = "* " - } - - status := "" - if b.Binary != "" { - if b.isInstalled() { - status = " \033[32m✓ installed\033[0m" - } else { - status = " \033[31m✗ not found\033[0m" - } - } - activeSuffix := "" if b.Name == active { + marker = "* " activeSuffix = " (active)" } - - fmt.Printf("%s%-10s — %s%s%s\n", marker, b.Name, b.Desc, status, activeSuffix) + fmt.Printf("%s%-10s — %s%s%s\n", marker, b.Name, b.Desc, b.installStatus(), activeSuffix) } - // Warn about env var override. if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" { fmt.Printf("\n\033[33mNote: INLINE_CLI_BACKEND=%s is set and overrides the config file.\033[0m\n", v) } @@ -143,45 +160,21 @@ func runBackendShow(cmd *cobra.Command, args []string) error { func runBackendSet(cmd *cobra.Command, args []string) error { name := args[0] - // Validate. - valid := false - for _, b := range backends { - if b.Name == name { - valid = true - break - } - } - if !valid { + if _, ok := findBackend(name); !ok { return fmt.Errorf("unknown backend %q (supported: %s)", name, strings.Join(validBackendNames(), ", ")) } - // Warn about env var override. if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" { fmt.Fprintf(os.Stderr, "\033[33mWarning: INLINE_CLI_BACKEND=%s is set and will override this config.\033[0m\n", v) fmt.Fprintf(os.Stderr, "Run: unset INLINE_CLI_BACKEND\n\n") } - // Write config. if err := config.SaveBackend(name); err != nil { return fmt.Errorf("failed to save config: %w", err) } fmt.Printf("Backend set to %q\n", name) - - // Auto-restart daemon. - cfg, err := config.Load() - if err != nil { - return err - } - - d := daemon.NewDaemon(cfg.PIDFile, cfg.SocketPath) - if d.IsRunning() { - if err := d.Stop(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to stop daemon: %v\n", err) - } else { - fmt.Println("Daemon restarted. Next query will use the new backend.") - } - } + restartDaemonIfRunning() return nil } diff --git a/cmd/inline-cli/setup.go b/cmd/inline-cli/setup.go index e4ff772..8d2bc00 100644 --- a/cmd/inline-cli/setup.go +++ b/cmd/inline-cli/setup.go @@ -10,7 +10,6 @@ import ( "github.com/spf13/cobra" "github.com/CCALITA/inline-cli/internal/config" - "github.com/CCALITA/inline-cli/internal/daemon" ) func newSetupCmd() *cobra.Command { @@ -28,15 +27,7 @@ func runSetup(cmd *cobra.Command, args []string) error { fmt.Println() for i, b := range backends { - status := "" - if b.Binary != "" { - if b.isInstalled() { - status = " \033[32m✓ installed\033[0m" - } else { - status = " \033[31m✗ not found\033[0m" - } - } - fmt.Printf(" %d) %-10s — %s%s\n", i+1, b.Name, b.Desc, status) + fmt.Printf(" %d) %-10s — %s%s\n", i+1, b.Name, b.Desc, b.installStatus()) } fmt.Println() @@ -60,7 +51,6 @@ func runSetup(cmd *cobra.Command, args []string) error { break } - // If API backend, prompt for API key. if chosen.Name == "api" { existing := os.Getenv("ANTHROPIC_API_KEY") if existing == "" { @@ -82,32 +72,19 @@ func runSetup(cmd *cobra.Command, args []string) error { } } - // Warn about env var override. if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" && v != chosen.Name { fmt.Println() fmt.Printf("\033[33mWarning: INLINE_CLI_BACKEND=%s is set and will override this config.\033[0m\n", v) fmt.Println("Run: unset INLINE_CLI_BACKEND") } - // Save config. if err := config.SaveBackend(chosen.Name); err != nil { return fmt.Errorf("failed to save config: %w", err) } fmt.Println() fmt.Printf("\033[32m✓\033[0m Backend set to %q\n", chosen.Name) - - // Restart daemon if running. - cfg, err := config.Load() - if err != nil { - return nil // non-fatal - } - - d := daemon.NewDaemon(cfg.PIDFile, cfg.SocketPath) - if d.IsRunning() { - d.Stop() - fmt.Println("\033[32m✓\033[0m Daemon restarted") - } + restartDaemonIfRunning() fmt.Println() fmt.Println("You're all set! Type something and press Ctrl+J (or Shift+Enter).") diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 6210ee6..0885e51 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -1,5 +1,10 @@ package backend +import ( + "fmt" + "strings" +) + // Backend defines the interface for communicating with Claude. // Implementations handle prompt delivery and streaming response capture. type Backend interface { @@ -14,3 +19,25 @@ type Message struct { Role string `json:"role"` Content string `json:"content"` } + +// extractPromptAndHistory splits a message list into the last user message +// (prompt) and a formatted history of preceding messages. +func extractPromptAndHistory(messages []Message) (prompt string, history []string) { + for i, m := range messages { + if i == len(messages)-1 && m.Role == "user" { + prompt = m.Content + } else { + prefix := "User" + if m.Role == "assistant" { + prefix = "Assistant" + } + history = append(history, fmt.Sprintf("%s: %s", prefix, m.Content)) + } + } + return +} + +// formatHistory joins history lines with a header, suitable for prepending to a prompt. +func formatHistory(history []string) string { + return "Previous conversation:\n" + strings.Join(history, "\n") +} diff --git a/internal/backend/cli.go b/internal/backend/cli.go index ac853c5..a296e3d 100644 --- a/internal/backend/cli.go +++ b/internal/backend/cli.go @@ -39,37 +39,15 @@ func (b *CLIBackend) Query(messages []Message, model string, onChunk func(text s return "", err } - // Build the prompt: use the last user message as the primary prompt. - // Pass conversation history as context via --system-prompt. - var prompt string - var history []string - - for i, m := range messages { - if i == len(messages)-1 && m.Role == "user" { - prompt = m.Content - } else { - prefix := "User" - if m.Role == "assistant" { - prefix = "Assistant" - } - history = append(history, fmt.Sprintf("%s: %s", prefix, m.Content)) - } - } - + prompt, history := extractPromptAndHistory(messages) if prompt == "" { return "", fmt.Errorf("no user message found") } args := []string{"-p", prompt, "--output-format", "text"} - // Don't pass --model to the CLI — let it use its own default. - // The CLI has its own model configuration and may not support - // the same models as the direct API. - - // Pass conversation history as system prompt for context. if len(history) > 0 { - systemCtx := "Previous conversation:\n" + strings.Join(history, "\n") - args = append(args, "--system-prompt", systemCtx) + args = append(args, "--system-prompt", formatHistory(history)) } cmd := exec.Command(binaryPath, args...) diff --git a/internal/backend/gemini.go b/internal/backend/gemini.go index 7976520..57ea19c 100644 --- a/internal/backend/gemini.go +++ b/internal/backend/gemini.go @@ -39,32 +39,13 @@ func (b *GeminiBackend) Query(messages []Message, model string, onChunk func(tex return "", err } - // Build the prompt: use the last user message as the primary prompt. - // Prepend conversation history as context directly in the prompt. - var prompt string - var history []string - - for i, m := range messages { - if i == len(messages)-1 && m.Role == "user" { - prompt = m.Content - } else { - prefix := "User" - if m.Role == "assistant" { - prefix = "Assistant" - } - history = append(history, fmt.Sprintf("%s: %s", prefix, m.Content)) - } - } - + prompt, history := extractPromptAndHistory(messages) if prompt == "" { return "", fmt.Errorf("no user message found") } - // If there is conversation history, prepend it to the prompt since the - // gemini CLI does not have a dedicated system-prompt flag. if len(history) > 0 { - historyCtx := "Previous conversation:\n" + strings.Join(history, "\n") + "\n\n" - prompt = historyCtx + prompt + prompt = formatHistory(history) + "\n\n" + prompt } // gemini CLI supports -p for non-interactive mode and -o for output format. diff --git a/internal/backend/opencode.go b/internal/backend/opencode.go index 8776ab6..e74c154 100644 --- a/internal/backend/opencode.go +++ b/internal/backend/opencode.go @@ -43,32 +43,13 @@ type openCodeEvent struct { } func (b *OpenCodeBackend) Query(messages []Message, model string, onChunk func(text string)) (string, error) { - // Build the prompt: use the last user message as the primary prompt. - // Pass conversation history as context in the prompt itself, - // since opencode does not support a --system-prompt flag. - var prompt string - var history []string - - for i, m := range messages { - if i == len(messages)-1 && m.Role == "user" { - prompt = m.Content - } else { - prefix := "User" - if m.Role == "assistant" { - prefix = "Assistant" - } - history = append(history, fmt.Sprintf("%s: %s", prefix, m.Content)) - } - } - + prompt, history := extractPromptAndHistory(messages) if prompt == "" { return "", fmt.Errorf("no user message found") } - // Prepend conversation history to the prompt for context. if len(history) > 0 { - historyCtx := "Previous conversation:\n" + strings.Join(history, "\n") + "\n\nCurrent request:\n" - prompt = historyCtx + prompt + prompt = formatHistory(history) + "\n\nCurrent request:\n" + prompt } // opencode uses `opencode run --format json ` for non-interactive mode. diff --git a/internal/config/config.go b/internal/config/config.go index 1e79775..3692ba6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -111,32 +111,23 @@ func configFilePath() string { return filepath.Join(homeDir, ".inline-cli", "config.toml") } -// ConfigFilePath returns the path to the config file. -func ConfigFilePath() string { - return configFilePath() -} - // SaveBackend updates the backend field in the config file, preserving other settings. // Creates the config directory and file if they don't exist. func SaveBackend(backend string) error { path := configFilePath() - // Ensure directory exists. if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } - // Load existing config to preserve other fields. cfg := DefaultConfig() - if _, err := os.Stat(path); err == nil { - if _, err := toml.DecodeFile(path, &cfg); err != nil { - return fmt.Errorf("failed to read existing config: %w", err) - } + if _, err := toml.DecodeFile(path, &cfg); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read existing config: %w", err) } cfg.Backend = backend - f, err := os.Create(path) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return fmt.Errorf("failed to write config file: %w", err) } From 510ca5078e19547e6ee902b82774f32f39167ae0 Mon Sep 17 00:00:00 2001 From: "fanxiyao.3" Date: Wed, 15 Apr 2026 15:35:50 +0800 Subject: [PATCH 3/8] docs: update README for multi-backend support Update install instructions to use setup wizard instead of hardcoded API key. Document new backend commands (setup, backend list/show/set). Update backends table with claude, gemini, opencode. Add env vars for gemini and opencode paths. Update architecture diagram to show multiple backend targets. --- README.md | 85 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index fdfd044..6382b76 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ No context switching. No new window. The answer streams in below your prompt and you keep working. Just press shift with enter to trigger claude response. + ## Install ```sh @@ -29,9 +30,9 @@ Or build from source: ```sh git clone https://github.com/CCALITA/inline-cli.git -cd inline-clig -make build -# Binary is at ./build/inline-cli +cd inline-cli +make build # Binary is at ./build/inline-cli +make install # Copies to $GOPATH/bin (or ~/go/bin) ``` Then add to your shell config: @@ -44,10 +45,10 @@ eval "$(inline-cli init zsh)" eval "$(inline-cli init bash)" ``` -Set your API key: +Run the setup wizard to choose a backend: ```sh -export ANTHROPIC_API_KEY=sk-ant-... +inline-cli setup ``` Restart your shell. Done. @@ -58,15 +59,19 @@ Restart your shell. Done. ┌─────────────────┐ Unix socket ┌──────────────┐ HTTPS/SSE ┌───────────┐ │ shell widget │ ──────────────────> │ daemon │ ─────────────────>│ Claude API│ │ captures buffer │ <── NDJSON stream── │ per-dir │ <── streaming ── │ │ - │ renders output │ │ sessions │ │ │ - └─────────────────┘ └──────────────┘ └───────────┘ + │ renders output │ │ sessions │ └───────────┘ + └─────────────────┘ │ │ or + │ │ ───> claude CLI + │ │ ───> gemini CLI + │ │ ───> opencode CLI + └──────────────┘ ``` **Three pieces:** 1. **Shell integration** — A zsh ZLE widget or bash readline binding captures your command-line buffer on Shift+Enter (or Ctrl+J) and pipes it to the CLI. 2. **Background daemon** — A long-lived Go process manages conversation sessions over a Unix domain socket. Sub-millisecond IPC. No cold start per query. -3. **Pluggable backend** — Talks to Claude via direct API, the `claude` CLI, or ACP. Streams responses as markdown to your terminal. +3. **Pluggable backend** — Talks to Claude via direct API, the `claude` CLI, Gemini CLI, or OpenCode CLI. Streams responses as markdown to your terminal. ### Directory-scoped sessions @@ -104,6 +109,12 @@ shift + enter # Check what's running inline-cli status +# Manage backends +inline-cli setup # Interactive first-time setup +inline-cli backend list # List backends with install status +inline-cli backend show # Show current backend +inline-cli backend set gemini # Switch backend (auto-restarts daemon) + # Manage the daemon inline-cli daemon start inline-cli daemon stop @@ -114,7 +125,7 @@ inline-cli daemon stop Config lives at `~/.inline-cli/config.toml`. All fields are optional — defaults are sensible. ```toml -# Backend: "api" (default), "cli", or "acp" +# Backend: "api" (default), "claude", "gemini", "opencode" backend = "api" # API backend settings @@ -122,8 +133,10 @@ api_key = "sk-ant-..." # or set ANTHROPIC_API_KEY env var model = "claude-sonnet-4-20250514" # default model api_base_url = "" # custom API endpoint (proxy, gateway) -# CLI backend settings (uses `claude` command) -cli_path = "" # auto-detected from PATH if empty +# CLI backend paths (auto-detected from PATH if empty) +cli_path = "" +gemini_path = "" +opencode_path = "" # Session settings max_session_idle_minutes = 30 @@ -132,34 +145,40 @@ max_messages = 50 ### Backends -| Backend | Config | What it does | -| ------------------- | ------------------------- | ---------------------------------------------------------------------------------- | -| **`api`** (default) | Needs `ANTHROPIC_API_KEY` | Direct HTTPS to Anthropic Messages API with SSE streaming | -| **`cli`** | Needs `claude` in PATH | Execs `claude -p ` and streams stdout. Uses your existing claude CLI auth. | -| **`acp`** | — | Agent Communication Protocol (planned) | +| Backend | Config | What it does | +| ---------------------- | ------------------------- | ---------------------------------------------------------------------------------- | +| **`api`** (default) | Needs `ANTHROPIC_API_KEY` | Direct HTTPS to Anthropic Messages API with SSE streaming | +| **`claude`** | Needs `claude` in PATH | Execs `claude -p ` and streams stdout. Uses your existing Claude CLI auth. | +| **`gemini`** | Needs `gemini` in PATH | Execs `gemini -p -o text` and streams stdout. Uses Gemini CLI auth. | +| **`opencode`** | Needs `opencode` in PATH | Execs `opencode run --format json` and parses the JSON event stream. | -Switch backends via config file or env var: +Switch backends via the CLI or config file: ```sh -# Use claude CLI instead of direct API -export INLINE_CLI_BACKEND=cli +# Interactive setup (detects installed CLIs) +inline-cli setup + +# Direct switch +inline-cli backend set gemini -# Or in config.toml -backend = "cli" +# Or via env var (overrides config file) +export INLINE_CLI_BACKEND=claude ``` ### Environment variables -| Variable | Purpose | -| ------------------------- | -------------------------------------- | -| `ANTHROPIC_API_KEY` | API key (required for `api` backend) | -| `INLINE_CLI_BACKEND` | Backend selection: `api`, `cli`, `acp` | -| `INLINE_CLI_MODEL` | Override model | -| `INLINE_CLI_SOCKET` | Custom socket path | -| `INLINE_CLI_API_BASE_URL` | Custom API endpoint | -| `INLINE_CLI_CLI_PATH` | Path to `claude` binary | -| `INLINE_CLI_MAX_IDLE` | Session idle timeout (minutes) | -| `INLINE_CLI_NO_PROMPT` | Set to `1` to disable prompt indicator | +| Variable | Purpose | +| --------------------------- | ------------------------------------------------ | +| `ANTHROPIC_API_KEY` | API key (required for `api` backend) | +| `INLINE_CLI_BACKEND` | Backend override: `api`, `claude`, `gemini`, `opencode` | +| `INLINE_CLI_MODEL` | Override model | +| `INLINE_CLI_SOCKET` | Custom socket path | +| `INLINE_CLI_API_BASE_URL` | Custom API endpoint | +| `INLINE_CLI_CLI_PATH` | Path to `claude` binary | +| `INLINE_CLI_GEMINI_PATH` | Path to `gemini` binary | +| `INLINE_CLI_OPENCODE_PATH` | Path to `opencode` binary | +| `INLINE_CLI_MAX_IDLE` | Session idle timeout (minutes) | +| `INLINE_CLI_NO_PROMPT` | Set to `1` to disable prompt indicator | Precedence: env vars > config file > defaults. @@ -236,7 +255,7 @@ Produces `{linux,darwin}_{amd64,arm64}` tarballs in `./build/` with SHA-256 chec inline-cli/ ├── cmd/inline-cli/ # CLI entry point + embedded shell scripts ├── internal/ -│ ├── backend/ # Backend interface + implementations (API, CLI, ACP) +│ ├── backend/ # Backend interface + implementations (API, Claude CLI, Gemini CLI, OpenCode CLI) │ ├── claude/ # Claude API client + SSE streaming parser │ ├── config/ # Config loading (TOML + env vars) │ ├── daemon/ # Daemon lifecycle + Unix socket server @@ -254,7 +273,7 @@ Single Go binary, ~14MB. No runtime dependencies. - **Go 1.22+** (build only) - **macOS** or **Linux** - **zsh** or **bash** -- One of: [Anthropic API key](https://console.anthropic.com/), `claude` CLI installed, or ACP endpoint +- One of: [Anthropic API key](https://console.anthropic.com/), `claude` CLI, `gemini` CLI, or `opencode` CLI ## Uninstall From b66f1368883815ce314765d337a39bf3a18c197b Mon Sep 17 00:00:00 2001 From: "fanxiyao.3" Date: Wed, 15 Apr 2026 16:02:20 +0800 Subject: [PATCH 4/8] fix: opencode lazy binary resolution and gemini non-zero exit handling - OpenCode backend now resolves the binary lazily at query time (matching claude and gemini backends) instead of eagerly at daemon startup. This fixes daemon startup failures when opencode is not yet in PATH. - Gemini backend now returns the response without error when output was produced but the CLI exits non-zero (e.g. skill conflict warnings). Previously this was treated as a hard error, stopping the response. --- internal/backend/gemini.go | 11 +++++----- internal/backend/gemini_test.go | 9 ++++---- internal/backend/opencode.go | 29 +++++++++++++++++--------- internal/backend/opencode_test.go | 34 ++++++++++++++++++------------- 4 files changed, 48 insertions(+), 35 deletions(-) diff --git a/internal/backend/gemini.go b/internal/backend/gemini.go index 57ea19c..403c35d 100644 --- a/internal/backend/gemini.go +++ b/internal/backend/gemini.go @@ -82,14 +82,13 @@ func (b *GeminiBackend) Query(messages []Message, model string, onChunk func(tex } if err := cmd.Wait(); err != nil { - // Extract the first meaningful line from stderr for the error message. - errMsg := firstLine(stderrBuf.String()) + // If we already streamed a response, treat non-zero exit as a warning + // (e.g. gemini prints skill conflict warnings to stderr but still + // produces valid output). Return the response without an error. if fullResponse.Len() > 0 { - if errMsg != "" { - return fullResponse.String(), fmt.Errorf("gemini CLI error: %s", errMsg) - } - return fullResponse.String(), fmt.Errorf("gemini CLI exited with error: %w", err) + return fullResponse.String(), nil } + errMsg := firstLine(stderrBuf.String()) if errMsg != "" { return "", fmt.Errorf("gemini CLI error: %s", errMsg) } diff --git a/internal/backend/gemini_test.go b/internal/backend/gemini_test.go index 76e122f..791a1a8 100644 --- a/internal/backend/gemini_test.go +++ b/internal/backend/gemini_test.go @@ -172,6 +172,8 @@ func TestGeminiBackend_Query_BinaryFailsNoStderr(t *testing.T) { } func TestGeminiBackend_Query_PartialOutputThenFail(t *testing.T) { + // When gemini produces output but exits non-zero (e.g. skill conflict + // warning), the response should be returned without an error. script := writeFakeGeminiScript(t, ` printf "partial" echo "something went wrong" >&2 @@ -186,11 +188,8 @@ exit 1 chunks = append(chunks, text) }) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "something went wrong") { - t.Errorf("error = %q, want stderr content surfaced", err.Error()) + if err != nil { + t.Fatalf("unexpected error: %v (should succeed when output exists)", err) } if result != "partial" { t.Errorf("result = %q, want %q", result, "partial") diff --git a/internal/backend/opencode.go b/internal/backend/opencode.go index e74c154..1166079 100644 --- a/internal/backend/opencode.go +++ b/internal/backend/opencode.go @@ -12,20 +12,24 @@ import ( // It invokes `opencode run --format json ` and parses the // newline-delimited JSON event stream from stdout. type OpenCodeBackend struct { - binaryPath string + configuredPath string } // NewOpenCodeBackend creates a backend that delegates to the opencode CLI. -// If binaryPath is empty, it looks for "opencode" in PATH. +// If binaryPath is empty, it will look for "opencode" in PATH on each query. func NewOpenCodeBackend(binaryPath string) (*OpenCodeBackend, error) { - if binaryPath == "" { - path, err := exec.LookPath("opencode") - if err != nil { - return nil, fmt.Errorf("opencode CLI not found in PATH: %w", err) - } - binaryPath = path + return &OpenCodeBackend{configuredPath: binaryPath}, nil +} + +func (b *OpenCodeBackend) resolveBinary() (string, error) { + if b.configuredPath != "" { + return b.configuredPath, nil + } + path, err := exec.LookPath("opencode") + if err != nil { + return "", fmt.Errorf("opencode CLI not found in PATH: %w", err) } - return &OpenCodeBackend{binaryPath: binaryPath}, nil + return path, nil } // openCodeEvent represents a single JSON event from `opencode run --format json`. @@ -43,6 +47,11 @@ type openCodeEvent struct { } func (b *OpenCodeBackend) Query(messages []Message, model string, onChunk func(text string)) (string, error) { + binaryPath, err := b.resolveBinary() + if err != nil { + return "", err + } + prompt, history := extractPromptAndHistory(messages) if prompt == "" { return "", fmt.Errorf("no user message found") @@ -60,7 +69,7 @@ func (b *OpenCodeBackend) Query(messages []Message, model string, onChunk func(t // model config. Let opencode use its own configured default. args := []string{"run", "--format", "json", prompt} - cmd := exec.Command(b.binaryPath, args...) + cmd := exec.Command(binaryPath, args...) stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/internal/backend/opencode_test.go b/internal/backend/opencode_test.go index 559ee66..d49f693 100644 --- a/internal/backend/opencode_test.go +++ b/internal/backend/opencode_test.go @@ -40,16 +40,22 @@ func TestNewOpenCodeBackend_WithPath(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if b.binaryPath != path { - t.Errorf("binaryPath = %q, want %q", b.binaryPath, path) + if b.configuredPath != path { + t.Errorf("configuredPath = %q, want %q", b.configuredPath, path) } } -func TestNewOpenCodeBackend_EmptyPath_NotFound(t *testing.T) { - // Ensure "opencode" is not in PATH by using a minimal PATH. +func TestOpenCodeBackend_Query_NotInPath(t *testing.T) { + // With empty path and opencode not in PATH, Query should fail. t.Setenv("PATH", t.TempDir()) - _, err := NewOpenCodeBackend("") + b, err := NewOpenCodeBackend("") + if err != nil { + t.Fatalf("unexpected error creating backend: %v", err) + } + + messages := []Message{{Role: "user", Content: "hello"}} + _, err = b.Query(messages, "", nil) if err == nil { t.Fatal("expected error when opencode is not in PATH, got nil") } @@ -59,7 +65,7 @@ func TestNewOpenCodeBackend_EmptyPath_NotFound(t *testing.T) { } func TestOpenCodeBackend_Query_NoUserMessage(t *testing.T) { - b := &OpenCodeBackend{binaryPath: "/bin/echo"} + b := &OpenCodeBackend{configuredPath: "/bin/echo"} // Only assistant messages, no user message at the end. messages := []Message{ @@ -80,7 +86,7 @@ func TestOpenCodeBackend_Query_SingleMessage(t *testing.T) { // Fake script that emits a JSON text event. script := writeFakeScript(t, fakeOpenCodeJSON("Hello from opencode")) - b := &OpenCodeBackend{binaryPath: script} + b := &OpenCodeBackend{configuredPath: script} messages := []Message{ {Role: "user", Content: "Say hello"}, @@ -111,7 +117,7 @@ printf '{"type":"text","part":{"text":" world"}}\n' printf '{"type":"step_finish","part":{}}\n' `) - b := &OpenCodeBackend{binaryPath: script} + b := &OpenCodeBackend{configuredPath: script} messages := []Message{ {Role: "user", Content: "Say hello world"}, @@ -146,7 +152,7 @@ printf '%s' "$arg" > `+argFile+` printf '{"type":"text","part":{"text":"ok"}}\n' `) - b := &OpenCodeBackend{binaryPath: script} + b := &OpenCodeBackend{configuredPath: script} messages := []Message{ {Role: "user", Content: "What is Go?"}, @@ -196,7 +202,7 @@ printf '{"type":"text","part":{"text":"actual output"}}\n' printf '{"type":"step_finish","part":{}}\n' `) - b := &OpenCodeBackend{binaryPath: script} + b := &OpenCodeBackend{configuredPath: script} messages := []Message{{Role: "user", Content: "hello"}} @@ -213,7 +219,7 @@ func TestOpenCodeBackend_Query_BinaryFails(t *testing.T) { // Fake script that exits with non-zero status and no JSON output. script := writeFakeScript(t, `exit 1`) - b := &OpenCodeBackend{binaryPath: script} + b := &OpenCodeBackend{configuredPath: script} messages := []Message{ {Role: "user", Content: "hello"}, @@ -235,7 +241,7 @@ printf '{"type":"text","part":{"text":"partial output"}}\n' exit 1 `) - b := &OpenCodeBackend{binaryPath: script} + b := &OpenCodeBackend{configuredPath: script} messages := []Message{ {Role: "user", Content: "hello"}, @@ -268,7 +274,7 @@ func TestOpenCodeBackend_Query_ErrorEvent(t *testing.T) { printf '{"type":"error","error":{"name":"UnknownError","data":{"message":"Model not found: bad-model"}}}\n' `) - b := &OpenCodeBackend{binaryPath: script} + b := &OpenCodeBackend{configuredPath: script} messages := []Message{ {Role: "user", Content: "hello"}, @@ -289,7 +295,7 @@ func TestOpenCodeBackend_Query_ErrorEventWithName(t *testing.T) { printf '{"type":"error","error":{"name":"ProviderError","data":{}}}\n' `) - b := &OpenCodeBackend{binaryPath: script} + b := &OpenCodeBackend{configuredPath: script} messages := []Message{{Role: "user", Content: "hello"}} _, err := b.Query(messages, "", nil) From 9d640934f151e5637df270aae969c17821d67922 Mon Sep 17 00:00:00 2001 From: "fanxiyao.3" Date: Wed, 15 Apr 2026 18:46:07 +0800 Subject: [PATCH 5/8] fix: remove INLINE_CLI_BACKEND env var override The env var silently overrode config file backend selection, making `backend set` and `setup` appear broken. Backend switching is now solely through `inline-cli backend set` or `inline-cli setup`, which write to the config file. Remove all INLINE_CLI_BACKEND references from config loading, CLI warnings, and documentation. --- README.md | 4 ---- cmd/inline-cli/backend.go | 14 -------------- cmd/inline-cli/setup.go | 6 ------ internal/config/config.go | 3 --- 4 files changed, 27 deletions(-) diff --git a/README.md b/README.md index 6382b76..3ef729f 100644 --- a/README.md +++ b/README.md @@ -160,9 +160,6 @@ inline-cli setup # Direct switch inline-cli backend set gemini - -# Or via env var (overrides config file) -export INLINE_CLI_BACKEND=claude ``` ### Environment variables @@ -170,7 +167,6 @@ export INLINE_CLI_BACKEND=claude | Variable | Purpose | | --------------------------- | ------------------------------------------------ | | `ANTHROPIC_API_KEY` | API key (required for `api` backend) | -| `INLINE_CLI_BACKEND` | Backend override: `api`, `claude`, `gemini`, `opencode` | | `INLINE_CLI_MODEL` | Override model | | `INLINE_CLI_SOCKET` | Custom socket path | | `INLINE_CLI_API_BASE_URL` | Custom API endpoint | diff --git a/cmd/inline-cli/backend.go b/cmd/inline-cli/backend.go index c6eb71f..a7f912f 100644 --- a/cmd/inline-cli/backend.go +++ b/cmd/inline-cli/backend.go @@ -135,10 +135,6 @@ func runBackendList(cmd *cobra.Command, args []string) error { fmt.Printf("%s%-10s — %s%s%s\n", marker, b.Name, b.Desc, b.installStatus(), activeSuffix) } - if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" { - fmt.Printf("\n\033[33mNote: INLINE_CLI_BACKEND=%s is set and overrides the config file.\033[0m\n", v) - } - return nil } @@ -149,11 +145,6 @@ func runBackendShow(cmd *cobra.Command, args []string) error { } fmt.Println(cfg.BackendName()) - - if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" { - fmt.Fprintf(os.Stderr, "Note: overridden by INLINE_CLI_BACKEND=%s\n", v) - } - return nil } @@ -164,11 +155,6 @@ func runBackendSet(cmd *cobra.Command, args []string) error { return fmt.Errorf("unknown backend %q (supported: %s)", name, strings.Join(validBackendNames(), ", ")) } - if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" { - fmt.Fprintf(os.Stderr, "\033[33mWarning: INLINE_CLI_BACKEND=%s is set and will override this config.\033[0m\n", v) - fmt.Fprintf(os.Stderr, "Run: unset INLINE_CLI_BACKEND\n\n") - } - if err := config.SaveBackend(name); err != nil { return fmt.Errorf("failed to save config: %w", err) } diff --git a/cmd/inline-cli/setup.go b/cmd/inline-cli/setup.go index 8d2bc00..6861bfe 100644 --- a/cmd/inline-cli/setup.go +++ b/cmd/inline-cli/setup.go @@ -72,12 +72,6 @@ func runSetup(cmd *cobra.Command, args []string) error { } } - if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" && v != chosen.Name { - fmt.Println() - fmt.Printf("\033[33mWarning: INLINE_CLI_BACKEND=%s is set and will override this config.\033[0m\n", v) - fmt.Println("Run: unset INLINE_CLI_BACKEND") - } - if err := config.SaveBackend(chosen.Name); err != nil { return fmt.Errorf("failed to save config: %w", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 3692ba6..8d5c454 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,9 +76,6 @@ func Load() (Config, error) { if v := os.Getenv("ANTHROPIC_API_KEY"); v != "" { cfg.APIKey = v } - if v := os.Getenv("INLINE_CLI_BACKEND"); v != "" { - cfg.Backend = v - } if v := os.Getenv("INLINE_CLI_MODEL"); v != "" { cfg.Model = v } From da5fc380f612df3e3531dde3d0a4ceb5c5d529fb Mon Sep 17 00:00:00 2001 From: "fanxiyao.3" Date: Wed, 15 Apr 2026 18:58:39 +0800 Subject: [PATCH 6/8] fix: address code review findings (security, error handling, atomicity) - Shell-quote API key output in setup wizard to prevent injection (C1) - Use os.Stat before toml.DecodeFile in SaveBackend matching Load() pattern, avoiding unreliable os.IsNotExist with wrapped TOML errors (C2) - Atomic config writes via temp file + os.Rename to prevent corruption (H3/H4) - Warn on stderr when config.Load() fails in restartDaemonIfRunning (H2) - Fix stale Backend comment in Config struct (M1) --- cmd/inline-cli/backend.go | 1 + cmd/inline-cli/setup.go | 2 +- internal/config/config.go | 39 +++++++++++++++++++++++++++++++-------- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/cmd/inline-cli/backend.go b/cmd/inline-cli/backend.go index a7f912f..daa5f40 100644 --- a/cmd/inline-cli/backend.go +++ b/cmd/inline-cli/backend.go @@ -64,6 +64,7 @@ func validBackendNames() []string { func restartDaemonIfRunning() { cfg, err := config.Load() if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not reload config: %v\n", err) return } d := daemon.NewDaemon(cfg.PIDFile, cfg.SocketPath) diff --git a/cmd/inline-cli/setup.go b/cmd/inline-cli/setup.go index 6861bfe..926527b 100644 --- a/cmd/inline-cli/setup.go +++ b/cmd/inline-cli/setup.go @@ -64,7 +64,7 @@ func runSetup(cmd *cobra.Command, args []string) error { if key != "" { fmt.Println() fmt.Println("Add this to your shell profile:") - fmt.Printf(" export ANTHROPIC_API_KEY=%s\n", key) + fmt.Printf(" export ANTHROPIC_API_KEY='%s'\n", strings.ReplaceAll(key, "'", "'\\''")) } } else { fmt.Println() diff --git a/internal/config/config.go b/internal/config/config.go index 8d5c454..ccefda9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,7 @@ import ( // Config holds all configuration for inline-cli. type Config struct { - // Backend selection: "api" (default), "cli", "acp" + // Backend selection: "api" (default), "claude", "gemini", "opencode" Backend string `toml:"backend"` // API backend settings @@ -110,29 +110,52 @@ func configFilePath() string { // SaveBackend updates the backend field in the config file, preserving other settings. // Creates the config directory and file if they don't exist. +// Uses atomic write (temp file + rename) to prevent corruption. func SaveBackend(backend string) error { path := configFilePath() + dir := filepath.Dir(path) - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + if err := os.MkdirAll(dir, 0700); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } cfg := DefaultConfig() - if _, err := toml.DecodeFile(path, &cfg); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read existing config: %w", err) + if _, err := os.Stat(path); err == nil { + if _, err := toml.DecodeFile(path, &cfg); err != nil { + return fmt.Errorf("failed to read existing config: %w", err) + } } cfg.Backend = backend - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + // Write to a temp file in the same directory, then rename for atomicity. + tmp, err := os.CreateTemp(dir, "config-*.toml.tmp") if err != nil { - return fmt.Errorf("failed to write config file: %w", err) + return fmt.Errorf("failed to create temp file: %w", err) } - defer f.Close() + tmpPath := tmp.Name() - if err := toml.NewEncoder(f).Encode(cfg); err != nil { + if err := os.Chmod(tmpPath, 0600); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("failed to set config file permissions: %w", err) + } + + if err := toml.NewEncoder(tmp).Encode(cfg); err != nil { + tmp.Close() + os.Remove(tmpPath) return fmt.Errorf("failed to encode config: %w", err) } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to write config file: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to save config file: %w", err) + } + return nil } From 95cf9d77e891ac0e3c27f47116b2ff1008a644f6 Mon Sep 17 00:00:00 2001 From: "fanxiyao.3" Date: Wed, 15 Apr 2026 22:19:37 +0800 Subject: [PATCH 7/8] fix: improve gemini stderr extraction to skip skill conflict warnings Replace firstLine() with extractError() that skips known non-fatal warnings (skill conflicts, stack traces) and prioritizes actionable API error messages (INVALID_ARGUMENT, PERMISSION_DENIED, etc.). --- internal/backend/gemini.go | 33 +++++++++++++++---- internal/backend/gemini_test.go | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/internal/backend/gemini.go b/internal/backend/gemini.go index 403c35d..a63b1b4 100644 --- a/internal/backend/gemini.go +++ b/internal/backend/gemini.go @@ -88,7 +88,7 @@ func (b *GeminiBackend) Query(messages []Message, model string, onChunk func(tex if fullResponse.Len() > 0 { return fullResponse.String(), nil } - errMsg := firstLine(stderrBuf.String()) + errMsg := extractError(stderrBuf.String()) if errMsg != "" { return "", fmt.Errorf("gemini CLI error: %s", errMsg) } @@ -98,13 +98,34 @@ func (b *GeminiBackend) Query(messages []Message, model string, onChunk func(tex return fullResponse.String(), nil } -// firstLine returns the first non-empty line from s, trimmed. -func firstLine(s string) string { - for _, line := range strings.Split(s, "\n") { +// extractError returns the most useful error message from gemini CLI stderr. +// It skips known non-fatal warnings (e.g. skill conflicts) and looks for +// actionable error messages. +func extractError(stderr string) string { + var fallback string + for _, line := range strings.Split(stderr, "\n") { line = strings.TrimSpace(line) - if line != "" { + if line == "" { + continue + } + // Skip non-fatal warnings. + if strings.HasPrefix(line, "Skill conflict") { + continue + } + // Skip stack traces. + if strings.HasPrefix(line, "at ") { + continue + } + if fallback == "" { + fallback = line + } + // Prefer lines that mention specific API errors. + if strings.Contains(line, "Error when talking to") || + strings.Contains(line, "INVALID_ARGUMENT") || + strings.Contains(line, "PERMISSION_DENIED") || + strings.Contains(line, "UNAUTHENTICATED") { return line } } - return "" + return fallback } diff --git a/internal/backend/gemini_test.go b/internal/backend/gemini_test.go index 791a1a8..f89ad9f 100644 --- a/internal/backend/gemini_test.go +++ b/internal/backend/gemini_test.go @@ -156,6 +156,64 @@ func TestGeminiBackend_Query_BinaryFails(t *testing.T) { } } +func TestExtractError(t *testing.T) { + tests := []struct { + name string + stderr string + want string + }{ + { + name: "empty", + stderr: "", + want: "", + }, + { + name: "skill conflict only", + stderr: "Skill conflict detected: foo vs bar\n", + want: "", + }, + { + name: "skill conflict with real error", + stderr: "Skill conflict detected: foo vs bar\nError when talking to Gemini API: 400\n", + want: "Error when talking to Gemini API: 400", + }, + { + name: "stack trace skipped", + stderr: "Something failed\nat /usr/lib/node.js:10:5\nat main.go:20\n", + want: "Something failed", + }, + { + name: "prefers API error over fallback", + stderr: "some warning\nINVALID_ARGUMENT: bad field\n", + want: "INVALID_ARGUMENT: bad field", + }, + { + name: "PERMISSION_DENIED", + stderr: "PERMISSION_DENIED: not authorized\n", + want: "PERMISSION_DENIED: not authorized", + }, + { + name: "UNAUTHENTICATED", + stderr: "UNAUTHENTICATED: missing credentials\n", + want: "UNAUTHENTICATED: missing credentials", + }, + { + name: "fallback to first non-skipped line", + stderr: "Skill conflict detected: x\nat foo.js:1\nactual problem here\n", + want: "actual problem here", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractError(tt.stderr) + if got != tt.want { + t.Errorf("extractError() = %q, want %q", got, tt.want) + } + }) + } +} + func TestGeminiBackend_Query_BinaryFailsNoStderr(t *testing.T) { script := writeFakeGeminiScript(t, `exit 1`) b := &GeminiBackend{configuredPath: script} From 97e70e6b9b5903cc34e865c975e5eef81bc56414 Mon Sep 17 00:00:00 2001 From: "fanxiyao.3" Date: Wed, 15 Apr 2026 22:30:01 +0800 Subject: [PATCH 8/8] docs: clarify build-from-source instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace make install with explicit cp to ~/.local/bin - Consolidate shell init + setup + restart into one numbered block - Fix Go version requirement (1.22 → 1.26) - Add make targets reference table --- README.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3ef729f..a943c50 100644 --- a/README.md +++ b/README.md @@ -31,27 +31,23 @@ Or build from source: ```sh git clone https://github.com/CCALITA/inline-cli.git cd inline-cli -make build # Binary is at ./build/inline-cli -make install # Copies to $GOPATH/bin (or ~/go/bin) +make build # → ./build/inline-cli +cp ./build/inline-cli ~/.local/bin/inline-cli # or anywhere in your PATH ``` -Then add to your shell config: +Then set up your shell and backend: ```sh -# zsh (~/.zshrc) -eval "$(inline-cli init zsh)" +# 1. Add shell integration (pick one) +echo 'eval "$(inline-cli init zsh)"' >> ~/.zshrc # zsh +echo 'eval "$(inline-cli init bash)"' >> ~/.bashrc # bash -# bash (~/.bashrc) -eval "$(inline-cli init bash)" -``` - -Run the setup wizard to choose a backend: - -```sh +# 2. Choose a backend inline-cli setup -``` -Restart your shell. Done. +# 3. Restart your shell +exec $SHELL +``` ## How it works @@ -245,6 +241,16 @@ make release Produces `{linux,darwin}_{amd64,arm64}` tarballs in `./build/` with SHA-256 checksums. +### Make targets + +| Target | What it does | +| -------------- | --------------------------------------------------- | +| `make build` | Build binary → `./build/inline-cli` | +| `make test` | Run all tests with `-race -cover` | +| `make lint` | Run `go vet` | +| `make clean` | Remove `./build/` | +| `make release` | Cross-compile + tarball + SHA-256 checksums | + ## Architecture ``` @@ -266,7 +272,7 @@ Single Go binary, ~14MB. No runtime dependencies. ## Requirements -- **Go 1.22+** (build only) +- **Go 1.26+** (build only) - **macOS** or **Linux** - **zsh** or **bash** - One of: [Anthropic API key](https://console.anthropic.com/), `claude` CLI, `gemini` CLI, or `opencode` CLI