From 4d556d5956e72597b15923daa052a956b0469252 Mon Sep 17 00:00:00 2001 From: Benedick Montales Date: Sat, 14 Feb 2026 23:19:34 +0800 Subject: [PATCH 1/6] docs: fix documentation inconsistencies with source code - Fix build commands: task -> just (README.md, user-guide.md) - Fix config file reference: config -> config.yaml (user-guide.md) - Fix Makefile -> justfile (architecture/README.md) - Add missing commands to maintenance table: backup, restore, recover, metrics - Remove broken links to non-existent cmd/README.md and internal/README.md - Fix table alignment for markdownlint compliance --- README.md | 30 +++++++++++++++++------------- docs/architecture/README.md | 2 +- docs/guides/user-guide.md | 4 ++-- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3312254..4fc3885 100644 --- a/README.md +++ b/README.md @@ -186,13 +186,15 @@ kairo metrics reset | Command | Description | | ------------------------------- | ---------------------------------- | | `kairo rotate` | Rotate encryption key | -| `kairo audit ` | View/export audit logs | +| `kairo backup` | Create backup of configuration | +| `kairo restore ` | Restore from backup | +| `kairo recover` | Generate/restore recovery phrase | +| `kairo audit` | View/export audit logs | +| `kairo metrics` | View performance metrics | | `kairo update` | Check for updates | | `kairo completion ` | Shell completion | | `kairo version` | Show version info | -[Full Command Reference](cmd/README.md) - ## Configuration | OS | Location | @@ -229,27 +231,29 @@ kairo metrics reset ### Reference -| Resource | Description | -|---------------------------------------------------------------|--------------------------------------| -| [Command Reference](cmd/README.md) | CLI command details | -| [Internal Packages](internal/README.md) | Core modules reference | -| [Troubleshooting](docs/troubleshooting/README.md) | Common issues and solutions | -| [Changelog](CHANGELOG.md) | Version history | +| Resource | Description | +| --------------------------------------------------------------- | -------------------------------------- | +| [Development Guide](docs/guides/development-guide.md) | Setup and contribution | +| [Architecture](docs/architecture/README.md) | System design and diagrams | +| [Wrapper Scripts](docs/architecture/wrapper-scripts.md) | Security design and rationale | +| [Contributing](docs/contributing/README.md) | Contribution workflow | +| [Troubleshooting](docs/troubleshooting/README.md) | Common issues and solutions | +| [Changelog](CHANGELOG.md) | Version history | ## Building ```bash # Build -task build # or: go build -o dist/kairo . +just build # or: go build -o dist/kairo . # Test -task test # or: go test -race ./... +just test # or: go test -race ./... # Lint -task lint # or: gofmt -w . && go vet ./... +just lint # or: gofmt -w . && go vet ./... # Format -task format # or: gofmt -w . +just format # or: gofmt -w . ``` ## Security diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c8b6a11..4ebdfed 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -119,7 +119,7 @@ kairo/ │ ├── guides/ # User & dev guides │ └── troubleshooting/ # Common issues ├── scripts/ # Install scripts -└── Makefile # Build targets +└── justfile # Command runner ``` ## Data Flow: Provider Configuration diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index ee3a22f..8b2c9a1 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -15,7 +15,7 @@ curl -sSL https://raw.githubusercontent.com/dkmnx/kairo/main/scripts/install.sh ```bash git clone https://github.com/dkmnx/kairo.git cd kairo -make build +just build # Binary outputs to ./dist/kairo ``` @@ -231,7 +231,7 @@ Location: `~/.config/kairo/` | File | Purpose | Permissions | | ------------- | ------------------------- | ------------- | -| `config` | Provider configurations | 0600 | +| `config.yaml` | Provider configurations | 0600 | | `secrets.age` | Encrypted API keys | 0600 | | `age.key` | Encryption private key | 0600 | From 5a0c619a608ea6e0f17f6f88e291a2d791a5375d Mon Sep 17 00:00:00 2001 From: Benedick Montales Date: Sun, 15 Feb 2026 01:08:30 +0800 Subject: [PATCH 2/6] feat(switch): add multi-harness support (claude/qwen) - Add --harness flag to switch command to select CLI harness - Add --model flag to override model (passed to Qwen CLI) - Add getHarness() and getHarnessBinary() helper functions - Add harness get/set commands for managing default harness - Add DefaultHarness field to Config struct - Enhance GenerateWrapperScript to support custom env var names - Qwen uses ANTHROPIC_API_KEY wrapper for secure API key handling - Validate harness names and warn on invalid values - Support case-insensitive harness names --- cmd/harness.go | 99 ++++++++++++++++++++++++++++ cmd/switch.go | 127 +++++++++++++++++++++++++++++++++--- internal/config/loader.go | 2 + internal/wrapper/wrapper.go | 28 ++++---- 4 files changed, 236 insertions(+), 20 deletions(-) create mode 100644 cmd/harness.go diff --git a/cmd/harness.go b/cmd/harness.go new file mode 100644 index 0000000..50a8fe6 --- /dev/null +++ b/cmd/harness.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/dkmnx/kairo/internal/config" + kairoerrors "github.com/dkmnx/kairo/internal/errors" + "github.com/dkmnx/kairo/internal/ui" + "github.com/spf13/cobra" +) + +var harnessGetCmd = &cobra.Command{ + Use: "get", + Short: "Get current harness", + Long: "Get the currently configured default harness", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + dir := getConfigDir() + if dir == "" { + ui.PrintError("Config directory not found") + return + } + + cfg, err := configCache.Get(dir) + if err != nil { + ui.PrintError(fmt.Sprintf("Error loading config: %v", err)) + return + } + + if cfg.DefaultHarness == "" { + ui.PrintInfo("No default harness configured (using claude)") + return + } + + ui.PrintInfo(fmt.Sprintf("Default harness: %s", cfg.DefaultHarness)) + }, +} + +var harnessSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set default harness", + Long: "Set the default CLI harness to use (claude or qwen)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + harnessName := strings.ToLower(args[0]) + + if harnessName != "claude" && harnessName != "qwen" { + ui.PrintError(fmt.Sprintf("Invalid harness: '%s'", args[0])) + ui.PrintInfo("Valid harnesses: claude, qwen") + return + } + + dir := getConfigDir() + if dir == "" { + ui.PrintError("Config directory not found") + return + } + + if err := os.MkdirAll(dir, 0700); err != nil { + ui.PrintError(fmt.Sprintf("Error creating config directory: %v", err)) + return + } + + cfg, err := configCache.Get(dir) + if err != nil && !errors.Is(err, kairoerrors.ErrConfigNotFound) { + ui.PrintError(fmt.Sprintf("Error loading config: %v", err)) + return + } + if err != nil { + cfg = &config.Config{ + Providers: make(map[string]config.Provider), + DefaultModels: make(map[string]string), + } + } + + cfg.DefaultHarness = harnessName + if err := config.SaveConfig(dir, cfg); err != nil { + ui.PrintError(fmt.Sprintf("Error saving config: %v", err)) + return + } + + ui.PrintSuccess(fmt.Sprintf("Default harness set to: %s", harnessName)) + }, +} + +var harnessCmd = &cobra.Command{ + Use: "harness", + Short: "Manage CLI harness", + Long: "Manage the CLI harness (claude or qwen)", +} + +func init() { + harnessCmd.AddCommand(harnessGetCmd) + harnessCmd.AddCommand(harnessSetCmd) + rootCmd.AddCommand(harnessCmd) +} diff --git a/cmd/switch.go b/cmd/switch.go index 619d503..45e50ef 100644 --- a/cmd/switch.go +++ b/cmd/switch.go @@ -32,6 +32,39 @@ var exitProcess = os.Exit // It can be replaced in tests to avoid requiring actual executables. var lookPath = exec.LookPath +var ( + modelFlag string + harnessFlag string +) + +// getHarness returns the harness to use, checking flag then config then defaulting to claude. +func getHarness(cfg *config.Config, flagHarness string) string { + harness := flagHarness + if harness == "" { + harness = cfg.DefaultHarness + } + if harness == "" { + return "claude" + } + if harness != "claude" && harness != "qwen" { + ui.PrintWarn(fmt.Sprintf("Unknown harness '%s', using 'claude'", harness)) + return "claude" + } + return harness +} + +// getHarnessBinary returns the CLI binary name for a given harness. +func getHarnessBinary(harness string) string { + switch harness { + case "qwen": + return "qwen" + case "claude": + return "claude" + default: + return "claude" + } +} + // mergeEnvVars merges environment variable slices, deduplicating by key. // If duplicate keys are found, the last value wins (preserves order of precedence). // Env vars should be in "KEY=VALUE" format. @@ -101,6 +134,9 @@ var switchCmd = &cobra.Command{ ui.PrintWarn(fmt.Sprintf("Audit logging failed: %v", err)) } + harnessToUse := getHarness(cfg, harnessFlag) + harnessBinary := getHarnessBinary(harnessToUse) + // Environment variable name constants for model configuration const ( envBaseURL = "ANTHROPIC_BASE_URL" @@ -176,14 +212,77 @@ var switchCmd = &cobra.Command{ return } - claudeArgs := args[1:] - claudePath, err := lookPath("claude") + cliArgs := args[1:] + + // Handle Qwen harness - use wrapper for secure API key + if harnessToUse == "qwen" { + modelToUse := modelFlag + if modelToUse == "" { + modelToUse = provider.Model + } + + cliArgs = append([]string{"--model", modelToUse}, cliArgs...) + + ui.ClearScreen() + ui.PrintBanner(version.Version, provider.Name) + + qwenPath, err := lookPath(harnessBinary) + if err != nil { + cmd.Printf("Error: '%s' command not found in PATH\n", harnessBinary) + cmd.Printf("Please install %s CLI or use 'kairo harness set claude'\n", harnessToUse) + return + } + + wrapperScript, useCmdExe, err := wrapper.GenerateWrapperScript(authDir, tokenPath, qwenPath, cliArgs, "ANTHROPIC_API_KEY") + if err != nil { + cmd.Printf("Error generating wrapper script: %v\n", err) + return + } + + // Set up signal handling for cleanup on SIGINT/SIGTERM + sigChan := make(chan os.Signal, 1) + defer func() { + signal.Stop(sigChan) + close(sigChan) + }() + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigChan + cleanup() + code := 128 + if s, ok := sig.(syscall.Signal); ok { + code += int(s) + } + exitProcess(code) + }() + + var execCmd *exec.Cmd + if useCmdExe { + execCmd = execCommand("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", wrapperScript) + } else { + execCmd = execCommand(wrapperScript) + } + execCmd.Env = providerEnv + execCmd.Stdin = os.Stdin + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + + if err := execCmd.Run(); err != nil { + cmd.Printf("Error running Qwen: %v\n", err) + exitProcess(1) + } + return + } + + // Claude harness - existing wrapper script logic + claudePath, err := lookPath(harnessBinary) if err != nil { - cmd.Println("Error: 'claude' command not found in PATH") + cmd.Printf("Error: '%s' command not found in PATH\n", harnessBinary) return } - wrapperScript, useCmdExe, err := wrapper.GenerateWrapperScript(authDir, tokenPath, claudePath, claudeArgs) + wrapperScript, useCmdExe, err := wrapper.GenerateWrapperScript(authDir, tokenPath, claudePath, cliArgs) if err != nil { cmd.Printf("Error generating wrapper script: %v\n", err) return @@ -236,19 +335,27 @@ var switchCmd = &cobra.Command{ return } - // No API key found, run claude directly without auth token - claudeArgs := args[1:] + // No API key found + cliArgs := args[1:] + + // Handle Qwen harness + if harnessToUse == "qwen" { + ui.PrintError(fmt.Sprintf("API key not found for provider '%s'", providerName)) + ui.PrintInfo("Qwen Code requires API keys to be set in environment variables.") + return + } - claudePath, err := lookPath("claude") + // Claude harness - run directly without auth token + claudePath, err := lookPath(harnessBinary) if err != nil { - cmd.Println("Error: 'claude' command not found in PATH") + cmd.Printf("Error: '%s' command not found in PATH\n", harnessBinary) return } ui.ClearScreen() ui.PrintBanner(version.Version, provider.Name) - execCmd := execCommand(claudePath, claudeArgs...) + execCmd := execCommand(claudePath, cliArgs...) execCmd.Env = providerEnv execCmd.Stdin = os.Stdin execCmd.Stdout = os.Stdout @@ -262,5 +369,7 @@ var switchCmd = &cobra.Command{ } func init() { + switchCmd.Flags().StringVar(&modelFlag, "model", "", "Model to use (passed through to CLI harness)") + switchCmd.Flags().StringVar(&harnessFlag, "harness", "", "CLI harness to use (claude or qwen)") rootCmd.AddCommand(switchCmd) } diff --git a/internal/config/loader.go b/internal/config/loader.go index db9ae60..ead9377 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -18,6 +18,8 @@ type Config struct { // Version is deprecated and kept for backward compatibility with existing configs. // It is no longer used but cannot be removed without breaking existing configs. Version string `yaml:"version,omitempty"` + // DefaultHarness specifies the default CLI harness to use (claude or qwen). + DefaultHarness string `yaml:"default_harness,omitempty"` } // Provider represents a configured API provider. diff --git a/internal/wrapper/wrapper.go b/internal/wrapper/wrapper.go index d047095..28f70b5 100644 --- a/internal/wrapper/wrapper.go +++ b/internal/wrapper/wrapper.go @@ -105,15 +105,21 @@ func EscapePowerShellArg(arg string) string { } // GenerateWrapperScript creates a temporary script that reads the API key from the -// token file, sets ANTHROPIC_AUTH_TOKEN environment variable, cleans up the token -// file, and executes the claude command with the provided arguments. +// token file, sets the specified environment variable, cleans up the token +// file, and executes the CLI command with the provided arguments. +// envVarName defaults to "ANTHROPIC_AUTH_TOKEN" if empty. // Returns the path to the wrapper script and whether to use shell execution. -func GenerateWrapperScript(authDir, tokenPath, claudePath string, claudeArgs []string) (string, bool, error) { +func GenerateWrapperScript(authDir, tokenPath, cliPath string, cliArgs []string, envVarName ...string) (string, bool, error) { if tokenPath == "" { return "", false, fmt.Errorf("token path cannot be empty") } - if claudePath == "" { - return "", false, fmt.Errorf("claude path cannot be empty") + if cliPath == "" { + return "", false, fmt.Errorf("cli path cannot be empty") + } + + envVar := "ANTHROPIC_AUTH_TOKEN" + if len(envVarName) > 0 && envVarName[0] != "" { + envVar = envVarName[0] } isWindows := runtime.GOOS == "windows" @@ -128,10 +134,10 @@ func GenerateWrapperScript(authDir, tokenPath, claudePath string, claudeArgs []s if isWindows { scriptContent = "# Generated by kairo - DO NOT EDIT\r\n" scriptContent += "# This script will be automatically deleted after execution\r\n" - scriptContent += fmt.Sprintf("$env:ANTHROPIC_AUTH_TOKEN = Get-Content -Path %q -Raw\r\n", tokenPath) + scriptContent += fmt.Sprintf("$env:%s = Get-Content -Path %q -Raw\r\n", envVar, tokenPath) scriptContent += fmt.Sprintf("Remove-Item -Path %q -Force\r\n", tokenPath) - scriptContent += fmt.Sprintf("& %q", claudePath) - for _, arg := range claudeArgs { + scriptContent += fmt.Sprintf("& %q", cliPath) + for _, arg := range cliArgs { scriptContent += fmt.Sprintf(" %s", EscapePowerShellArg(arg)) } scriptContent += "\r\n" @@ -139,10 +145,10 @@ func GenerateWrapperScript(authDir, tokenPath, claudePath string, claudeArgs []s scriptContent = "#!/bin/sh\n" scriptContent += "# Generated by kairo - DO NOT EDIT\n" scriptContent += "# This script will be automatically deleted after execution\n" - scriptContent += fmt.Sprintf("export ANTHROPIC_AUTH_TOKEN=$(cat %q)\n", tokenPath) + scriptContent += fmt.Sprintf("export %s=$(cat %q)\n", envVar, tokenPath) scriptContent += fmt.Sprintf("rm -f %q\n", tokenPath) - scriptContent += "exec " + fmt.Sprintf("%q", claudePath) - for _, arg := range claudeArgs { + scriptContent += "exec " + fmt.Sprintf("%q", cliPath) + for _, arg := range cliArgs { scriptContent += " " + fmt.Sprintf("%q", arg) } scriptContent += "\n" From a97f1be13c0ae3828921659b998ec6887f1ecdce Mon Sep 17 00:00:00 2001 From: Benedick Montales Date: Sun, 15 Feb 2026 01:09:15 +0800 Subject: [PATCH 3/6] test: add tests for harness functionality and wrapper env var - Add cmd/harness_test.go with tests for: - harness get/set commands - getHarness() and getHarnessBinary() functions - Case-insensitive harness validation - Add wrapper envVarName parameter tests: - Custom env var (ANTHROPIC_API_KEY) - Default env var (ANTHROPIC_AUTH_TOKEN) - Empty string defaults to ANTHROPIC_AUTH_TOKEN --- cmd/harness_test.go | 222 +++++++++++++++++++++++++++++++ internal/wrapper/wrapper_test.go | 64 +++++++++ 2 files changed, 286 insertions(+) create mode 100644 cmd/harness_test.go diff --git a/cmd/harness_test.go b/cmd/harness_test.go new file mode 100644 index 0000000..304ad44 --- /dev/null +++ b/cmd/harness_test.go @@ -0,0 +1,222 @@ +package cmd + +import ( + "testing" + + "github.com/dkmnx/kairo/internal/config" +) + +func TestHarnessGetNoConfig(t *testing.T) { + originalConfigDir := getConfigDir() + defer func() { setConfigDir(originalConfigDir) }() + + tmpDir := t.TempDir() + setConfigDir(tmpDir) + + rootCmd.SetArgs([]string{"harness", "get"}) + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestHarnessGetWithConfig(t *testing.T) { + originalConfigDir := getConfigDir() + defer func() { setConfigDir(originalConfigDir) }() + + tmpDir := t.TempDir() + setConfigDir(tmpDir) + + cfg := &config.Config{ + Providers: make(map[string]config.Provider), + DefaultModels: make(map[string]string), + DefaultHarness: "qwen", + } + err := config.SaveConfig(tmpDir, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + rootCmd.SetArgs([]string{"harness", "get"}) + err = rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestHarnessSetClaude(t *testing.T) { + originalConfigDir := getConfigDir() + defer func() { setConfigDir(originalConfigDir) }() + + tmpDir := t.TempDir() + setConfigDir(tmpDir) + + rootCmd.SetArgs([]string{"harness", "set", "claude"}) + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + cfg, err := config.LoadConfig(tmpDir) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if cfg.DefaultHarness != "claude" { + t.Errorf("DefaultHarness = %q, want %q", cfg.DefaultHarness, "claude") + } +} + +func TestHarnessSetQwen(t *testing.T) { + originalConfigDir := getConfigDir() + defer func() { setConfigDir(originalConfigDir) }() + + tmpDir := t.TempDir() + setConfigDir(tmpDir) + + rootCmd.SetArgs([]string{"harness", "set", "qwen"}) + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + cfg, err := config.LoadConfig(tmpDir) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if cfg.DefaultHarness != "qwen" { + t.Errorf("DefaultHarness = %q, want %q", cfg.DefaultHarness, "qwen") + } +} + +func TestHarnessSetInvalid(t *testing.T) { + originalConfigDir := getConfigDir() + defer func() { setConfigDir(originalConfigDir) }() + + tmpDir := t.TempDir() + setConfigDir(tmpDir) + + rootCmd.SetArgs([]string{"harness", "set", "invalid"}) + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestHarnessSetCaseInsensitive(t *testing.T) { + tests := []struct { + name string + harnessName string + expected string + }{ + {"uppercase CLAUDE", "CLAUDE", "claude"}, + {"uppercase QWEN", "QWEN", "qwen"}, + {"mixed case Claude", "Claude", "claude"}, + {"mixed case Qwen", "Qwen", "qwen"}, + {"lowercase claude", "claude", "claude"}, + {"lowercase qwen", "qwen", "qwen"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalConfigDir := getConfigDir() + defer func() { setConfigDir(originalConfigDir) }() + + tmpDir := t.TempDir() + setConfigDir(tmpDir) + + rootCmd.SetArgs([]string{"harness", "set", tt.harnessName}) + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + cfg, err := config.LoadConfig(tmpDir) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if cfg.DefaultHarness != tt.expected { + t.Errorf("DefaultHarness = %q, want %q", cfg.DefaultHarness, tt.expected) + } + }) + } +} + +func TestGetHarness(t *testing.T) { + tests := []struct { + name string + flagHarness string + configHarness string + expected string + }{ + {"flag takes precedence", "qwen", "claude", "qwen"}, + {"uses config when flag empty", "", "qwen", "qwen"}, + {"defaults to claude when both empty", "", "", "claude"}, + {"defaults to claude when config invalid", "", "invalid", "claude"}, + {"defaults to claude when flag invalid", "invalid", "", "claude"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + Providers: make(map[string]config.Provider), + DefaultModels: make(map[string]string), + DefaultHarness: tt.configHarness, + } + + result := getHarness(cfg, tt.flagHarness) + if result != tt.expected { + t.Errorf("getHarness() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestGetHarnessBinary(t *testing.T) { + tests := []struct { + name string + harness string + expected string + }{ + {"claude returns claude", "claude", "claude"}, + {"qwen returns qwen", "qwen", "qwen"}, + {"unknown returns claude", "unknown", "claude"}, + {"empty returns claude", "", "claude"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getHarnessBinary(tt.harness) + if result != tt.expected { + t.Errorf("getHarnessBinary() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestGetHarnessWithExistingConfig(t *testing.T) { + originalConfigDir := getConfigDir() + defer func() { setConfigDir(originalConfigDir) }() + + tmpDir := t.TempDir() + setConfigDir(tmpDir) + + cfg := &config.Config{ + Providers: make(map[string]config.Provider), + DefaultModels: make(map[string]string), + DefaultHarness: "qwen", + } + err := config.SaveConfig(tmpDir, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + loadedCfg, err := config.LoadConfig(tmpDir) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + result := getHarness(loadedCfg, "") + if result != "qwen" { + t.Errorf("getHarness() = %q, want %q", result, "qwen") + } +} diff --git a/internal/wrapper/wrapper_test.go b/internal/wrapper/wrapper_test.go index f37fb82..60eded7 100644 --- a/internal/wrapper/wrapper_test.go +++ b/internal/wrapper/wrapper_test.go @@ -402,3 +402,67 @@ func containsAt(s, substr string) bool { } return false } + +func TestGenerateWrapperScript_CustomEnvVar(t *testing.T) { + authDir := t.TempDir() + tokenPath := filepath.Join(authDir, "token") + cliPath := "/usr/local/bin/claude" + args := []string{"--help"} + + t.Run("uses custom env var when provided", func(t *testing.T) { + scriptPath, _, err := GenerateWrapperScript(authDir, tokenPath, cliPath, args, "ANTHROPIC_API_KEY") + if err != nil { + t.Fatalf("GenerateWrapperScript() error = %v", err) + } + defer os.Remove(scriptPath) + + content, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + scriptStr := string(content) + if !strings.Contains(scriptStr, "ANTHROPIC_API_KEY") { + t.Error("Script should use custom env var ANTHROPIC_API_KEY") + } + if strings.Contains(scriptStr, "ANTHROPIC_AUTH_TOKEN") { + t.Error("Script should not contain ANTHROPIC_AUTH_TOKEN when custom env var is provided") + } + }) + + t.Run("uses default auth token when not provided", func(t *testing.T) { + scriptPath, _, err := GenerateWrapperScript(authDir, tokenPath, cliPath, args) + if err != nil { + t.Fatalf("GenerateWrapperScript() error = %v", err) + } + defer os.Remove(scriptPath) + + content, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + scriptStr := string(content) + if !strings.Contains(scriptStr, "ANTHROPIC_AUTH_TOKEN") { + t.Error("Script should use default env var ANTHROPIC_AUTH_TOKEN") + } + }) + + t.Run("uses empty string as default", func(t *testing.T) { + scriptPath, _, err := GenerateWrapperScript(authDir, tokenPath, cliPath, args, "") + if err != nil { + t.Fatalf("GenerateWrapperScript() error = %v", err) + } + defer os.Remove(scriptPath) + + content, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + scriptStr := string(content) + if !strings.Contains(scriptStr, "ANTHROPIC_AUTH_TOKEN") { + t.Error("Empty string should default to ANTHROPIC_AUTH_TOKEN") + } + }) +} From 73a9648a111dfe3fcf00b750b83927808af48e2d Mon Sep 17 00:00:00 2001 From: Benedick Montales Date: Sun, 15 Feb 2026 01:09:30 +0800 Subject: [PATCH 4/6] docs: update documentation for harness feature - README.md: Add Multi-Harness feature, fix table formatting - cmd/README.md: Add harness commands, --harness and --model flags - docs/guides/user-guide.md: Add harness get/set documentation - cmd/setup.go: Add promptForHarness() for setup wizard --- README.md | 5 +++-- cmd/README.md | 39 ++++++++++++++++++++++++++++++++++++--- cmd/setup.go | 35 +++++++++++++++++++++++++++++++++++ docs/guides/user-guide.md | 26 ++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4fc3885..8bed47c 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,12 @@ flowchart TB | Feature | Description | | ---------------------- | ---------------------------------------------------------- | -| **Multi-Provider** | Native Anthropic, Z.AI, MiniMax, Kimi, DeepSeek, custom | +| **Multi-Harness** | Claude Code (default), Qwen Code | | **Secure Encryption** | Age (X25519) encryption for all API keys | | **Key Rotation** | Regenerate encryption keys periodically | | **Audit Logging** | Track all configuration changes | | **Cross-Platform** | Linux, macOS, Windows support | +| **Model Override** | `--model` flag to override Qwen Code model (passed through to qwen) | ## Metrics @@ -177,7 +178,7 @@ kairo metrics reset | Command | Description | | ---------------------------- | ------------------------------------------ | -| `kairo switch ` | Switch and exec Claude | +| `kairo switch ` | Switch and exec Claude (with --model flag for Qwen) | | `kairo [args]` | Shorthand for switch | | `kairo -- "query"` | Query mode (default provider) | diff --git a/cmd/README.md b/cmd/README.md index f177f41..b3de410 100644 --- a/cmd/README.md +++ b/cmd/README.md @@ -12,7 +12,8 @@ CLI command implementations using the Cobra framework. | `list.go` | List all configured providers | | `status.go` | Test connectivity for all providers | | `test.go` | Test specific provider connectivity | -| `switch.go` | Switch provider and execute Claude | +| `switch.go` | Switch provider and execute CLI (Claude or Qwen) | +| `harness.go` | Manage CLI harness (claude or qwen) | | `default.go` | Get or set default provider | | `reset.go` | Remove provider configurations | | `rotate.go` | Rotate encryption key | @@ -79,8 +80,12 @@ flowchart TB | Command | Description | | ----------------------------- | -------------------------------------------------- | -| `kairo switch ` | Switch and execute Claude | -| `kairo [args]` | Shorthand for switch (e.g., `kairo zai`) | +| `kairo switch ` | Switch and execute CLI (claude or qwen) | +| `kairo switch --harness qwen` | Switch using Qwen CLI | +| `kairo switch --model ` | Override model (Qwen harness only) | +| `kairo harness get` | Get current default harness | +| `kairo harness set ` | Set default harness (claude or qwen) | +| `kairo [args]` | Shorthand for switch (e.g., `kairo zai`) | | `kairo -- "query"` | Query mode using default provider | ### Maintenance @@ -163,6 +168,7 @@ go test -v ./cmd/... -run Integration | ---------------- | -------------------------------- | | `-v, --verbose` | Enable verbose output | | `-h, --help` | Show help for command | +| `--harness` | CLI harness to use (claude/qwen) | ## Banner Display @@ -174,6 +180,33 @@ kairo v1.2.0 - Provider: zai This is rendered from `internal/version/version.go` and `providers` package. +## CLI Harnesses + +Kairo supports multiple CLI harnesses: + +| Harness | CLI Binary | Description | +| -------- | --------- | ----------- | +| `claude` | `claude` | Claude Code (default) | +| `qwen` | `qwen` | Qwen Code | + +### Using Harnesses + +```bash +# Use Qwen harness for a specific provider +kairo switch zai --harness qwen + +# Override model for Qwen +kairo switch zai --harness qwen --model qwen-turbo + +# Set default harness globally +kairo harness set qwen + +# Get current harness +kairo harness get +``` + +The `--model` flag is passed through to Qwen CLI. If not specified, uses the provider's configured model. + ## Provider Shorthand Users can use provider name directly instead of `switch`: diff --git a/cmd/setup.go b/cmd/setup.go index 201ddbf..7b26703 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -206,6 +206,31 @@ func promptForProvider() string { return strings.TrimSpace(selection) } +// promptForHarness prompts user to select a CLI harness (claude or qwen). +func promptForHarness() string { + ui.PrintHeader("CLI Harness Selection\n") + ui.PrintWhite("Select CLI harness:") + ui.PrintWhite(" 1. Claude Code (default)") + ui.PrintWhite(" 2. Qwen Code") + ui.PrintWhite("") + + selection, err := ui.PromptWithDefault("Selection [1-2]", "1") + if err != nil { + ui.PrintError(fmt.Sprintf("Failed to read input: %v", err)) + return "" + } + + num := parseIntOrZero(selection) + if num < 1 || num > 2 { + return "claude" + } + + if num == 2 { + return "qwen" + } + return "claude" +} + // parseProviderSelection converts user input to a provider name. func parseProviderSelection(selection string) (string, bool) { if selection == "" || selection == "done" || selection == "q" || selection == "exit" { @@ -382,6 +407,16 @@ var setupCmd = &cobra.Command{ return } + harnessSelection := promptForHarness() + if harnessSelection != "" { + cfg.DefaultHarness = harnessSelection + if err := config.SaveConfig(dir, cfg); err != nil { + ui.PrintError(fmt.Sprintf("Error saving config: %v", err)) + return + } + ui.PrintSuccess(fmt.Sprintf("Harness set to: %s", harnessSelection)) + } + secrets, secretsPath, keyPath, err := LoadSecrets(dir) if err != nil { ui.PrintError(fmt.Sprintf("Failed to decrypt secrets file: %v", err)) diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index 8b2c9a1..d938f2d 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -131,10 +131,36 @@ kairo switch zai # Switch and execute query kairo switch zai "Explain goroutines" +# Use Qwen harness instead of Claude +kairo switch zai --harness qwen + +# Override model for Qwen +kairo switch zai --harness qwen --model qwen-turbo + # Short form kairo switch zai ``` +### `kairo harness get` + +Get the current default CLI harness. + +```bash +kairo harness get +``` + +### `kairo harness set ` + +Set the default CLI harness (claude or qwen). + +```bash +# Set default to Qwen +kairo harness set qwen + +# Set default to Claude +kairo harness set claude +``` + ### `kairo default [provider]` Get or set the default provider. From 335e38c57026fcf3c73281045c6099c96c104792 Mon Sep 17 00:00:00 2001 From: Benedick Montales Date: Sun, 15 Feb 2026 01:12:07 +0800 Subject: [PATCH 5/6] docs: update README for multi-harness support - Update description to mention Claude Code and Qwen Code - Add prerequisites section for both Claude and Qwen CLI - Add harness commands to Execution section - Update Security section to mention wrapper scripts --- README.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8bed47c..98fad74 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,13 @@ [![CI Status](https://img.shields.io/github/actions/workflow/status/dkmnx/kairo/ci.yml?branch=main&style=flat-square)](https://github.com/dkmnx/kairo/actions) [![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](LICENSE) -**Secure CLI for managing Claude Code API providers** with age (X25519) encryption, multi-provider support, and audit logging. +**Secure CLI for managing Claude Code and Qwen Code API providers** with age (X25519) encryption, multi-provider support, and audit logging. ## Prerequisites -### Required: Claude Code CLI +### Required: Claude Code CLI or Qwen Code CLI -Kairo acts as a wrapper around Claude Code CLI to enable multi-provider support. You need to install Claude Code first. +Kairo acts as a wrapper around Claude Code or Qwen Code CLI to enable multi-provider support. You need to install at least one of them. **Install Claude Code:** @@ -37,10 +37,22 @@ Kairo acts as a wrapper around Claude Code CLI to enable multi-provider support. npm install -g @anthropic-ai/claude-code ``` +**Install Qwen Code:** + +- Visit: +- Or via package managers: + + ```bash + # npm + npm install -g qwen-code + ``` + **Verify installation:** ```bash claude --version +# or +qwen --version ``` ## Quick Start @@ -178,7 +190,11 @@ kairo metrics reset | Command | Description | | ---------------------------- | ------------------------------------------ | -| `kairo switch ` | Switch and exec Claude (with --model flag for Qwen) | +| `kairo switch ` | Switch and exec CLI (claude or qwen) | +| `kairo switch --harness qwen` | Switch using Qwen CLI | +| `kairo switch --model ` | Override model (Qwen only) | +| `kairo harness get` | Get current default harness | +| `kairo harness set ` | Set default harness (claude or qwen) | | `kairo [args]` | Shorthand for switch | | `kairo -- "query"` | Query mode (default provider) | @@ -262,6 +278,7 @@ just format # or: gofmt -w . - Age (X25519) encryption for all API keys - 0600 permissions on sensitive files - Secrets decrypted in-memory only +- Secure wrapper scripts for both Claude and Qwen harnesses - Key generation on first run - Use `kairo rotate` for periodic key rotation From 02ecedcbe50c4285ce210ba937b769fe97f08d82 Mon Sep 17 00:00:00 2001 From: Benedick Montales Date: Sun, 15 Feb 2026 01:17:18 +0800 Subject: [PATCH 6/6] docs: fix markdown table alignment (MD060) Fix table column alignment in README.md and cmd/README.md to pass markdownlint validation. --- README.md | 32 ++++++++++++++-------------- cmd/README.md | 58 +++++++++++++++++++++++++-------------------------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 98fad74..8c80197 100644 --- a/README.md +++ b/README.md @@ -119,13 +119,13 @@ flowchart TB ## Features -| Feature | Description | -| ---------------------- | ---------------------------------------------------------- | -| **Multi-Harness** | Claude Code (default), Qwen Code | -| **Secure Encryption** | Age (X25519) encryption for all API keys | -| **Key Rotation** | Regenerate encryption keys periodically | -| **Audit Logging** | Track all configuration changes | -| **Cross-Platform** | Linux, macOS, Windows support | +| Feature | Description | +| ---------------------- | ------------------------------------------------------------------- | +| **Multi-Harness** | Claude Code (default), Qwen Code | +| **Secure Encryption** | Age (X25519) encryption for all API keys | +| **Key Rotation** | Regenerate encryption keys periodically | +| **Audit Logging** | Track all configuration changes | +| **Cross-Platform** | Linux, macOS, Windows support | | **Model Override** | `--model` flag to override Qwen Code model (passed through to qwen) | ## Metrics @@ -188,15 +188,15 @@ kairo metrics reset ### Execution -| Command | Description | -| ---------------------------- | ------------------------------------------ | -| `kairo switch ` | Switch and exec CLI (claude or qwen) | -| `kairo switch --harness qwen` | Switch using Qwen CLI | -| `kairo switch --model ` | Override model (Qwen only) | -| `kairo harness get` | Get current default harness | -| `kairo harness set ` | Set default harness (claude or qwen) | -| `kairo [args]` | Shorthand for switch | -| `kairo -- "query"` | Query mode (default provider) | +| Command | Description | +| ----------------------------------------- | ------------------------------------------ | +| `kairo switch ` | Switch and exec CLI (claude or qwen) | +| `kairo switch --harness qwen` | Switch using Qwen CLI | +| `kairo switch --model ` | Override model (Qwen only) | +| `kairo harness get` | Get current default harness | +| `kairo harness set ` | Set default harness (claude or qwen) | +| `kairo [args]` | Shorthand for switch | +| `kairo -- "query"` | Query mode (default provider) | ### Maintenance diff --git a/cmd/README.md b/cmd/README.md index b3de410..7d7844c 100644 --- a/cmd/README.md +++ b/cmd/README.md @@ -4,23 +4,23 @@ CLI command implementations using the Cobra framework. ## Structure -| File | Purpose | -| ------------------- | ---------------------------------------------- | -| `root.go` | Root command, banner display, global flags | -| `setup.go` | Interactive configuration wizard | -| `config.go` | Configure individual providers | -| `list.go` | List all configured providers | -| `status.go` | Test connectivity for all providers | -| `test.go` | Test specific provider connectivity | +| File | Purpose | +| ------------------- | ------------------------------------------------ | +| `root.go` | Root command, banner display, global flags | +| `setup.go` | Interactive configuration wizard | +| `config.go` | Configure individual providers | +| `list.go` | List all configured providers | +| `status.go` | Test connectivity for all providers | +| `test.go` | Test specific provider connectivity | | `switch.go` | Switch provider and execute CLI (Claude or Qwen) | -| `harness.go` | Manage CLI harness (claude or qwen) | -| `default.go` | Get or set default provider | -| `reset.go` | Remove provider configurations | -| `rotate.go` | Rotate encryption key | -| `version.go` | Display version information | -| `update.go` | Check for and install updates | -| `completion.go` | Shell completion support | -| `audit.go` | View and export audit logs | +| `harness.go` | Manage CLI harness (claude or qwen) | +| `default.go` | Get or set default provider | +| `reset.go` | Remove provider configurations | +| `rotate.go` | Rotate encryption key | +| `version.go` | Display version information | +| `update.go` | Check for and install updates | +| `completion.go` | Shell completion support | +| `audit.go` | View and export audit logs | ## Command Architecture @@ -78,15 +78,15 @@ flowchart TB ### Execution -| Command | Description | -| ----------------------------- | -------------------------------------------------- | -| `kairo switch ` | Switch and execute CLI (claude or qwen) | -| `kairo switch --harness qwen` | Switch using Qwen CLI | -| `kairo switch --model ` | Override model (Qwen harness only) | -| `kairo harness get` | Get current default harness | -| `kairo harness set ` | Set default harness (claude or qwen) | -| `kairo [args]` | Shorthand for switch (e.g., `kairo zai`) | -| `kairo -- "query"` | Query mode using default provider | +| Command | Description | +| ----------------------------------------- | -------------------------------------------------- | +| `kairo switch ` | Switch and execute CLI (claude or qwen) | +| `kairo switch --harness qwen` | Switch using Qwen CLI | +| `kairo switch --model ` | Override model (Qwen harness only) | +| `kairo harness get` | Get current default harness | +| `kairo harness set ` | Set default harness (claude or qwen) | +| `kairo [args]` | Shorthand for switch (e.g., `kairo zai`) | +| `kairo -- "query"` | Query mode using default provider | ### Maintenance @@ -184,10 +184,10 @@ This is rendered from `internal/version/version.go` and `providers` package. Kairo supports multiple CLI harnesses: -| Harness | CLI Binary | Description | -| -------- | --------- | ----------- | -| `claude` | `claude` | Claude Code (default) | -| `qwen` | `qwen` | Qwen Code | +| Harness | CLI Binary | Description | +| -------- | ---------- | --------------------- | +| `claude` | `claude` | Claude Code (default) | +| `qwen` | `qwen` | Qwen Code | ### Using Harnesses