From 0d5989c443e920336ee2fe2a3fab82075a0f199f Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sat, 4 Apr 2026 14:34:16 +0000 Subject: [PATCH 1/2] feat(cmd): add --model flag to init command (#188) Add optional --model/-m flag to specify a model ID for the agent during workspace initialization. The flag takes precedence over any model defined in the agent's default settings files. - Add SetModel method to Agent interface - Implement SetModel for Claude (sets model in .claude.json) - Implement SetModel for Goose (sets GOOSE_MODEL in config.yaml) - Implement SetModel for Cursor (set model in .cursor/cli-config.json) - Call SetModel in manager.Add when model is specified - Update README and skills documentation Made-with: Cursor Co-Authored-By: Claude Code (Claude Sonnet 4.5) Signed-off-by: Philippe Martin --- README.md | 44 ++- pkg/agent/agent.go | 6 + pkg/agent/claude.go | 38 +- pkg/agent/claude_test.go | 166 ++++++++- pkg/agent/cursor.go | 40 ++ pkg/agent/cursor_test.go | 192 ++++++++++ pkg/agent/goose.go | 30 ++ pkg/agent/goose_test.go | 126 +++++++ pkg/agent/registry_test.go | 6 +- pkg/cmd/init.go | 8 + pkg/cmd/init_test.go | 68 +++- pkg/instances/manager.go | 10 + pkg/instances/manager_test.go | 350 ++++++++++++++++++ skills/working-with-config-system/SKILL.md | 20 +- .../working-with-instances-manager/SKILL.md | 4 +- 15 files changed, 1071 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 80935ea..f813b08 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ To reuse your host Claude Code settings (preferences, custom instructions, etc.) - Run `gcloud auth application-default login` on your host machine before starting the workspace to ensure valid credentials are available - The `$HOME/.config/gcloud` mount is read-only to prevent the workspace from modifying your host credentials - No `ANTHROPIC_API_KEY` is needed when using Vertex AI — credentials are provided via the mounted gcloud configuration -- To pin a specific Claude model, add a `ANTHROPIC_MODEL` environment variable (e.g., `"claude-opus-4-5"`) +- To pin a specific Claude model, use `--model` flag during `init` (e.g., `--model claude-sonnet-4-20250514`), which takes precedence over any model in default settings, or add an `ANTHROPIC_MODEL` environment variable (e.g., `"claude-opus-4-5"`) ### Starting Claude with Default Settings @@ -361,7 +361,7 @@ EOF **Fields:** -- `GOOSE_MODEL` - The model identifier Goose uses for its AI interactions +- `GOOSE_MODEL` - The model identifier Goose uses for its AI interactions. Alternatively, use `--model` flag during `init` to set this (the flag takes precedence over this setting) - `GOOSE_TELEMETRY_ENABLED` - Whether Goose sends usage telemetry; set to `true` to opt in, or omit to have kortex-cli default it to `false` **Step 3: Register and start the workspace** @@ -496,23 +496,18 @@ Create or edit `~/.kortex-cli/config/agents.json` to inject the API key. No moun mkdir -p ~/.kortex-cli/config/cursor/.cursor ``` -**Step 3: Write the default Cursor settings file** +**Step 3: Write the default Cursor settings file (optional)** -As an example, you can configure a default model: +You can optionally pre-configure Cursor with additional settings by creating a `cli-config.json` file: ```bash cat > ~/.kortex-cli/config/cursor/.cursor/cli-config.json << 'EOF' { "model": { - "modelId": "claude-4.5-opus-high-thinking", - "displayModelId": "claude-4.5-opus-high-thinking", - "displayName": "Opus 4.5 Thinking", - "displayNameShort": "Opus 4.5 Thinking", - "aliases": [ - "opus", - "opus-4.5", - "opus-4-5" - ], + "modelId": "my-preferred-model", + "displayModelId": "my-preferred-model", + "displayName": "My Preferred Model", + "displayNameShort": "My Model", "maxMode": false }, "hasChangedDefaultModel": true @@ -524,14 +519,18 @@ EOF - `model.modelId` - The model identifier used internally by Cursor - `model.displayName` / `model.displayNameShort` - Human-readable model names shown in the UI -- `model.aliases` - Shorthand names that can be used to reference the model - `model.maxMode` - Whether to enable max mode for this model - `hasChangedDefaultModel` - Tells Cursor that the model selection is intentional and should not prompt the user to choose a model +**Note:** Using the `--model` flag during `init` is the preferred way to configure the model, as it automatically sets all model fields correctly. + **Step 4: Register and start the workspace** ```bash -# Register a workspace — the settings file is embedded in the container image +# Register a workspace with a specific model using the --model flag (recommended) +kortex-cli init /path/to/project --runtime podman --agent cursor --model my-model-id + +# Or register without --model to use settings from cli-config.json kortex-cli init /path/to/project --runtime podman --agent cursor # Start the workspace @@ -542,14 +541,16 @@ kortex-cli terminal my-project ``` When `init` runs, kortex-cli: -1. Reads all files from `~/.kortex-cli/config/cursor/` (e.g., your model settings) -2. Automatically creates the workspace trust file so Cursor skips its trust dialog -3. Copies the final settings into the container image at `/home/agent/.cursor/cli-config.json` +1. Reads all files from `~/.kortex-cli/config/cursor/` (e.g., your settings) +2. If `--model` is specified, updates `cli-config.json` with the model configuration (takes precedence over any existing model in settings files) +3. Automatically creates the workspace trust file so Cursor skips its trust dialog +4. Copies the final settings into the container image at `/home/agent/.cursor/cli-config.json` Cursor finds this file on startup and uses the pre-configured model without prompting. **Notes:** +- **Model configuration**: Use `--model` flag during `init` to set the model (e.g., `--model my-model-id`). This takes precedence over any model defined in settings files - The settings are baked into the container image at `init` time, not mounted at runtime — changes to the files on the host require re-registering the workspace to take effect - Any file placed under `~/.kortex-cli/config/cursor/` is copied into the container home directory, preserving the directory structure (e.g., `~/.kortex-cli/config/cursor/.cursor/cli-config.json` becomes `/home/agent/.cursor/cli-config.json` inside the container) - To apply changes to the settings, remove and re-register the workspace: `kortex-cli remove ` then `kortex-cli init` again @@ -1872,6 +1873,7 @@ kortex-cli init [sources-directory] [flags] - `--runtime, -r ` - Runtime to use for the workspace (required if `KORTEX_CLI_DEFAULT_RUNTIME` is not set) - `--agent, -a ` - Agent to use for the workspace (required if `KORTEX_CLI_DEFAULT_AGENT` is not set) +- `--model, -m ` - Model ID to configure for the agent (optional, uses agent's default if not specified) - `--workspace-configuration ` - Directory for workspace configuration files (default: `/.kortex`) - `--name, -n ` - Human-readable name for the workspace (default: generated from sources directory) - `--project, -p ` - Custom project identifier to override auto-detection (default: auto-detected from git repository or source directory) @@ -1909,6 +1911,11 @@ kortex-cli init /path/to/myproject --runtime podman --agent claude --project "my kortex-cli init /path/to/myproject --runtime podman --agent claude --workspace-configuration /path/to/config ``` +**Register with a specific model:** +```bash +kortex-cli init /path/to/myproject --runtime podman --agent claude --model claude-sonnet-4-20250514 +``` + **Register and start immediately:** ```bash kortex-cli init /path/to/myproject --runtime podman --agent claude --start @@ -2073,6 +2080,7 @@ kortex-cli init /tmp/workspace --runtime podman --agent claude - **Runtime is required**: You must specify a runtime using either the `--runtime` flag or the `KORTEX_CLI_DEFAULT_RUNTIME` environment variable - **Agent is required**: You must specify an agent using either the `--agent` flag or the `KORTEX_CLI_DEFAULT_AGENT` environment variable +- **Model is optional**: Use `--model` to specify a model ID for the agent. The flag takes precedence over any model defined in the agent's default settings files (`~/.kortex-cli/config//`). If not provided, the agent uses its default model or the one configured in settings. All agents support model configuration: Claude (via `.claude/settings.json`), Goose (via `config.yaml`), and Cursor (via `.cursor/cli-config.json`) - **Project auto-detection**: The project identifier is automatically detected from git repository information or source directory path. Use `--project` flag to override with a custom identifier - **Auto-start**: Use the `--start` flag or set `KORTEX_CLI_INIT_AUTO_START=1` to automatically start the workspace after registration, combining `init` and `start` into a single operation - All directory paths are converted to absolute paths for consistency diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index a47972f..b2af159 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -28,4 +28,10 @@ type Agent interface { // onboarding flags set appropriately. // Returns the modified settings map, or an error if modification fails. SkipOnboarding(settings map[string][]byte, workspaceSourcesPath string) (map[string][]byte, error) + // SetModel configures the model ID in the agent settings. + // It takes the current agent settings map (path -> content) and the model ID, + // and returns the modified settings with the model configured. + // If the agent does not support model configuration, settings are returned unchanged. + // Returns the modified settings map, or an error if modification fails. + SetModel(settings map[string][]byte, modelID string) (map[string][]byte, error) } diff --git a/pkg/agent/claude.go b/pkg/agent/claude.go index bfe76c6..3f1af34 100644 --- a/pkg/agent/claude.go +++ b/pkg/agent/claude.go @@ -24,8 +24,10 @@ import ( ) const ( + // ClaudeJSONPath is the relative path to the claude.json file. + ClaudeJSONPath = ".claude.json" // ClaudeSettingsPath is the relative path to the Claude settings file. - ClaudeSettingsPath = ".claude.json" + ClaudeSettingsPath = ".claude/settings.json" ) // claudeAgent is the implementation of Agent for Claude Code. @@ -54,14 +56,14 @@ func (c *claudeAgent) SkipOnboarding(settings map[string][]byte, workspaceSource var existingContent []byte var exists bool - if existingContent, exists = settings[ClaudeSettingsPath]; !exists { + if existingContent, exists = settings[ClaudeJSONPath]; !exists { existingContent = []byte("{}") } // Parse into map to preserve all unknown fields var config map[string]interface{} if err := json.Unmarshal(existingContent, &config); err != nil { - return nil, fmt.Errorf("failed to parse existing %s: %w", ClaudeSettingsPath, err) + return nil, fmt.Errorf("failed to parse existing %s: %w", ClaudeJSONPath, err) } // Set hasCompletedOnboarding @@ -97,6 +99,36 @@ func (c *claudeAgent) SkipOnboarding(settings map[string][]byte, workspaceSource projects[workspaceSourcesPath] = projectSettings // Marshal final result + modifiedContent, err := json.MarshalIndent(config, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal modified %s: %w", ClaudeJSONPath, err) + } + + settings[ClaudeJSONPath] = modifiedContent + return settings, nil +} + +// SetModel configures the model ID in Claude settings. +// It sets the model field in .claude/settings.json. +// All other fields in the settings file are preserved. +func (c *claudeAgent) SetModel(settings map[string][]byte, modelID string) (map[string][]byte, error) { + if settings == nil { + settings = make(map[string][]byte) + } + + var existingContent []byte + var exists bool + if existingContent, exists = settings[ClaudeSettingsPath]; !exists { + existingContent = []byte("{}") + } + + var config map[string]interface{} + if err := json.Unmarshal(existingContent, &config); err != nil { + return nil, fmt.Errorf("failed to parse existing %s: %w", ClaudeSettingsPath, err) + } + + config["model"] = modelID + modifiedContent, err := json.MarshalIndent(config, "", " ") if err != nil { return nil, fmt.Errorf("failed to marshal modified %s: %w", ClaudeSettingsPath, err) diff --git a/pkg/agent/claude_test.go b/pkg/agent/claude_test.go index a0d61bb..ccd9194 100644 --- a/pkg/agent/claude_test.go +++ b/pkg/agent/claude_test.go @@ -44,9 +44,9 @@ func TestClaude_SkipOnboarding_NoExistingSettings(t *testing.T) { } // Verify .claude.json was created - claudeJSON, exists := result[ClaudeSettingsPath] + claudeJSON, exists := result[ClaudeJSONPath] if !exists { - t.Fatalf("Expected %s to be created", ClaudeSettingsPath) + t.Fatalf("Expected %s to be created", ClaudeJSONPath) } // Parse and verify content @@ -90,8 +90,8 @@ func TestClaude_SkipOnboarding_NilSettings(t *testing.T) { t.Fatal("Expected non-nil result map") } - if _, exists := result[ClaudeSettingsPath]; !exists { - t.Errorf("Expected %s to be created", ClaudeSettingsPath) + if _, exists := result[ClaudeJSONPath]; !exists { + t.Errorf("Expected %s to be created", ClaudeJSONPath) } } @@ -123,7 +123,7 @@ func TestClaude_SkipOnboarding_PreservesUnknownFields(t *testing.T) { } settings := map[string][]byte{ - ClaudeSettingsPath: existingJSON, + ClaudeJSONPath: existingJSON, } result, err := agent.SkipOnboarding(settings, "/workspace/sources") @@ -133,7 +133,7 @@ func TestClaude_SkipOnboarding_PreservesUnknownFields(t *testing.T) { // Parse result var config map[string]interface{} - if err := json.Unmarshal(result[ClaudeSettingsPath], &config); err != nil { + if err := json.Unmarshal(result[ClaudeJSONPath], &config); err != nil { t.Fatalf("Failed to parse result JSON: %v", err) } @@ -226,7 +226,7 @@ func TestClaude_SkipOnboarding_DifferentWorkspacePaths(t *testing.T) { } var config map[string]interface{} - if err := json.Unmarshal(result[ClaudeSettingsPath], &config); err != nil { + if err := json.Unmarshal(result[ClaudeJSONPath], &config); err != nil { t.Fatalf("Failed to parse result JSON: %v", err) } @@ -269,7 +269,7 @@ func TestClaude_SkipOnboarding_UpdatesExistingProject(t *testing.T) { } settings := map[string][]byte{ - ClaudeSettingsPath: existingJSON, + ClaudeJSONPath: existingJSON, } result, err := agent.SkipOnboarding(settings, "/workspace/sources") @@ -278,7 +278,7 @@ func TestClaude_SkipOnboarding_UpdatesExistingProject(t *testing.T) { } var config map[string]interface{} - if err := json.Unmarshal(result[ClaudeSettingsPath], &config); err != nil { + if err := json.Unmarshal(result[ClaudeJSONPath], &config); err != nil { t.Fatalf("Failed to parse result JSON: %v", err) } @@ -309,7 +309,7 @@ func TestClaude_SkipOnboarding_InvalidJSON(t *testing.T) { agent := NewClaude() settings := map[string][]byte{ - ClaudeSettingsPath: []byte("invalid json {{{"), + ClaudeJSONPath: []byte("invalid json {{{"), } _, err := agent.SkipOnboarding(settings, "/workspace/sources") @@ -330,7 +330,7 @@ func TestClaude_SkipOnboarding_EmptyWorkspacePath(t *testing.T) { } var config map[string]interface{} - if err := json.Unmarshal(result[ClaudeSettingsPath], &config); err != nil { + if err := json.Unmarshal(result[ClaudeJSONPath], &config); err != nil { t.Fatalf("Failed to parse result JSON: %v", err) } @@ -349,3 +349,147 @@ func TestClaude_SkipOnboarding_EmptyWorkspacePath(t *testing.T) { t.Errorf("hasTrustDialogAccepted = %v, want true", projectSettings["hasTrustDialogAccepted"]) } } + +func TestClaude_SetModel_NoExistingSettings(t *testing.T) { + t.Parallel() + + agent := NewClaude() + settings := make(map[string][]byte) + + result, err := agent.SetModel(settings, "model-from-flag") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + claudeJSON, exists := result[ClaudeSettingsPath] + if !exists { + t.Fatalf("Expected %s to be created", ClaudeSettingsPath) + } + + var config map[string]interface{} + if err := json.Unmarshal(claudeJSON, &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + if model, ok := config["model"].(string); !ok || model != "model-from-flag" { + t.Errorf("model = %v, want %q", config["model"], "model-from-flag") + } +} + +func TestClaude_SetModel_NilSettings(t *testing.T) { + t.Parallel() + + agent := NewClaude() + + result, err := agent.SetModel(nil, "model-from-flag") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result map") + } + + if _, exists := result[ClaudeSettingsPath]; !exists { + t.Errorf("Expected %s to be created", ClaudeSettingsPath) + } +} + +func TestClaude_SetModel_PreservesExistingFields(t *testing.T) { + t.Parallel() + + agent := NewClaude() + + existingSettings := map[string]interface{}{ + "customField": "custom value", + "anotherField": 123, + } + + existingJSON, err := json.Marshal(existingSettings) + if err != nil { + t.Fatalf("Failed to marshal existing settings: %v", err) + } + + settings := map[string][]byte{ + ClaudeSettingsPath: existingJSON, + } + + result, err := agent.SetModel(settings, "model-from-flag") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(result[ClaudeSettingsPath], &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + // Verify model was set + if model, ok := config["model"].(string); !ok || model != "model-from-flag" { + t.Errorf("model = %v, want %q", config["model"], "model-from-flag") + } + + // Verify existing fields are preserved + if customField, ok := config["customField"].(string); !ok || customField != "custom value" { + t.Errorf("customField = %v, want %q", config["customField"], "custom value") + } + + if anotherField, ok := config["anotherField"].(float64); !ok || anotherField != 123 { + t.Errorf("anotherField = %v, want 123", config["anotherField"]) + } +} + +func TestClaude_SetModel_InvalidJSON(t *testing.T) { + t.Parallel() + + agent := NewClaude() + + settings := map[string][]byte{ + ClaudeSettingsPath: []byte("invalid json {{{"), + } + + _, err := agent.SetModel(settings, "model-from-flag") + if err == nil { + t.Error("Expected error for invalid JSON, got nil") + } +} + +func TestClaude_SetModel_OverwritesExistingModel(t *testing.T) { + t.Parallel() + + agent := NewClaude() + + existingSettings := map[string]interface{}{ + "model": "original-model", + "otherField": true, + } + + existingJSON, err := json.Marshal(existingSettings) + if err != nil { + t.Fatalf("Failed to marshal existing settings: %v", err) + } + + settings := map[string][]byte{ + ClaudeSettingsPath: existingJSON, + } + + result, err := agent.SetModel(settings, "model-from-flag") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(result[ClaudeSettingsPath], &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + // Verify model was overwritten + if model, ok := config["model"].(string); !ok || model != "model-from-flag" { + t.Errorf("model = %v, want %q (should overwrite existing)", config["model"], "model-from-flag") + } + + // Verify other fields are preserved + if otherField, ok := config["otherField"].(bool); !ok || !otherField { + t.Errorf("otherField = %v, want true", config["otherField"]) + } +} diff --git a/pkg/agent/cursor.go b/pkg/agent/cursor.go index 6c23f24..0ada8b3 100644 --- a/pkg/agent/cursor.go +++ b/pkg/agent/cursor.go @@ -25,6 +25,9 @@ import ( "time" ) +// CursorCLIConfigPath is the path to Cursor's CLI configuration file. +const CursorCLIConfigPath = ".cursor/cli-config.json" + // cursorAgent is the implementation of Agent for Cursor. type cursorAgent struct{} @@ -66,6 +69,43 @@ func (c *cursorAgent) SkipOnboarding(settings map[string][]byte, workspaceSource return settings, nil } +// SetModel configures the model ID in Cursor settings. +// It sets the model object in cli-config.json with the specified model ID. +// All other fields in the settings file are preserved. +func (c *cursorAgent) SetModel(settings map[string][]byte, modelID string) (map[string][]byte, error) { + if settings == nil { + settings = make(map[string][]byte) + } + + var existingContent []byte + var exists bool + if existingContent, exists = settings[CursorCLIConfigPath]; !exists { + existingContent = []byte("{}") + } + + var config map[string]interface{} + if err := json.Unmarshal(existingContent, &config); err != nil { + return nil, fmt.Errorf("failed to parse existing %s: %w", CursorCLIConfigPath, err) + } + + config["model"] = map[string]interface{}{ + "modelId": modelID, + "displayModelId": modelID, + "displayName": modelID, + "displayNameShort": modelID, + "maxMode": false, + } + config["hasChangedDefaultModel"] = true + + modifiedContent, err := json.MarshalIndent(config, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal modified %s: %w", CursorCLIConfigPath, err) + } + + settings[CursorCLIConfigPath] = modifiedContent + return settings, nil +} + // workspacePathToCursorDir converts a workspace path to the directory name // used by Cursor in its projects directory. Cursor replaces '/' with '-' // and the resulting string has any leading '-' stripped. diff --git a/pkg/agent/cursor_test.go b/pkg/agent/cursor_test.go index 233fccd..99afac1 100644 --- a/pkg/agent/cursor_test.go +++ b/pkg/agent/cursor_test.go @@ -219,3 +219,195 @@ func TestWorkspacePathToCursorDir(t *testing.T) { }) } } + +func TestCursor_SetModel_NoExistingSettings(t *testing.T) { + t.Parallel() + + agent := NewCursor() + settings := make(map[string][]byte) + + result, err := agent.SetModel(settings, "model-from-flag") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result map") + } + + cliConfig, exists := result[CursorCLIConfigPath] + if !exists { + t.Fatalf("Expected %s to be created", CursorCLIConfigPath) + } + + var config map[string]interface{} + if err := json.Unmarshal(cliConfig, &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + modelObj, ok := config["model"].(map[string]interface{}) + if !ok { + t.Fatalf("model is not an object: %v", config["model"]) + } + + if modelObj["modelId"] != "model-from-flag" { + t.Errorf("modelId = %v, want %q", modelObj["modelId"], "model-from-flag") + } + if modelObj["displayModelId"] != "model-from-flag" { + t.Errorf("displayModelId = %v, want %q", modelObj["displayModelId"], "model-from-flag") + } + if modelObj["displayName"] != "model-from-flag" { + t.Errorf("displayName = %v, want %q", modelObj["displayName"], "model-from-flag") + } + if modelObj["displayNameShort"] != "model-from-flag" { + t.Errorf("displayNameShort = %v, want %q", modelObj["displayNameShort"], "model-from-flag") + } + if modelObj["maxMode"] != false { + t.Errorf("maxMode = %v, want false", modelObj["maxMode"]) + } + + if config["hasChangedDefaultModel"] != true { + t.Errorf("hasChangedDefaultModel = %v, want true", config["hasChangedDefaultModel"]) + } +} + +func TestCursor_SetModel_NilSettings(t *testing.T) { + t.Parallel() + + agent := NewCursor() + + result, err := agent.SetModel(nil, "some-model-id") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result map") + } + + if _, exists := result[CursorCLIConfigPath]; !exists { + t.Errorf("Expected %s to be created", CursorCLIConfigPath) + } +} + +func TestCursor_SetModel_PreservesExistingSettings(t *testing.T) { + t.Parallel() + + agent := NewCursor() + + existingSettings := map[string][]byte{ + "some/other/file": []byte("existing content"), + } + + result, err := agent.SetModel(existingSettings, "some-model-id") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + if string(result["some/other/file"]) != "existing content" { + t.Errorf("Existing settings were not preserved") + } + + if _, exists := result[CursorCLIConfigPath]; !exists { + t.Errorf("Expected %s to be created", CursorCLIConfigPath) + } +} + +func TestCursor_SetModel_PreservesExistingCLIConfig(t *testing.T) { + t.Parallel() + + agent := NewCursor() + + existingConfig := map[string]interface{}{ + "someOtherField": "some-value", + "anotherField": 123, + } + existingJSON, _ := json.Marshal(existingConfig) + + settings := map[string][]byte{ + CursorCLIConfigPath: existingJSON, + } + + result, err := agent.SetModel(settings, "new-model-id") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(result[CursorCLIConfigPath], &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + if config["someOtherField"] != "some-value" { + t.Errorf("someOtherField = %v, want %q", config["someOtherField"], "some-value") + } + if config["anotherField"] != float64(123) { + t.Errorf("anotherField = %v, want 123", config["anotherField"]) + } + + modelObj, ok := config["model"].(map[string]interface{}) + if !ok { + t.Fatalf("model is not an object: %v", config["model"]) + } + if modelObj["modelId"] != "new-model-id" { + t.Errorf("modelId = %v, want %q", modelObj["modelId"], "new-model-id") + } +} + +func TestCursor_SetModel_OverwritesExistingModel(t *testing.T) { + t.Parallel() + + agent := NewCursor() + + existingConfig := map[string]interface{}{ + "model": map[string]interface{}{ + "modelId": "old-model", + "displayModelId": "old-model", + "displayName": "old-model", + "displayNameShort": "old-model", + "maxMode": true, + }, + } + existingJSON, _ := json.Marshal(existingConfig) + + settings := map[string][]byte{ + CursorCLIConfigPath: existingJSON, + } + + result, err := agent.SetModel(settings, "new-model-id") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(result[CursorCLIConfigPath], &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + modelObj, ok := config["model"].(map[string]interface{}) + if !ok { + t.Fatalf("model is not an object: %v", config["model"]) + } + + if modelObj["modelId"] != "new-model-id" { + t.Errorf("modelId = %v, want %q", modelObj["modelId"], "new-model-id") + } + if modelObj["maxMode"] != false { + t.Errorf("maxMode = %v, want false (should be overwritten)", modelObj["maxMode"]) + } +} + +func TestCursor_SetModel_InvalidJSON(t *testing.T) { + t.Parallel() + + agent := NewCursor() + + settings := map[string][]byte{ + CursorCLIConfigPath: []byte("invalid json"), + } + + _, err := agent.SetModel(settings, "some-model-id") + if err == nil { + t.Fatal("Expected error for invalid JSON") + } +} diff --git a/pkg/agent/goose.go b/pkg/agent/goose.go index 6d0b992..24f67b1 100644 --- a/pkg/agent/goose.go +++ b/pkg/agent/goose.go @@ -29,6 +29,7 @@ const ( GooseConfigPath = ".config/goose/config.yaml" gooseTelemetryKey = "GOOSE_TELEMETRY_ENABLED" + gooseModelKey = "GOOSE_MODEL" ) // gooseAgent is the implementation of Agent for Goose. @@ -79,3 +80,32 @@ func (g *gooseAgent) SkipOnboarding(settings map[string][]byte, _ string) (map[s settings[GooseConfigPath] = modifiedContent return settings, nil } + +// SetModel configures the model ID in Goose settings. +// It sets the GOOSE_MODEL key in the config file. +// All other fields in the settings file are preserved. +func (g *gooseAgent) SetModel(settings map[string][]byte, modelID string) (map[string][]byte, error) { + if settings == nil { + settings = make(map[string][]byte) + } + + var config map[string]interface{} + if content, exists := settings[GooseConfigPath]; exists { + if err := yaml.Unmarshal(content, &config); err != nil { + return nil, fmt.Errorf("failed to parse existing %s: %w", GooseConfigPath, err) + } + } + if config == nil { + config = make(map[string]interface{}) + } + + config[gooseModelKey] = modelID + + modifiedContent, err := yaml.Marshal(config) + if err != nil { + return nil, fmt.Errorf("failed to marshal modified %s: %w", GooseConfigPath, err) + } + + settings[GooseConfigPath] = modifiedContent + return settings, nil +} diff --git a/pkg/agent/goose_test.go b/pkg/agent/goose_test.go index de02bb6..486fb78 100644 --- a/pkg/agent/goose_test.go +++ b/pkg/agent/goose_test.go @@ -183,3 +183,129 @@ func TestGoose_SkipOnboarding_InvalidYAML(t *testing.T) { t.Error("Expected error for invalid YAML, got nil") } } + +func TestGoose_SetModel_NoExistingSettings(t *testing.T) { + t.Parallel() + + agent := NewGoose() + settings := make(map[string][]byte) + + result, err := agent.SetModel(settings, "model-from-flag") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + configYAML, exists := result[GooseConfigPath] + if !exists { + t.Fatalf("Expected %s to be created", GooseConfigPath) + } + + var config map[string]interface{} + if err := yaml.Unmarshal(configYAML, &config); err != nil { + t.Fatalf("Failed to parse result YAML: %v", err) + } + + if model, ok := config[gooseModelKey].(string); !ok || model != "model-from-flag" { + t.Errorf("%s = %v, want %q", gooseModelKey, config[gooseModelKey], "model-from-flag") + } +} + +func TestGoose_SetModel_NilSettings(t *testing.T) { + t.Parallel() + + agent := NewGoose() + + result, err := agent.SetModel(nil, "model-from-flag") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result map") + } + + if _, exists := result[GooseConfigPath]; !exists { + t.Errorf("Expected %s to be created", GooseConfigPath) + } +} + +func TestGoose_SetModel_PreservesExistingFields(t *testing.T) { + t.Parallel() + + agent := NewGoose() + + existingContent := []byte("GOOSE_TELEMETRY_ENABLED: false\nGOOSE_PROVIDER: \"anthropic\"\n") + settings := map[string][]byte{ + GooseConfigPath: existingContent, + } + + result, err := agent.SetModel(settings, "model-from-flag") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := yaml.Unmarshal(result[GooseConfigPath], &config); err != nil { + t.Fatalf("Failed to parse result YAML: %v", err) + } + + // Verify model was set + if model, ok := config[gooseModelKey].(string); !ok || model != "model-from-flag" { + t.Errorf("%s = %v, want %q", gooseModelKey, config[gooseModelKey], "model-from-flag") + } + + // Verify existing fields are preserved + if val, ok := config[gooseTelemetryKey]; !ok || val != false { + t.Errorf("%s = %v, want false", gooseTelemetryKey, val) + } + + if provider, ok := config["GOOSE_PROVIDER"].(string); !ok || provider != "anthropic" { + t.Errorf("GOOSE_PROVIDER = %v, want %q", config["GOOSE_PROVIDER"], "anthropic") + } +} + +func TestGoose_SetModel_InvalidYAML(t *testing.T) { + t.Parallel() + + agent := NewGoose() + + settings := map[string][]byte{ + GooseConfigPath: []byte("invalid: yaml: :::"), + } + + _, err := agent.SetModel(settings, "model-from-flag") + if err == nil { + t.Error("Expected error for invalid YAML, got nil") + } +} + +func TestGoose_SetModel_OverwritesExistingModel(t *testing.T) { + t.Parallel() + + agent := NewGoose() + + existingContent := []byte("GOOSE_MODEL: \"original-model\"\nGOOSE_TELEMETRY_ENABLED: false\n") + settings := map[string][]byte{ + GooseConfigPath: existingContent, + } + + result, err := agent.SetModel(settings, "model-from-flag") + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := yaml.Unmarshal(result[GooseConfigPath], &config); err != nil { + t.Fatalf("Failed to parse result YAML: %v", err) + } + + // Verify model was overwritten + if model, ok := config[gooseModelKey].(string); !ok || model != "model-from-flag" { + t.Errorf("%s = %v, want %q (should overwrite existing)", gooseModelKey, config[gooseModelKey], "model-from-flag") + } + + // Verify other fields are preserved + if val, ok := config[gooseTelemetryKey]; !ok || val != false { + t.Errorf("%s = %v, want false", gooseTelemetryKey, val) + } +} diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go index c237baa..597114e 100644 --- a/pkg/agent/registry_test.go +++ b/pkg/agent/registry_test.go @@ -32,7 +32,11 @@ func (f *fakeAgent) Name() string { return f.name } -func (f *fakeAgent) SkipOnboarding(settings map[string][]byte, workspaceSourcesPath string) (map[string][]byte, error) { +func (f *fakeAgent) SkipOnboarding(settings map[string][]byte, _ string) (map[string][]byte, error) { + return settings, nil +} + +func (f *fakeAgent) SetModel(settings map[string][]byte, _ string) (map[string][]byte, error) { return settings, nil } diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 3b281df..050c9e7 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -43,6 +43,7 @@ type initCmd struct { runtime string project string agent string + model string absSourcesDir string absConfigDir string manager instances.Manager @@ -229,6 +230,7 @@ func (i *initCmd) run(cmd *cobra.Command, args []string) error { WorkspaceConfig: i.workspaceConfig, Project: i.project, Agent: i.agent, + Model: i.model, }) if err != nil { return outputErrorIfJSON(cmd, i.output, err) @@ -310,6 +312,9 @@ kortex-cli init --runtime podman --agent claude --name my-project # Register with custom project identifier kortex-cli init --runtime podman --agent goose --project my-custom-project +# Register with a specific model +kortex-cli init --runtime podman --agent claude --model claude-sonnet-4-20250514 + # Register and start workspace kortex-cli init --runtime podman --agent claude --start @@ -339,6 +344,9 @@ kortex-cli init --runtime podman --agent claude --show-logs`, // Add agent flag cmd.Flags().StringVarP(&c.agent, "agent", "a", "", "Agent name for loading agent-specific configuration (required if KORTEX_CLI_DEFAULT_AGENT is not set)") + // Add model flag + cmd.Flags().StringVarP(&c.model, "model", "m", "", "Model ID to configure for the agent (optional)") + // Add start flag cmd.Flags().BoolVar(&c.start, "start", false, "Start the workspace after registration (can also be set via KORTEX_CLI_INIT_AUTO_START environment variable)") diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go index bc2bfe2..9fce1ed 100644 --- a/pkg/cmd/init_test.go +++ b/pkg/cmd/init_test.go @@ -1833,6 +1833,72 @@ func TestInitCmd_E2E(t *testing.T) { } }) + t.Run("registers workspace with model flag", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourcesDir := t.TempDir() + modelID := "model-from-flag" + + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"--storage", storageDir, "init", "--runtime", "fake", "--agent", "test-agent", sourcesDir, "--model", modelID}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() failed: %v", err) + } + + // Verify instance was created + manager, err := instances.NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + instancesList, err := manager.List() + if err != nil { + t.Fatalf("Failed to list instances: %v", err) + } + + if len(instancesList) != 1 { + t.Fatalf("Expected 1 instance, got %d", len(instancesList)) + } + + // Note: The model is passed to the agent's SetModel method and written to agent settings. + // The fake runtime doesn't persist agent settings, so we can only verify the command succeeded. + // The agent's SetModel functionality is tested in pkg/agent/*_test.go + }) + + t.Run("registers workspace with model flag using short form", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourcesDir := t.TempDir() + modelID := "model-from-short-flag" + + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"--storage", storageDir, "init", "--runtime", "fake", "--agent", "test-agent", sourcesDir, "-m", modelID}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() failed: %v", err) + } + + // Verify instance was created + manager, err := instances.NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + instancesList, err := manager.List() + if err != nil { + t.Fatalf("Failed to list instances: %v", err) + } + + if len(instancesList) != 1 { + t.Fatalf("Expected 1 instance, got %d", len(instancesList)) + } + }) + t.Run("registers and starts workspace with --start flag", func(t *testing.T) { t.Parallel() @@ -2280,7 +2346,7 @@ func TestInitCmd_Examples(t *testing.T) { } // Verify we have the expected number of examples - expectedCount := 7 + expectedCount := 8 if len(commands) != expectedCount { t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) } diff --git a/pkg/instances/manager.go b/pkg/instances/manager.go index ddaf9fb..6976184 100644 --- a/pkg/instances/manager.go +++ b/pkg/instances/manager.go @@ -56,6 +56,8 @@ type AddOptions struct { Project string // Agent is an optional agent name for loading agent-specific configuration Agent string + // Model is an optional model ID to configure for the agent + Model string } // Manager handles instance storage and operations @@ -231,6 +233,14 @@ func (m *manager) Add(ctx context.Context, opts AddOptions) (Instance, error) { if err != nil { return nil, fmt.Errorf("failed to apply agent onboarding settings: %w", err) } + + // Set model if specified + if opts.Model != "" { + agentSettings, err = agentImpl.SetModel(agentSettings, opts.Model) + if err != nil { + return nil, fmt.Errorf("failed to apply agent model settings: %w", err) + } + } } // If agent not found in registry, use settings as-is (not all agents may be implemented) } diff --git a/pkg/instances/manager_test.go b/pkg/instances/manager_test.go index 0d8ad53..f8208e9 100644 --- a/pkg/instances/manager_test.go +++ b/pkg/instances/manager_test.go @@ -193,6 +193,98 @@ func (g *fakeSequentialGenerator) Generate() string { return id } +// trackingAgent is a test double for the Agent interface that tracks method calls +type trackingAgent struct { + name string + skipOnboardingCalled bool + skipOnboardingSettings map[string][]byte + skipOnboardingPath string + setModelCalled bool + setModelSettings map[string][]byte + setModelID string + mu sync.Mutex +} + +// Compile-time check to ensure trackingAgent implements agent.Agent interface +var _ agent.Agent = (*trackingAgent)(nil) + +func newTrackingAgent(name string) *trackingAgent { + return &trackingAgent{name: name} +} + +func (t *trackingAgent) Name() string { + return t.name +} + +func (t *trackingAgent) SkipOnboarding(settings map[string][]byte, workspaceSourcesPath string) (map[string][]byte, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.skipOnboardingCalled = true + t.skipOnboardingSettings = settings + t.skipOnboardingPath = workspaceSourcesPath + if settings == nil { + settings = make(map[string][]byte) + } + return settings, nil +} + +func (t *trackingAgent) SetModel(settings map[string][]byte, modelID string) (map[string][]byte, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.setModelCalled = true + t.setModelSettings = settings + t.setModelID = modelID + if settings == nil { + settings = make(map[string][]byte) + } + return settings, nil +} + +func (t *trackingAgent) WasSetModelCalled() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.setModelCalled +} + +func (t *trackingAgent) GetSetModelID() string { + t.mu.Lock() + defer t.mu.Unlock() + return t.setModelID +} + +func (t *trackingAgent) WasSkipOnboardingCalled() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.skipOnboardingCalled +} + +// erroringSetModelAgent is a test double that returns an error from SetModel +type erroringSetModelAgent struct { + name string +} + +// Compile-time check to ensure erroringSetModelAgent implements agent.Agent interface +var _ agent.Agent = (*erroringSetModelAgent)(nil) + +func newErroringSetModelAgent(name string) *erroringSetModelAgent { + return &erroringSetModelAgent{name: name} +} + +func (e *erroringSetModelAgent) Name() string { + return e.name +} + +func (e *erroringSetModelAgent) SkipOnboarding(settings map[string][]byte, _ string) (map[string][]byte, error) { + if settings == nil { + settings = make(map[string][]byte) + } + return settings, nil +} + +func (e *erroringSetModelAgent) SetModel(_ map[string][]byte, _ string) (map[string][]byte, error) { + return nil, errors.New("simulated SetModel error") +} + // newTestRegistry creates a runtime registry with a fake runtime for testing func newTestRegistry(storageDir string) runtime.Registry { runtimesDir := filepath.Join(storageDir, RuntimesSubdirectory) @@ -2840,6 +2932,264 @@ func TestManager_Add_AppliesAgentOnboarding(t *testing.T) { }) } +func TestManager_Add_AppliesAgentModel(t *testing.T) { + t.Parallel() + + t.Run("calls agent SetModel when model is specified", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + manager, err := NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + // Register fake runtime + if err := manager.RegisterRuntime(fake.New()); err != nil { + t.Fatalf("Failed to register fake runtime: %v", err) + } + + // Register tracking agent to verify SetModel is called + trackingAgent := newTrackingAgent("test-agent") + if err := manager.RegisterAgent("test-agent", trackingAgent); err != nil { + t.Fatalf("Failed to register tracking agent: %v", err) + } + + // Create test instance + instanceTmpDir := t.TempDir() + sourceDir := filepath.Join(instanceTmpDir, "source") + configDir := filepath.Join(instanceTmpDir, "config") + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatalf("Failed to create source directory: %v", err) + } + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + inst, err := NewInstance(NewInstanceParams{ + SourceDir: sourceDir, + ConfigDir: configDir, + }) + if err != nil { + t.Fatalf("Failed to create instance: %v", err) + } + + // Add instance with agent and model + added, err := manager.Add(context.Background(), AddOptions{ + Instance: inst, + RuntimeType: "fake", + Agent: "test-agent", + Model: "model-from-flag", + }) + if err != nil { + t.Fatalf("Add() error = %v", err) + } + + // Verify instance was created + if added == nil { + t.Fatal("Add() returned nil instance") + } + + // Verify SetModel was called + if !trackingAgent.WasSetModelCalled() { + t.Error("SetModel() was not called") + } + + // Verify SetModel was called with the correct model ID + if trackingAgent.GetSetModelID() != "model-from-flag" { + t.Errorf("SetModel() called with model ID %q, want %q", trackingAgent.GetSetModelID(), "model-from-flag") + } + + // Verify SkipOnboarding was also called + if !trackingAgent.WasSkipOnboardingCalled() { + t.Error("SkipOnboarding() was not called") + } + }) + + t.Run("does not call SetModel when model is empty", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + manager, err := NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + // Register fake runtime + if err := manager.RegisterRuntime(fake.New()); err != nil { + t.Fatalf("Failed to register fake runtime: %v", err) + } + + // Register tracking agent to verify SetModel is not called + trackingAgent := newTrackingAgent("test-agent") + if err := manager.RegisterAgent("test-agent", trackingAgent); err != nil { + t.Fatalf("Failed to register tracking agent: %v", err) + } + + // Create test instance + instanceTmpDir := t.TempDir() + sourceDir := filepath.Join(instanceTmpDir, "source") + configDir := filepath.Join(instanceTmpDir, "config") + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatalf("Failed to create source directory: %v", err) + } + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + inst, err := NewInstance(NewInstanceParams{ + SourceDir: sourceDir, + ConfigDir: configDir, + }) + if err != nil { + t.Fatalf("Failed to create instance: %v", err) + } + + // Add instance with agent but no model + added, err := manager.Add(context.Background(), AddOptions{ + Instance: inst, + RuntimeType: "fake", + Agent: "test-agent", + Model: "", // Empty model + }) + if err != nil { + t.Fatalf("Add() error = %v", err) + } + + // Verify instance was created + if added == nil { + t.Fatal("Add() returned nil instance") + } + + // Verify SetModel was NOT called + if trackingAgent.WasSetModelCalled() { + t.Error("SetModel() should not be called when model is empty") + } + + // Verify SkipOnboarding was still called + if !trackingAgent.WasSkipOnboardingCalled() { + t.Error("SkipOnboarding() was not called") + } + }) + + t.Run("does not call SetModel when agent is not registered", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + manager, err := NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + // Register fake runtime + if err := manager.RegisterRuntime(fake.New()); err != nil { + t.Fatalf("Failed to register fake runtime: %v", err) + } + + // Note: Not registering any agent + + // Create test instance + instanceTmpDir := t.TempDir() + sourceDir := filepath.Join(instanceTmpDir, "source") + configDir := filepath.Join(instanceTmpDir, "config") + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatalf("Failed to create source directory: %v", err) + } + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + inst, err := NewInstance(NewInstanceParams{ + SourceDir: sourceDir, + ConfigDir: configDir, + }) + if err != nil { + t.Fatalf("Failed to create instance: %v", err) + } + + // Add instance with unknown agent and model - should succeed (agent settings used as-is) + added, err := manager.Add(context.Background(), AddOptions{ + Instance: inst, + RuntimeType: "fake", + Agent: "unknown-agent", + Model: "some-model", + }) + if err != nil { + t.Fatalf("Add() error = %v", err) + } + + // Verify instance was created + if added == nil { + t.Fatal("Add() returned nil instance") + } + + if added.GetID() == "" { + t.Error("Add() returned instance with empty ID") + } + }) + + t.Run("propagates SetModel errors", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + manager, err := NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + // Register fake runtime + if err := manager.RegisterRuntime(fake.New()); err != nil { + t.Fatalf("Failed to register fake runtime: %v", err) + } + + // Register agent that errors on SetModel but not SkipOnboarding + erroringAgent := newErroringSetModelAgent("erroring-agent") + if err := manager.RegisterAgent("erroring-agent", erroringAgent); err != nil { + t.Fatalf("Failed to register erroring agent: %v", err) + } + + // Create test instance + instanceTmpDir := t.TempDir() + sourceDir := filepath.Join(instanceTmpDir, "source") + configDir := filepath.Join(instanceTmpDir, "config") + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatalf("Failed to create source directory: %v", err) + } + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + inst, err := NewInstance(NewInstanceParams{ + SourceDir: sourceDir, + ConfigDir: configDir, + }) + if err != nil { + t.Fatalf("Failed to create instance: %v", err) + } + + // Add instance with erroring agent and model - should fail due to SetModel error + _, err = manager.Add(context.Background(), AddOptions{ + Instance: inst, + RuntimeType: "fake", + Agent: "erroring-agent", + Model: "some-model", + }) + if err == nil { + t.Fatal("Add() should return error when SetModel fails") + } + + // Verify error message mentions agent model settings + if !strings.Contains(err.Error(), "agent model settings") { + t.Errorf("Add() error = %q, want error containing 'agent model settings'", err.Error()) + } + + // Verify the underlying error is propagated + if !strings.Contains(err.Error(), "simulated SetModel error") { + t.Errorf("Add() error = %q, want error containing 'simulated SetModel error'", err.Error()) + } + }) +} + func TestManager_Start_RejectsInvalidState(t *testing.T) { t.Parallel() diff --git a/skills/working-with-config-system/SKILL.md b/skills/working-with-config-system/SKILL.md index bbe14c3..6b4048e 100644 --- a/skills/working-with-config-system/SKILL.md +++ b/skills/working-with-config-system/SKILL.md @@ -55,7 +55,20 @@ For supported agents (e.g., Claude), kortex-cli automatically modifies settings This means you can optionally customize agent preferences (theme, etc.) in the settings files, and kortex-cli will automatically add the onboarding flags. -**Implementation:** `manager.readAgentSettings(storageDir, agentName)` in `pkg/instances/manager.go` walks this directory and returns a `map[string][]byte` (relative forward-slash path → content). If the agent is registered in the agent registry, the manager calls the agent's `SkipOnboarding()` method to modify the settings. The final map is passed to the runtime via `runtime.CreateParams.AgentSettings`. The Podman runtime writes the files into the build context and adds a `COPY --chown=agent:agent agent-settings/. /home/agent/` instruction to the Containerfile. +**Model Configuration:** + +When the `--model` flag is provided during `init`, kortex-cli calls the agent's `SetModel()` method to configure the model in the settings files: + +1. After `SkipOnboarding()` is called, if a model ID is specified, `SetModel()` is called +2. The agent sets the appropriate model field in its settings: + - Claude: `model` field in `.claude/settings.json` + - Goose: `GOOSE_MODEL` field in `.config/goose/config.yaml` + - Cursor: `model` object in `.cursor/cli-config.json` +3. The `--model` flag takes precedence over any model already defined in the settings files + +This allows users to quickly specify a model without manually editing settings files. + +**Implementation:** `manager.readAgentSettings(storageDir, agentName)` in `pkg/instances/manager.go` walks this directory and returns a `map[string][]byte` (relative forward-slash path → content). If the agent is registered in the agent registry, the manager calls the agent's `SkipOnboarding()` method to modify the settings. If a model ID is provided, the manager then calls the agent's `SetModel()` method to configure the model in the appropriate settings file. The final map is passed to the runtime via `runtime.CreateParams.AgentSettings`. The Podman runtime writes the files into the build context and adds a `COPY --chown=agent:agent agent-settings/. /home/agent/` instruction to the Containerfile. ## Key Components @@ -254,6 +267,7 @@ addedInstance, err := manager.Add(ctx, instances.AddOptions{ WorkspaceConfig: workspaceConfig, // From .kortex/workspace.json or --workspace-configuration directory Project: "custom-project", // Optional override Agent: "claude", // Optional agent name + Model: "claude-sonnet-4", // Optional model ID (takes precedence over settings) }) ``` @@ -262,7 +276,9 @@ The Manager's `Add()` method: 2. Loads project config (global `""` + project-specific merged) 3. Loads agent config (if agent name provided) 4. Merges configs: workspace → global → project → agent -5. Passes merged config to runtime for injection into workspace +5. Calls agent's `SkipOnboarding()` if agent is registered +6. Calls agent's `SetModel()` if model is specified (takes precedence over settings) +7. Passes merged config to runtime for injection into workspace ## Merging Behavior diff --git a/skills/working-with-instances-manager/SKILL.md b/skills/working-with-instances-manager/SKILL.md index b01affe..1f4a56c 100644 --- a/skills/working-with-instances-manager/SKILL.md +++ b/skills/working-with-instances-manager/SKILL.md @@ -52,6 +52,7 @@ addedInstance, err := manager.Add(ctx, instances.AddOptions{ WorkspaceConfig: workspaceConfig, // From .kortex/workspace.json Project: "custom-project", // Optional: overrides auto-detection Agent: "claude", // Optional: agent name for agent-specific config + Model: "claude-sonnet-4", // Optional: model ID for agent (takes precedence over settings) }) if err != nil { return fmt.Errorf("failed to add instance: %w", err) @@ -65,7 +66,8 @@ The `Add()` method: 4. Merges configs: workspace → global → project → agent 5. Reads agent settings files from `/config//` into `map[string][]byte` 6. Calls agent's `SkipOnboarding()` method if agent is registered (e.g., Claude agent automatically sets onboarding flags) -7. Passes merged config and modified agent settings to runtime for injection into workspace +7. Calls agent's `SetModel()` method if model is specified (takes precedence over model in settings files) +8. Passes merged config and modified agent settings to runtime for injection into workspace ### List - Get All Instances From 07d8ed1891400e424974c45bb8341c859c6e32ef Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sat, 4 Apr 2026 19:04:28 +0200 Subject: [PATCH 2/2] fix: review Signed-off-by: Philippe Martin Co-Authored-By: Claude Code (Claude Sonnet 4.5) --- pkg/agent/cursor.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/agent/cursor.go b/pkg/agent/cursor.go index 0ada8b3..80ccb5d 100644 --- a/pkg/agent/cursor.go +++ b/pkg/agent/cursor.go @@ -88,6 +88,10 @@ func (c *cursorAgent) SetModel(settings map[string][]byte, modelID string) (map[ return nil, fmt.Errorf("failed to parse existing %s: %w", CursorCLIConfigPath, err) } + if config == nil { + config = make(map[string]interface{}) + } + config["model"] = map[string]interface{}{ "modelId": modelID, "displayModelId": modelID,