diff --git a/README.md b/README.md index 928c890..0d4aad1 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,20 @@ $ kortex-cli init /path/to/another-project --runtime fake --agent claude -o json Exit code: `0` (success) +**Step 3a: Register and start immediately with auto-start flag** + +```bash +$ kortex-cli init /path/to/third-project --runtime podman --agent claude -o json --start +``` + +```json +{ + "id": "3c4d5e6f7a8b9098765432109876543210987654321098765432109876543210b" +} +``` + +Exit code: `0` (success, workspace is running) + **Step 4: List all workspaces** ```bash @@ -737,6 +751,57 @@ kortex-cli list kortex-cli list --storage /tmp/kortex-storage ``` +### `KORTEX_CLI_INIT_AUTO_START` + +Automatically starts a workspace after registration when using the `init` command. + +**Usage:** + +```bash +export KORTEX_CLI_INIT_AUTO_START=1 +kortex-cli init /path/to/project --runtime podman --agent claude +``` + +**Priority:** + +The auto-start behavior is determined in the following order (highest to lowest priority): + +1. `--start` flag (if specified) +2. `KORTEX_CLI_INIT_AUTO_START` environment variable (if set to a truthy value) +3. Default: workspace is not started automatically + +**Supported Values:** + +The environment variable accepts the following truthy values (case-insensitive): +- `1` +- `true`, `True`, `TRUE` +- `yes`, `Yes`, `YES` + +Any other value (including `0`, `false`, `no`, or empty string) will not trigger auto-start. + +**Example:** + +```bash +# Set auto-start for the current shell session +export KORTEX_CLI_INIT_AUTO_START=1 + +# Register and start a workspace automatically +kortex-cli init /path/to/project --runtime podman --agent claude +# Workspace is now running + +# Override the environment variable with the flag +export KORTEX_CLI_INIT_AUTO_START=0 +kortex-cli init /path/to/another-project --runtime podman --agent claude --start +# Workspace is started despite env var being 0 +``` + +**Notes:** + +- Auto-starting combines the `init` and `start` commands into a single operation +- Useful for automation scripts where you want workspaces ready to use immediately +- If the workspace fails to start, the registration still succeeds, but an error is returned +- The `--start` flag always takes precedence over the environment variable + ## Podman Runtime The Podman runtime provides a container-based development environment for workspaces. It creates an isolated environment with all necessary tools pre-installed and configured. @@ -1491,6 +1556,7 @@ kortex-cli init [sources-directory] [flags] - `--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) +- `--start` - Start the workspace after registration (can also be set via `KORTEX_CLI_INIT_AUTO_START` environment variable) - `--verbose, -v` - Show detailed output including all workspace information - `--output, -o ` - Output format (supported: `json`) - `--show-logs` - Show stdout and stderr from runtime commands (cannot be combined with `--output json`) @@ -1524,6 +1590,19 @@ kortex-cli init /path/to/myproject --runtime fake --agent claude --project "my p kortex-cli init /path/to/myproject --runtime fake --agent claude --workspace-configuration /path/to/config ``` +**Register and start immediately:** +```bash +kortex-cli init /path/to/myproject --runtime podman --agent claude --start +``` +Output: `a1b2c3d4e5f6...` (workspace ID, workspace is now running) + +**Register and start using environment variable:** +```bash +export KORTEX_CLI_INIT_AUTO_START=1 +kortex-cli init /path/to/myproject --runtime podman --agent claude +``` +Output: `a1b2c3d4e5f6...` (workspace ID, workspace is now running) + **View detailed output:** ```bash kortex-cli init --runtime fake --agent claude --verbose @@ -1676,6 +1755,7 @@ kortex-cli init /tmp/workspace --runtime fake --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 - **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 - The workspace ID is a unique identifier generated automatically - Workspaces can be listed using the `workspace list` command diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index fdbe342..afd26a5 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -49,6 +49,7 @@ type initCmd struct { verbose bool output string showLogs bool + start bool } // preRun validates the parameters and flags @@ -115,6 +116,18 @@ func (i *initCmd) preRun(cmd *cobra.Command, args []string) error { } } + // Determine start behavior: if flag is not set to true, check environment variable + if !i.start { + // Check environment variable + if envStart := os.Getenv("KORTEX_CLI_INIT_AUTO_START"); envStart != "" { + // Accept "1", "true", "yes" as truthy values (case-insensitive) + switch envStart { + case "1", "true", "True", "TRUE", "yes", "Yes", "YES": + i.start = true + } + } + } + // Get sources directory (default to current directory) i.sourcesDir = "." if len(args) > 0 { @@ -215,6 +228,14 @@ func (i *initCmd) run(cmd *cobra.Command, args []string) error { return outputErrorIfJSON(cmd, i.output, err) } + // Start the workspace if auto-start is enabled + if i.start { + err = i.manager.Start(ctx, addedInstance.GetID()) + if err != nil { + return outputErrorIfJSON(cmd, i.output, err) + } + } + // Handle JSON output if i.output == "json" { return i.outputJSON(cmd, addedInstance) @@ -283,6 +304,9 @@ kortex-cli init --runtime fake --agent claude --name my-project # Register with custom project identifier kortex-cli init --runtime fake --agent goose --project my-custom-project +# Register and start workspace +kortex-cli init --runtime podman --agent claude --start + # Show detailed output kortex-cli init --runtime fake --agent claude --verbose @@ -309,6 +333,9 @@ kortex-cli init --runtime fake --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 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)") + // Add verbose flag cmd.Flags().BoolVarP(&c.verbose, "verbose", "v", false, "Show detailed output") diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go index eeeb10d..bc2bfe2 100644 --- a/pkg/cmd/init_test.go +++ b/pkg/cmd/init_test.go @@ -30,6 +30,7 @@ import ( api "github.com/kortex-hub/kortex-cli-api/cli/go" "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/kortex-hub/kortex-cli/pkg/instances" + "github.com/kortex-hub/kortex-cli/pkg/runtimesetup" "github.com/spf13/cobra" ) @@ -748,6 +749,7 @@ func TestInitCmd_PreRun(t *testing.T) { cmd := &cobra.Command{} cmd.Flags().String("workspace-configuration", "", "test flag") cmd.Flags().String("storage", tempDir, "test storage flag") + cmd.Flags().Bool("start", false, "test start flag") args := []string{sourcesDir} @@ -756,6 +758,142 @@ func TestInitCmd_PreRun(t *testing.T) { t.Fatalf("preRun() should succeed when workspace.json doesn't exist: %v", err) } }) + + t.Run("start flag defaults to false", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + c := &initCmd{ + runtime: "fake", + agent: "test-agent", + } + cmd := &cobra.Command{} + cmd.Flags().String("workspace-configuration", "", "test flag") + cmd.Flags().String("storage", tempDir, "test storage flag") + cmd.Flags().Bool("start", false, "test start flag") + + args := []string{} + + err := c.preRun(cmd, args) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if c.start { + t.Errorf("Expected start to be false by default, got true") + } + }) + + t.Run("start flag can be set to true", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + c := &initCmd{ + runtime: "fake", + agent: "test-agent", + start: true, + } + cmd := &cobra.Command{} + cmd.Flags().String("workspace-configuration", "", "test flag") + cmd.Flags().String("storage", tempDir, "test storage flag") + cmd.Flags().Bool("start", false, "test start flag") + cmd.Flags().Set("start", "true") + + args := []string{} + + err := c.preRun(cmd, args) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if !c.start { + t.Errorf("Expected start to be true, got false") + } + }) + + t.Run("uses environment variable when start flag is not set", func(t *testing.T) { + // Note: Cannot use t.Parallel() when using t.Setenv() + + tests := []struct { + name string + envValue string + expected bool + }{ + {"KORTEX_CLI_INIT_AUTO_START=1", "1", true}, + {"KORTEX_CLI_INIT_AUTO_START=true", "true", true}, + {"KORTEX_CLI_INIT_AUTO_START=True", "True", true}, + {"KORTEX_CLI_INIT_AUTO_START=TRUE", "TRUE", true}, + {"KORTEX_CLI_INIT_AUTO_START=yes", "yes", true}, + {"KORTEX_CLI_INIT_AUTO_START=Yes", "Yes", true}, + {"KORTEX_CLI_INIT_AUTO_START=YES", "YES", true}, + {"KORTEX_CLI_INIT_AUTO_START=0", "0", false}, + {"KORTEX_CLI_INIT_AUTO_START=false", "false", false}, + {"KORTEX_CLI_INIT_AUTO_START=no", "no", false}, + {"KORTEX_CLI_INIT_AUTO_START=empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("KORTEX_CLI_INIT_AUTO_START", tt.envValue) + + tempDir := t.TempDir() + + c := &initCmd{ + runtime: "fake", + agent: "test-agent", + } + cmd := &cobra.Command{} + cmd.Flags().String("workspace-configuration", "", "test flag") + cmd.Flags().String("storage", tempDir, "test storage flag") + cmd.Flags().Bool("start", false, "test start flag") + + args := []string{} + + err := c.preRun(cmd, args) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if c.start != tt.expected { + t.Errorf("Expected start to be %v with env var '%s', got %v", tt.expected, tt.envValue, c.start) + } + }) + } + }) + + t.Run("start flag takes precedence over environment variable", func(t *testing.T) { + // Note: Cannot use t.Parallel() when using t.Setenv() + + t.Run("flag true overrides env", func(t *testing.T) { + t.Setenv("KORTEX_CLI_INIT_AUTO_START", "0") + + tempDir := t.TempDir() + + c := &initCmd{ + runtime: "fake", + agent: "test-agent", + start: true, + } + cmd := &cobra.Command{} + cmd.Flags().String("workspace-configuration", "", "test flag") + cmd.Flags().String("storage", tempDir, "test storage flag") + cmd.Flags().Bool("start", false, "test start flag") + cmd.Flags().Set("start", "true") + + args := []string{} + + err := c.preRun(cmd, args) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if !c.start { + t.Errorf("Expected start to be true from flag, got false") + } + }) + }) } func TestInitCmd_E2E(t *testing.T) { @@ -1694,6 +1832,141 @@ func TestInitCmd_E2E(t *testing.T) { t.Errorf("Expected project %s, got %s", customProject, inst.GetProject()) } }) + + t.Run("registers and starts workspace with --start flag", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourcesDir := t.TempDir() + + rootCmd := NewRootCmd() + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetArgs([]string{"--storage", storageDir, "init", "--runtime", "fake", "--agent", "test-agent", sourcesDir, "--start"}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() failed: %v", err) + } + + // Verify instance was created and register runtimes to check state + manager, err := instances.NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + if err := runtimesetup.RegisterAll(manager); err != nil { + t.Fatalf("Failed to register runtimes: %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)) + } + + inst := instancesList[0] + + // Verify instance is running + if inst.GetRuntimeData().State != "running" { + t.Errorf("Expected instance state to be 'running', got '%s'", inst.GetRuntimeData().State) + } + }) + + t.Run("registers without starting when --start is not set and env var is not set", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourcesDir := t.TempDir() + + rootCmd := NewRootCmd() + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetArgs([]string{"--storage", storageDir, "init", "--runtime", "fake", "--agent", "test-agent", sourcesDir}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() failed: %v", err) + } + + // Verify instance was created and register runtimes to check state + manager, err := instances.NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + if err := runtimesetup.RegisterAll(manager); err != nil { + t.Fatalf("Failed to register runtimes: %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)) + } + + inst := instancesList[0] + + // Verify instance is not running (fake runtime sets state to "created" for new instances) + if inst.GetRuntimeData().State == "running" { + t.Errorf("Expected instance state to not be 'running', got '%s'", inst.GetRuntimeData().State) + } + }) +} + +func TestInitCmd_E2E_AutoStartWithEnv(t *testing.T) { + // Note: This test function cannot use t.Parallel() because subtests use t.Setenv() + + t.Run("registers and starts workspace with KORTEX_CLI_INIT_AUTO_START environment variable", func(t *testing.T) { + t.Run("with env var set to 1", func(t *testing.T) { + t.Setenv("KORTEX_CLI_INIT_AUTO_START", "1") + + storageDir := t.TempDir() + sourcesDir := t.TempDir() + + rootCmd := NewRootCmd() + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetArgs([]string{"--storage", storageDir, "init", "--runtime", "fake", "--agent", "test-agent", sourcesDir}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute() failed: %v", err) + } + + // Verify instance was created and register runtimes to check state + manager, err := instances.NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + if err := runtimesetup.RegisterAll(manager); err != nil { + t.Fatalf("Failed to register runtimes: %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)) + } + + inst := instancesList[0] + + // Verify instance is running + if inst.GetRuntimeData().State != "running" { + t.Errorf("Expected instance state to be 'running', got '%s'", inst.GetRuntimeData().State) + } + }) + }) } func TestInitCmd_MultiLevelConfig(t *testing.T) { @@ -2007,7 +2280,7 @@ func TestInitCmd_Examples(t *testing.T) { } // Verify we have the expected number of examples - expectedCount := 6 + expectedCount := 7 if len(commands) != expectedCount { t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) } diff --git a/skills/implementing-command-patterns/SKILL.md b/skills/implementing-command-patterns/SKILL.md index 551b8e4..d34d561 100644 --- a/skills/implementing-command-patterns/SKILL.md +++ b/skills/implementing-command-patterns/SKILL.md @@ -129,6 +129,162 @@ func TestMyCmd_PreRun(t *testing.T) { **Reference:** See `pkg/cmd/init.go`, `pkg/cmd/workspace_remove.go`, and `pkg/cmd/workspace_list.go` for examples of proper flag binding. +## Environment Variable Fallback Pattern + +Commands can support environment variables as fallbacks for flags, allowing users to set defaults without specifying flags every time. + +### Rules + +1. **Flags always take precedence** over environment variables +2. **Check the struct field value** - If empty/false, then check environment variable +3. **Parse environment variable** after checking if the field value is not set +4. **Document priority** in command help text and examples + +### Pattern + +```go +type myCmd struct { + runtime string // Bound to --runtime flag + agent string // Bound to --agent flag + start bool // Bound to --start flag +} + +func (m *myCmd) preRun(cmd *cobra.Command, args []string) error { + // String flag: check if empty, then try environment variable + if m.runtime == "" { + if envRuntime := os.Getenv("KORTEX_CLI_DEFAULT_RUNTIME"); envRuntime != "" { + m.runtime = envRuntime + } else { + return fmt.Errorf("runtime is required: use --runtime flag or set KORTEX_CLI_DEFAULT_RUNTIME environment variable") + } + } + + // Boolean flag: check if false, then try environment variable + if !m.start { + if envStart := os.Getenv("KORTEX_CLI_INIT_AUTO_START"); envStart != "" { + // Parse truthy values + switch envStart { + case "1", "true", "True", "TRUE", "yes", "Yes", "YES": + m.start = true + } + } + } + + return nil +} + +func NewMyCmd() *cobra.Command { + c := &myCmd{} + + cmd := &cobra.Command{ + Use: "my-command", + Short: "My command description", + PreRunE: c.preRun, + RunE: c.run, + } + + // Bind flags with helpful descriptions mentioning environment variable fallback + cmd.Flags().StringVarP(&c.runtime, "runtime", "r", "", "Runtime to use (or set KORTEX_CLI_DEFAULT_RUNTIME)") + cmd.Flags().StringVarP(&c.agent, "agent", "a", "", "Agent to use (or set KORTEX_CLI_DEFAULT_AGENT)") + cmd.Flags().BoolVar(&c.start, "start", false, "Auto-start (or set KORTEX_CLI_INIT_AUTO_START)") + + return cmd +} +``` + +### Environment Variable Parsing + +**String values:** +- Simple: just use the environment variable value directly +- Empty string means not set + +**Boolean values:** +- Check if field is `false`, then parse environment variable +- Parse truthy values: `"1"`, `"true"`, `"True"`, `"TRUE"`, `"yes"`, `"Yes"`, `"YES"` +- All other values (including `"0"`, `"false"`, `"no"`, `""`) are falsy +- If flag is explicitly set to `true`, the field is already `true`, so environment variable is never checked + +### Testing Environment Variables + +```go +func TestMyCmd_PreRun(t *testing.T) { + t.Run("uses environment variable when flag not set", func(t *testing.T) { + // Note: Cannot use t.Parallel() when using t.Setenv() + t.Setenv("KORTEX_CLI_DEFAULT_RUNTIME", "podman") + + c := &myCmd{} + cmd := &cobra.Command{} + cmd.Flags().String("runtime", "", "test flag") + + err := c.preRun(cmd, []string{}) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if c.runtime != "podman" { + t.Errorf("Expected runtime to be 'podman' from env var, got: %s", c.runtime) + } + }) + + t.Run("flag takes precedence over environment variable", func(t *testing.T) { + // Note: Cannot use t.Parallel() when using t.Setenv() + t.Setenv("KORTEX_CLI_DEFAULT_RUNTIME", "fake") + + c := &myCmd{runtime: "podman"} // Set via flag + cmd := &cobra.Command{} + cmd.Flags().String("runtime", "", "test flag") + cmd.Flags().Set("runtime", "podman") + + err := c.preRun(cmd, []string{}) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if c.runtime != "podman" { + t.Errorf("Expected runtime to be 'podman' from flag, got: %s", c.runtime) + } + }) + + // Table-driven test for boolean environment variable values + t.Run("parses boolean environment variable", func(t *testing.T) { + tests := []struct { + name string + envValue string + expected bool + }{ + {"1 is truthy", "1", true}, + {"true is truthy", "true", true}, + {"True is truthy", "True", true}, + {"yes is truthy", "yes", true}, + {"0 is falsy", "0", false}, + {"false is falsy", "false", false}, + {"empty is falsy", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("KORTEX_CLI_INIT_AUTO_START", tt.envValue) + + c := &myCmd{} + cmd := &cobra.Command{} + cmd.Flags().Bool("start", false, "test flag") + + err := c.preRun(cmd, []string{}) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if c.start != tt.expected { + t.Errorf("Expected start to be %v, got %v", tt.expected, c.start) + } + }) + } + }) +} +``` + +**Reference:** See `pkg/cmd/init.go` for a complete implementation with `KORTEX_CLI_DEFAULT_RUNTIME`, `KORTEX_CLI_DEFAULT_AGENT`, and `KORTEX_CLI_INIT_AUTO_START`. + ## JSON Output Support Pattern When adding JSON output support to commands, follow this pattern to ensure consistent error handling and output formatting.