diff --git a/README.md b/README.md
index 3312254..8c80197 100644
--- a/README.md
+++ b/README.md
@@ -16,13 +16,13 @@
[](https://github.com/dkmnx/kairo/actions)
[](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
@@ -107,13 +119,14 @@ flowchart TB
## Features
-| Feature | Description |
-| ---------------------- | ---------------------------------------------------------- |
-| **Multi-Provider** | Native Anthropic, Z.AI, MiniMax, Kimi, DeepSeek, custom |
-| **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
@@ -175,24 +188,30 @@ kairo metrics reset
### Execution
-| Command | Description |
-| ---------------------------- | ------------------------------------------ |
-| `kairo switch ` | Switch and exec Claude |
-| `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
| 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 +248,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
@@ -257,6 +278,7 @@ task 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
diff --git a/cmd/README.md b/cmd/README.md
index f177f41..7d7844c 100644
--- a/cmd/README.md
+++ b/cmd/README.md
@@ -4,22 +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 |
-| `switch.go` | Switch provider and execute Claude |
-| `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 |
+| 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 |
## Command Architecture
@@ -77,11 +78,15 @@ flowchart TB
### Execution
-| Command | Description |
-| ----------------------------- | -------------------------------------------------- |
-| `kairo switch ` | Switch and execute Claude |
-| `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
@@ -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/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/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/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/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/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..d938f2d 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
```
@@ -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.
@@ -231,7 +257,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 |
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"
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")
+ }
+ })
+}