diff --git a/README.md b/README.md index 4e0dc95..6660348 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,147 @@ Claude Code finds this file on startup and skips onboarding. - This approach keeps your workspace self-contained — other developers using the same project are not affected, and your local `~/.claude` directory is not exposed inside the container - To apply changes to the settings, remove and re-register the workspace: `kortex-cli remove ` then `kortex-cli init` again +### Using Goose Agent with a Model from Vertex AI + +This scenario demonstrates how to configure the Goose agent in a kortex-cli workspace using Vertex AI as the backend, covering credential injection, sharing your local gcloud configuration, and pre-configuring the default model. + +#### Authenticating with Vertex AI + +Goose can use Google Cloud Vertex AI as its backend. Authentication relies on Application Default Credentials (ADC) provided by the `gcloud` CLI. Mount your local `~/.config/gcloud` directory to make your host credentials available inside the workspace, and set the `GCP_PROJECT_ID`, `GCP_LOCATION`, and `GOOSE_PROVIDER` environment variables to tell Goose which project and region to use. + +Create or edit `~/.kortex-cli/config/agents.json`: + +```json +{ + "goose": { + "environment": [ + { + "name": "GOOSE_PROVIDER", + "value": "gcp_vertex_ai" + }, + { + "name": "GCP_PROJECT_ID", + "value": "my-gcp-project" + }, + { + "name": "GCP_LOCATION", + "value": "my-region" + } + ], + "mounts": [ + {"host": "$HOME/.config/gcloud", "target": "$HOME/.config/gcloud", "ro": true} + ] + } +} +``` + +The `~/.config/gcloud` directory contains your Application Default Credentials and active account configuration. It is mounted read-only so that credentials are available inside the workspace while the host configuration remains unmodified. + +Then register and start the workspace: + +```bash +# Register a workspace with the Podman runtime and Goose agent +kortex-cli init /path/to/project --runtime podman --agent goose + +# Start the workspace +kortex-cli start my-project + +# Connect — Goose starts with Vertex AI configured +kortex-cli terminal my-project +``` + +#### Sharing Local Goose Settings + +To reuse your host Goose settings (model preferences, provider configuration, etc.) inside the workspace, mount the `~/.config/goose` directory. + +Edit `~/.kortex-cli/config/agents.json` to add the mount alongside the Vertex AI configuration: + +```json +{ + "goose": { + "environment": [ + { + "name": "GOOSE_PROVIDER", + "value": "gcp_vertex_ai" + }, + { + "name": "GCP_PROJECT_ID", + "value": "my-gcp-project" + }, + { + "name": "GCP_LOCATION", + "value": "my-region" + } + ], + "mounts": [ + {"host": "$HOME/.config/gcloud", "target": "$HOME/.config/gcloud", "ro": true}, + {"host": "$HOME/.config/goose", "target": "$HOME/.config/goose"} + ] + } +} +``` + +The `~/.config/goose` directory contains your Goose configuration (settings, model preferences, etc.). It is mounted read-write so that changes made inside the workspace are persisted back to your host. + +#### Using Default Settings + +If you want to pre-configure Goose with default settings without exposing your local `~/.config/goose` directory inside the container, create default settings files that are baked into the container image at workspace registration time. This is an alternative to mounting your local Goose settings — use one approach or the other, not both. + +**Automatic Onboarding Skip** + +When you register a workspace with the Goose agent, kortex-cli automatically sets `GOOSE_TELEMETRY_ENABLED` to `false` in the Goose config file if it is not already defined, so Goose skips its telemetry prompt on first launch. + +**Step 1: Create the agent settings directory** + +```bash +mkdir -p ~/.kortex-cli/config/goose/.config/goose +``` + +**Step 2: Write the default Goose settings file** + +As an example, you can configure the model and enable telemetry: + +```bash +cat > ~/.kortex-cli/config/goose/.config/goose/config.yaml << 'EOF' +GOOSE_MODEL: "claude-sonnet-4-6" +GOOSE_TELEMETRY_ENABLED: true +EOF +``` + +**Fields:** + +- `GOOSE_MODEL` - The model identifier Goose uses for its AI interactions +- `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** + +```bash +# Register a workspace — the settings file is embedded in the container image +kortex-cli init /path/to/project --runtime podman --agent goose + +# Start the workspace +kortex-cli start my-project + +# Connect — Goose starts with the configured provider and model +kortex-cli terminal my-project +``` + +When `init` runs, kortex-cli: +1. Reads all files from `~/.kortex-cli/config/goose/` (e.g., your provider and model settings) +2. Automatically sets `GOOSE_TELEMETRY_ENABLED: false` in `.config/goose/config.yaml` if the key is not already defined +3. Copies the final settings into the container image at `/home/agent/.config/goose/config.yaml` + +Goose finds this file on startup and uses the pre-configured settings without prompting. + +**Notes:** + +- **Telemetry is disabled automatically** — even if you don't create any settings files, kortex-cli ensures Goose starts without the telemetry prompt +- If you prefer to enable telemetry, set `GOOSE_TELEMETRY_ENABLED: true` in `~/.kortex-cli/config/goose/.config/goose/config.yaml` +- 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/goose/` is copied into the container home directory, preserving the directory structure (e.g., `~/.kortex-cli/config/goose/.config/goose/config.yaml` becomes `/home/agent/.config/goose/config.yaml` inside the container) +- This approach keeps your workspace self-contained — other developers using the same project are not affected, and your local `~/.config/goose` directory is not exposed inside the container +- To apply changes to the settings, remove and re-register the workspace: `kortex-cli remove ` then `kortex-cli init` again + ### Using Cursor CLI Agent This scenario demonstrates how to configure the Cursor agent in a kortex-cli workspace, covering API key injection, sharing your local Cursor settings, and pre-configuring the default model. diff --git a/go.mod b/go.mod index d89308e..1551018 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.1 require ( github.com/fatih/color v1.19.0 + github.com/goccy/go-yaml v1.19.2 github.com/kortex-hub/kortex-cli-api/cli/go v0.0.0-20260402113340-592f26f380bc github.com/kortex-hub/kortex-cli-api/workspace-configuration/go v0.0.0-20260331070743-a7c5f045c21c github.com/rodaine/table v1.3.1 diff --git a/go.sum b/go.sum index 5ee7e77..b967b40 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/pkg/agent/goose.go b/pkg/agent/goose.go new file mode 100644 index 0000000..6d0b992 --- /dev/null +++ b/pkg/agent/goose.go @@ -0,0 +1,81 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +package agent + +import ( + "fmt" + + "github.com/goccy/go-yaml" +) + +const ( + // GooseConfigPath is the relative path to the Goose configuration file. + GooseConfigPath = ".config/goose/config.yaml" + + gooseTelemetryKey = "GOOSE_TELEMETRY_ENABLED" +) + +// gooseAgent is the implementation of Agent for Goose. +type gooseAgent struct{} + +// Compile-time check to ensure gooseAgent implements Agent interface +var _ Agent = (*gooseAgent)(nil) + +// NewGoose creates a new Goose agent implementation. +func NewGoose() Agent { + return &gooseAgent{} +} + +// Name returns the agent name. +func (g *gooseAgent) Name() string { + return "goose" +} + +// SkipOnboarding modifies Goose settings to disable telemetry prompts. +// It sets GOOSE_TELEMETRY_ENABLED to false in the goose config file if the +// value is not already defined. If the user has already set it in their own +// config file, the existing value is preserved. +func (g *gooseAgent) SkipOnboarding(settings map[string][]byte, _ 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{}) + } + + // Only set telemetry if not already defined by the user + if _, defined := config[gooseTelemetryKey]; !defined { + config[gooseTelemetryKey] = false + } + + 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 new file mode 100644 index 0000000..de02bb6 --- /dev/null +++ b/pkg/agent/goose_test.go @@ -0,0 +1,185 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +package agent + +import ( + "testing" + + "github.com/goccy/go-yaml" +) + +func TestGoose_Name(t *testing.T) { + t.Parallel() + + agent := NewGoose() + if got := agent.Name(); got != "goose" { + t.Errorf("Name() = %q, want %q", got, "goose") + } +} + +func TestGoose_SkipOnboarding_NoExistingSettings(t *testing.T) { + t.Parallel() + + agent := NewGoose() + settings := make(map[string][]byte) + + result, err := agent.SkipOnboarding(settings, "/workspace/sources") + if err != nil { + t.Fatalf("SkipOnboarding() 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 val, ok := config[gooseTelemetryKey]; !ok { + t.Errorf("%s not set", gooseTelemetryKey) + } else if val != false { + t.Errorf("%s = %v, want false", gooseTelemetryKey, val) + } +} + +func TestGoose_SkipOnboarding_NilSettings(t *testing.T) { + t.Parallel() + + agent := NewGoose() + + result, err := agent.SkipOnboarding(nil, "/workspace/sources") + if err != nil { + t.Fatalf("SkipOnboarding() 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_SkipOnboarding_PreservesExistingTelemetryTrue(t *testing.T) { + t.Parallel() + + agent := NewGoose() + + existingContent := []byte("GOOSE_MODEL: \"claude-sonnet-4-6\"\nGOOSE_TELEMETRY_ENABLED: true\n") + settings := map[string][]byte{ + GooseConfigPath: existingContent, + } + + result, err := agent.SkipOnboarding(settings, "/workspace/sources") + if err != nil { + t.Fatalf("SkipOnboarding() 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) + } + + if val, ok := config[gooseTelemetryKey]; !ok { + t.Errorf("%s not set", gooseTelemetryKey) + } else if val != true { + t.Errorf("%s = %v, want true (user preference preserved)", gooseTelemetryKey, val) + } +} + +func TestGoose_SkipOnboarding_PreservesExistingTelemetryFalse(t *testing.T) { + t.Parallel() + + agent := NewGoose() + + existingContent := []byte("GOOSE_TELEMETRY_ENABLED: false\n") + settings := map[string][]byte{ + GooseConfigPath: existingContent, + } + + result, err := agent.SkipOnboarding(settings, "/workspace/sources") + if err != nil { + t.Fatalf("SkipOnboarding() 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) + } + + if val, ok := config[gooseTelemetryKey]; !ok { + t.Errorf("%s not set", gooseTelemetryKey) + } else if val != false { + t.Errorf("%s = %v, want false", gooseTelemetryKey, val) + } +} + +func TestGoose_SkipOnboarding_PreservesOtherFields(t *testing.T) { + t.Parallel() + + agent := NewGoose() + + existingContent := []byte("GOOSE_MODEL: \"claude-sonnet-4-6\"\nGOOSE_PROVIDER: \"anthropic\"\n") + settings := map[string][]byte{ + GooseConfigPath: existingContent, + } + + result, err := agent.SkipOnboarding(settings, "/workspace/sources") + if err != nil { + t.Fatalf("SkipOnboarding() 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) + } + + if model, ok := config["GOOSE_MODEL"].(string); !ok || model != "claude-sonnet-4-6" { + t.Errorf("GOOSE_MODEL = %v, want %q", config["GOOSE_MODEL"], "claude-sonnet-4-6") + } + + if provider, ok := config["GOOSE_PROVIDER"].(string); !ok || provider != "anthropic" { + t.Errorf("GOOSE_PROVIDER = %v, want %q", config["GOOSE_PROVIDER"], "anthropic") + } + + if val, ok := config[gooseTelemetryKey]; !ok { + t.Errorf("%s not set", gooseTelemetryKey) + } else if val != false { + t.Errorf("%s = %v, want false", gooseTelemetryKey, val) + } +} + +func TestGoose_SkipOnboarding_InvalidYAML(t *testing.T) { + t.Parallel() + + agent := NewGoose() + + settings := map[string][]byte{ + GooseConfigPath: []byte("invalid: yaml: :::"), + } + + _, err := agent.SkipOnboarding(settings, "/workspace/sources") + if err == nil { + t.Error("Expected error for invalid YAML, got nil") + } +} diff --git a/pkg/agentsetup/register.go b/pkg/agentsetup/register.go index 7757369..ac940be 100644 --- a/pkg/agentsetup/register.go +++ b/pkg/agentsetup/register.go @@ -39,6 +39,7 @@ type agentFactory func() agent.Agent var availableAgents = []agentFactory{ agent.NewClaude, agent.NewCursor, + agent.NewGoose, } // RegisterAll registers all available agent implementations to the given registrar. diff --git a/pkg/runtime/podman/config/defaults.go b/pkg/runtime/podman/config/defaults.go index e47dffd..82a38eb 100644 --- a/pkg/runtime/podman/config/defaults.go +++ b/pkg/runtime/podman/config/defaults.go @@ -82,6 +82,7 @@ func defaultGooseConfig() *AgentConfig { Packages: []string{}, RunCommands: []string{ "cd /tmp && curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash", + fmt.Sprintf("mkdir -p /home/%s/.config/goose", constants.ContainerUser), }, TerminalCommand: []string{"goose"}, }