Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4639745
Merge branch 'main' into develop
dkmnx May 23, 2026
cd75dcf
refactor: consolidate harness logic and introduce crypto interface
dkmnx May 25, 2026
21985de
refactor(providers): move key format validation into provider definit…
dkmnx May 25, 2026
3e2a666
style: remove redundant doc comments on delegate stubs and test helpers
dkmnx May 25, 2026
f1867aa
refactor(cmd): deduplicate config loading with loadConfigOrExit and l…
dkmnx May 25, 2026
2d6af7c
refactor(cmd): simplify providerFromArgs with inline branching
dkmnx May 25, 2026
6e8f281
style: tighten doc comments on new API surface
dkmnx May 25, 2026
618bd24
refactor(cmd): rename yoloFlag to skipPermissionsFlag
dkmnx May 25, 2026
63e32c8
refactor(cmd): remove harnessBinary identity function
dkmnx May 25, 2026
228c0db
fix: report errors instead of silently discarding them
dkmnx May 25, 2026
a4092cb
refactor(cmd): rename hasDoubleDash to hasArgsSeparator
dkmnx May 25, 2026
f53ad65
feat(providers): support custom provider definitions in config.yaml
dkmnx May 25, 2026
576a512
docs: document custom_providers in config, provider, and dev guides
dkmnx May 25, 2026
2fab419
docs: fix pre-existing markdownlint issues
dkmnx May 25, 2026
83ecc72
docs: switch contributing guide to Google Go Style
dkmnx May 25, 2026
15498fd
docs: fix markdown formatting in config and providers docs
dkmnx May 26, 2026
b564e3d
chore: add commandcode taste configuration
dkmnx May 26, 2026
53803eb
fix(cmd): surface loadConfigOrEmpty errors instead of swallowing them
dkmnx May 27, 2026
c0c6eda
refactor(cmd): remove setupSignalHandler, add StartSession tests
dkmnx May 27, 2026
2990046
refactor(providers): compute provider order from priority list
dkmnx May 27, 2026
a58c650
test(execution): skip signal-self test on Windows
dkmnx May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .commandcode/taste/taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Taste (Continuously Learned by [CommandCode][cmd])

[cmd]: https://commandcode.ai/

# git
- Use short conventional commit messages (type(scope): description) — describe only what changed, no explanations or AI slop. Confidence: 0.70
- Do not add Co-authored-by or other attribution lines to commit messages. Confidence: 0.75

# audit
- Always run the /slop audit on changed code before committing, checking for AI-generated slop patterns (obvious comments, TODO placeholders, identity functions, robotic naming). Confidence: 0.85

# code-style
- Do not write AI-generated slop code — avoid obvious comments that restate the code, redundant doc strings, and architectural trivia in comments. Confidence: 0.85
- Always follow Google Go style conventions. Confidence: 0.85

# workflow
- Always run markdownlint-cli2 after modifying markdown files. Confidence: 0.90
4 changes: 3 additions & 1 deletion cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,9 @@ PowerShell:

if closeOut {
if f, ok := out.(*os.File); ok {
f.Close()
if err := f.Close(); err != nil {
cmd.Printf("Error closing completion file: %v\n", err)
}
}
}
},
Expand Down
41 changes: 30 additions & 11 deletions cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,37 @@ import (

"github.com/dkmnx/kairo/internal/config"
"github.com/dkmnx/kairo/internal/constants"
"github.com/dkmnx/kairo/internal/crypto"
"github.com/spf13/cobra"
)

type cliContextKey struct{}

// ConfigDirResolver resolves the default configuration directory.
type ConfigDirResolver func() (string, error)

// CLIContext holds shared CLI state: config directory, verbosity, config cache,
// root context, and external dependencies. It is safe for concurrent use.
type CLIContext struct {
configDir string
configDirMu sync.RWMutex
verbose bool
verboseMu sync.RWMutex
configCache *config.ConfigCache
rootCtx context.Context
deps *Deps
configDir string
configDirMu sync.RWMutex
configDirResolver ConfigDirResolver
verbose bool
verboseMu sync.RWMutex
configCache *config.ConfigCache
rootCtx context.Context
deps *Deps

defaultProviderExplicit bool
}

// NewCLIContext creates a CLIContext with default settings.
func NewCLIContext() *CLIContext {
return &CLIContext{
configCache: config.NewConfigCache(constants.ConfigCacheTTL),
rootCtx: context.Background(),
deps: NewDeps(),
configDirResolver: config.DefaultConfigDir,
configCache: config.NewConfigCache(constants.ConfigCacheTTL),
rootCtx: context.Background(),
deps: NewDeps(),
}
}

Expand All @@ -43,14 +49,22 @@ func (c *CLIContext) ConfigDir() string {
return c.configDir
}

dir, err := config.ConfigDir()
dir, err := c.configDirResolver()
if err != nil {
return ""
}

return dir
}

// SetConfigDirResolver sets the function used to locate the config directory.
func (c *CLIContext) SetConfigDirResolver(r ConfigDirResolver) {
c.configDirMu.Lock()
defer c.configDirMu.Unlock()

c.configDirResolver = r
}

// SetConfigDir overrides the configuration directory.
func (c *CLIContext) SetConfigDir(dir string) {
c.configDirMu.Lock()
Expand Down Expand Up @@ -90,6 +104,11 @@ func (c *CLIContext) Deps() *Deps {
return c.deps
}

// Crypto returns the crypto service for this CLI session.
func (c *CLIContext) Crypto() crypto.Service {
return c.deps.Crypto
}

// SetDeps replaces the external dependencies. For use in tests.
func (c *CLIContext) SetDeps(d *Deps) {
c.deps = d
Expand Down
17 changes: 17 additions & 0 deletions cmd/coverage_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package cmd

import (
"bytes"
stderrors "errors"
"os"
"strings"
"testing"

"github.com/dkmnx/kairo/internal/config"
"github.com/dkmnx/kairo/internal/providers"
"github.com/spf13/cobra"
)

func TestHandleSecretsError(t *testing.T) {
Expand Down Expand Up @@ -235,3 +238,17 @@ func TestProviderDefinition(t *testing.T) {
t.Errorf("expected 'custom-provider', got %q", def.Name)
}
}

func TestHandleConfigErrorNonBinary(t *testing.T) {
cmd := &cobra.Command{}
buf := new(bytes.Buffer)
cmd.SetOut(buf)
cmd.SetErr(buf)

handleConfigError(cmd, stderrors.New("test error"))

out := buf.String()
if !strings.Contains(out, "Error loading config") {
t.Errorf("expected error message, got: %s", out)
}
}
26 changes: 7 additions & 19 deletions cmd/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ package cmd

import (
"context"
stderrors "errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
Expand All @@ -25,22 +23,12 @@ var deleteCmd = &cobra.Command{
Long: "Remove a provider from Kairo. If no provider is specified, shows an interactive list of configured providers.",
Run: func(cmd *cobra.Command, args []string) {
cliCtx := CLIContextFromCmd(cmd)
dir := requireConfigDir(cmd)
if dir == "" {
return
}

cfg, err := config.LoadConfig(cliCtx.RootCtx(), dir)
if err != nil {
if stderrors.Is(err, fs.ErrNotExist) {
printNoProvidersMessage()

return
}
handleConfigError(cmd, err)

cfg, err := loadConfigOrExit(cmd)
if err != nil || cfg == nil {
return
}
dir := cliCtx.ConfigDir()

var target string
if len(args) == 0 {
Expand Down Expand Up @@ -113,7 +101,7 @@ var deleteCmd = &cobra.Command{
secretsPath := filepath.Join(dir, constants.SecretsFileName)
keyPath := filepath.Join(dir, constants.KeyFileName)

if err := deleteProviderSecrets(cliCtx.RootCtx(), secretsPath, keyPath, target); err != nil {
if err := deleteProviderSecrets(cliCtx.RootCtx(), cliCtx.Crypto(), secretsPath, keyPath, target); err != nil {
tap.Cancel(fmt.Sprintf("Failed to clean up secrets for '%s': %v", target, err))

return
Expand All @@ -123,8 +111,8 @@ var deleteCmd = &cobra.Command{
},
}

func deleteProviderSecrets(ctx context.Context, secretsPath, keyPath, providerName string) error {
existingSecrets, err := crypto.DecryptSecretsBytes(ctx, secretsPath, keyPath)
func deleteProviderSecrets(ctx context.Context, svc crypto.Service, secretsPath, keyPath, providerName string) error {
existingSecrets, err := svc.DecryptSecretsBytes(ctx, secretsPath, keyPath)
if err != nil {
return errors.WrapError(errors.CryptoError,
"failed to decrypt secrets for cleanup", err).
Expand Down Expand Up @@ -157,7 +145,7 @@ func deleteProviderSecrets(ctx context.Context, secretsPath, keyPath, providerNa
return nil
}

if err := crypto.EncryptSecrets(ctx, secretsPath, keyPath, secretsContent); err != nil {
if err := svc.EncryptSecrets(ctx, secretsPath, keyPath, secretsContent); err != nil {
return errors.WrapError(errors.CryptoError,
"could not update secrets", err).
WithContext("path", secretsPath)
Expand Down
8 changes: 4 additions & 4 deletions cmd/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func TestDeleteCmdDeletesProviderSecrets(t *testing.T) {
t.Fatalf("EncryptSecrets() error = %v", err)
}

result, err := LoadSecrets(context.Background(), tmpDir)
result, err := LoadSecrets(NewCLIContext(), tmpDir)
if err != nil {
t.Fatalf("LoadSecrets() error = %v", err)
}
Expand All @@ -134,7 +134,7 @@ func TestDeleteCmdDeletesProviderSecrets(t *testing.T) {
t.Fatalf("EncryptSecrets() error = %v", err)
}

result, err = LoadSecrets(context.Background(), tmpDir)
result, err = LoadSecrets(NewCLIContext(), tmpDir)
if err != nil {
t.Fatalf("LoadSecrets() error = %v", err)
}
Expand Down Expand Up @@ -189,7 +189,7 @@ func TestDeleteProviderSecretsReturnsErrorOnBadKey(t *testing.T) {
secretsPath := filepath.Join(tmpDir, constants.SecretsFileName)
keyPath := filepath.Join(tmpDir, "nonexistent.key")

err := deleteProviderSecrets(context.Background(), secretsPath, keyPath, "testprovider")
err := deleteProviderSecrets(context.Background(), NewCLIContext().Crypto(), secretsPath, keyPath, "testprovider")
if err == nil {
t.Fatal("deleteProviderSecrets should return error when decryption fails")
}
Expand Down Expand Up @@ -217,7 +217,7 @@ func TestDeleteProviderSecretsPreservesMalformedLines(t *testing.T) {
t.Fatalf("EncryptSecrets() error = %v", err)
}

if err := deleteProviderSecrets(context.Background(), secretsPath, keyPath, "PROVIDER_TO_DELETE"); err != nil {
if err := deleteProviderSecrets(context.Background(), NewCLIContext().Crypto(), secretsPath, keyPath, "PROVIDER_TO_DELETE"); err != nil {
t.Fatalf("deleteProviderSecrets() error = %v", err)
}

Expand Down
2 changes: 2 additions & 0 deletions cmd/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"

"github.com/dkmnx/kairo/internal/crypto"
"github.com/dkmnx/kairo/internal/ui"
"github.com/dkmnx/kairo/internal/update"
"github.com/dkmnx/kairo/internal/wrapper"
Expand Down Expand Up @@ -65,5 +66,6 @@ func NewDeps() *Deps {
Process: osProcessRunner{},
Wrapper: prodWrapperService{},
Update: &prodUpdateService{client: update.NewClient()},
Crypto: crypto.DefaultService{},
}
}
2 changes: 1 addition & 1 deletion cmd/execution_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func BuildProviderEnv(
) (EnvBuildResult, error) {
builtIn := BuildBuiltInEnvVars(provider)

secretsResult, err := LoadSecrets(cliCtx.RootCtx(), configDir)
secretsResult, err := LoadSecrets(cliCtx, configDir)
if err != nil {
if RequiresAPIKey(providerName) {
return EnvBuildResult{}, err
Expand Down
42 changes: 42 additions & 0 deletions cmd/execution_env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,45 @@ func TestPiAPIKeyEnvVarMapping(t *testing.T) {
})
}
}

func TestHarnessAPIKeyEnvVar(t *testing.T) {
tests := []struct {
provider string
want string
}{
{"zai", "ZAI_API_KEY"},
{"minimax", "MINIMAX_API_KEY"},
{"anthropic", "ANTHROPIC_API_KEY"},
}

for _, tt := range tests {
t.Run(tt.provider, func(t *testing.T) {
got := HarnessAPIKeyEnvVar(tt.provider)
if got != tt.want {
t.Errorf("HarnessAPIKeyEnvVar(%q) = %q, want %q", tt.provider, got, tt.want)
}
})
}
}

func TestYoloModeFlag(t *testing.T) {
tests := []struct {
name string
harness string
want string
}{
{"claude", harnessClaude, "--dangerously-skip-permissions"},
{"qwen", harnessQwen, "--yolo"},
{"pi", harnessPi, ""},
{"crush", harnessCrush, "--yolo"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := yoloModeFlag(tt.harness)
if got != tt.want {
t.Errorf("yoloModeFlag(%q) = %q, want %q", tt.harness, got, tt.want)
}
})
}
}
16 changes: 3 additions & 13 deletions cmd/execution_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,15 @@ import (

"github.com/dkmnx/kairo/internal/constants"
kairoerrors "github.com/dkmnx/kairo/internal/errors"
"github.com/dkmnx/kairo/internal/harness"
"github.com/dkmnx/kairo/internal/ui"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

const claudeYoloFlag = "--dangerously-skip-permissions"
const qwenYoloFlag = "--yolo"

func yoloModeFlag(harness string) string {
if harness == harnessQwen || harness == harnessCrush {
return qwenYoloFlag
}
if harness == harnessPi {
return ""
}

return claudeYoloFlag
func yoloModeFlag(h string) string {
return harness.YoloFlag(h)
}

func handleConfigError(cmd *cobra.Command, err error) {
if isBinaryOutdatedError(err) {
promptUpgrade(cmd, err)
Expand Down
Loading
Loading