From 839af42e7623d370feecd3695e479cbe98124296 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Tue, 24 Mar 2026 13:52:10 +0000 Subject: [PATCH 1/4] feat(cmd): add terminal command for interactive workspace sessions Implements terminal command that enables users to connect to running workspace instances with interactive sessions. The command delegates to runtime-specific implementations (podman uses 'podman exec -it') to execute commands inside running containers. Key additions: - Terminal interface in runtime system for interactive session support - Podman runtime implementation using 'podman exec -it' - Instances manager Terminal() method with running state validation - 'workspace terminal' command and 'terminal' alias - Enhanced example validator to support '--' argument separator - Comprehensive tests for all components Implements plan #100. Co-authored-by: Claude Sonnet 4.5 Signed-off-by: Philippe Martin --- docs/plans/100.md | 289 +++++++++++++++++++++ pkg/cmd/root.go | 1 + pkg/cmd/terminal.go | 44 ++++ pkg/cmd/terminal_test.go | 149 +++++++++++ pkg/cmd/testutil/example_validator.go | 7 + pkg/cmd/testutil/example_validator_test.go | 43 +++ pkg/cmd/workspace.go | 1 + pkg/cmd/workspace_terminal.go | 122 +++++++++ pkg/cmd/workspace_terminal_test.go | 236 +++++++++++++++++ pkg/instances/manager.go | 53 ++++ pkg/instances/manager_terminal_test.go | 260 ++++++++++++++++++ pkg/runtime/podman/exec/exec.go | 15 ++ pkg/runtime/podman/exec/fake.go | 32 ++- pkg/runtime/podman/terminal.go | 41 +++ pkg/runtime/podman/terminal_test.go | 134 ++++++++++ pkg/runtime/runtime.go | 30 +++ 16 files changed, 1455 insertions(+), 2 deletions(-) create mode 100644 docs/plans/100.md create mode 100644 pkg/cmd/terminal.go create mode 100644 pkg/cmd/terminal_test.go create mode 100644 pkg/cmd/workspace_terminal.go create mode 100644 pkg/cmd/workspace_terminal_test.go create mode 100644 pkg/instances/manager_terminal_test.go create mode 100644 pkg/runtime/podman/terminal.go create mode 100644 pkg/runtime/podman/terminal_test.go diff --git a/docs/plans/100.md b/docs/plans/100.md new file mode 100644 index 0000000..1b3b29b --- /dev/null +++ b/docs/plans/100.md @@ -0,0 +1,289 @@ +# Implementation Plan: Terminal Command (Issue #100) + +## Context + +This feature enables users to connect to running workspace instances and launch interactive terminal sessions with AI agents (Claude Code, Goose, etc.). Currently, users can start/stop workspaces but cannot interact with the running agents inside them. The `terminal` command fills this gap by providing direct terminal access to running instances. + +**Problem:** No way to interact with running agents in workspace containers +**Solution:** Add `terminal` command with runtime-specific implementations (podman runtime uses `podman exec -it`) +**Benefit:** Users can work directly within their agent environments + +## Implementation Approach + +### 1. Extend Runtime System with Optional Terminal Interface + +**Add to `/workspace/sources/pkg/runtime/runtime.go`:** + +Add a new optional `Terminal` interface after the `StorageAware` interface definition: + +```go +// Terminal is an optional interface for runtimes that support interactive terminal sessions. +// Runtimes implementing this interface enable the terminal command for connecting to running instances. +type Terminal interface { + // Terminal starts an interactive terminal session inside a running instance. + // The command is executed with stdin/stdout/stderr connected directly to the user's terminal. + // Returns an error if the instance is not running or command execution fails. + Terminal(ctx context.Context, instanceID string, command []string) error +} +``` + +**Why optional?** Not all runtimes may support interactive terminals. Follows the existing `StorageAware` pattern. + +### 2. Extend Podman Executor for Interactive Execution + +**Update `/workspace/sources/pkg/runtime/podman/exec/exec.go`:** + +Add `RunInteractive` method to the `Executor` interface and implement it in the executor struct: + +```go +// In Executor interface: +RunInteractive(ctx context.Context, args ...string) error + +// In executor struct implementation: +func (e *executor) RunInteractive(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "podman", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} +``` + +**Update fake executor** in `exec/fake.go` to track `RunInteractive` calls for testing. + +**Why separate method?** Existing `Run()` and `Output()` are for programmatic execution. Interactive sessions need direct terminal I/O connection. + +### 3. Implement Terminal Support in Podman Runtime + +**Create `/workspace/sources/pkg/runtime/podman/terminal.go`:** + +**Podman-specific implementation:** Uses `podman exec -it` to execute commands inside containers. + +```go +package podman + +import ( + "context" + "fmt" + "github.com/kortex-hub/kortex-cli/pkg/runtime" +) + +// Compile-time check +var _ runtime.Terminal = (*podmanRuntime)(nil) + +func (p *podmanRuntime) Terminal(ctx context.Context, instanceID string, command []string) error { + if instanceID == "" { + return fmt.Errorf("%w: instance ID is required", runtime.ErrInvalidParams) + } + if len(command) == 0 { + return fmt.Errorf("%w: command is required", runtime.ErrInvalidParams) + } + + // Podman-specific: Build podman exec -it + args := []string{"exec", "-it", instanceID} + args = append(args, command...) + + return p.executor.RunInteractive(ctx, args...) +} +``` + +### 4. Do not Implement Terminal Support in Fake Runtime + +### 5. Add Terminal Method to Instances Manager + +**Update `/workspace/sources/pkg/instances/manager.go`:** + +Add `Terminal` method to the `Manager` interface: + +```go +// Terminal starts an interactive terminal session in a running instance. +Terminal(ctx context.Context, id string, command []string) error +``` + +**Implement in manager struct:** + +```go +func (m *manager) Terminal(ctx context.Context, id string, command []string) error { + // Get instance by ID + // Verify instance is running (check RuntimeData.State == "running") + // Get runtime from registry by type + // Type-assert to runtime.Terminal interface + // Call runtime.Terminal(ctx, runtimeInstanceID, command) +} +``` + +**Key logic:** +- Uses read lock (doesn't modify state) +- Returns error if instance not found or not running +- Returns error if runtime doesn't implement Terminal interface +- Delegates to runtime's Terminal implementation + +### 6. Create Workspace Terminal Command + +**Create `/workspace/sources/pkg/cmd/workspace_terminal.go`:** + +Command struct: +```go +type workspaceTerminalCmd struct { + manager instances.Manager + id string + command []string // From args[1:] (arguments after ID) +} +``` + +**Command behavior:** +1. `preRun`: Create manager, register runtimes, extract ID from args[0] +2. `run`: + - Extract command from args[1:] (remaining arguments after ID) + - Determine command to execute: + - If args provided (len(args) > 1): use args[1:] as the command + - Otherwise load from podman runtime configuration (PR #94) + - Default to `["claude"]` if runtime doesn't specify + - Call `manager.Terminal(ctx, id, command)` + +**Command definition:** +- Use: `terminal ID [COMMAND...]` +- Args: `cobra.MinimumNArgs(1)` (ID required, command optional) +- ValidArgsFunction: `completeRunningWorkspaceID` (only running workspaces) +- ArgsLenAtDash: Cobra handles `--` separator automatically, stops parsing flags and treats rest as args +- Example: + ``` + # Connect using agent command from runtime configuration + kortex-cli workspace terminal abc123 + + # Run specific command + kortex-cli workspace terminal abc123 claude-code + + # Run command with arguments + kortex-cli workspace terminal abc123 claude-code --debug + + # Run shell command (use -- to prevent flag parsing) + kortex-cli workspace terminal abc123 -- bash -c "sleep 10000" + ``` + +**No JSON output support** - Terminal is inherently interactive, no `--output` flag. + +### 7. Create Terminal Alias Command + +**Create `/workspace/sources/pkg/cmd/terminal.go`:** + +Delegate to `NewWorkspaceTerminalCmd()` following the pattern in `start.go`, `stop.go`: + +```go +func NewTerminalCmd() *cobra.Command { + workspaceTerminalCmd := NewWorkspaceTerminalCmd() + cmd := &cobra.Command{ + Use: "terminal ID [COMMAND...]", + Short: workspaceTerminalCmd.Short, + Long: workspaceTerminalCmd.Long, + Example: AdaptExampleForAlias(workspaceTerminalCmd.Example, "workspace terminal", "terminal"), + Args: workspaceTerminalCmd.Args, + ValidArgsFunction: workspaceTerminalCmd.ValidArgsFunction, + PreRunE: workspaceTerminalCmd.PreRunE, + RunE: workspaceTerminalCmd.RunE, + } + // No flags to copy - terminal command uses arguments only + return cmd +} +``` + +### 8. Register Commands + +**Update `/workspace/sources/pkg/cmd/workspace.go`:** +```go +cmd.AddCommand(NewWorkspaceTerminalCmd()) +``` + +**Update `/workspace/sources/pkg/cmd/root.go`:** +```go +rootCmd.AddCommand(NewTerminalCmd()) +``` + +### 9. Agent Command Configuration + +The agent command is defined in the **podman runtime configuration** (being implemented in PR #94). + +**Command resolution logic in `run` method:** +1. If command args provided (len(args) > 1): use args[1:] directly +2. Otherwise: retrieve from podman runtime configuration (via PR #94 not merged yet - to be done later) +3. Fallback: use default `["claude"]` if runtime config doesn't specify + +**Integration with PR #94:** +- Podman runtime will have configuration for the agent command to execute +- The terminal command will query this configuration when no args are provided +- This keeps runtime-specific settings (like agent command) with the runtime, not the workspace + +## Critical Files to Create/Modify + +### New Files: +- `/workspace/sources/pkg/runtime/podman/terminal.go` - Podman Terminal implementation +- `/workspace/sources/pkg/cmd/workspace_terminal.go` - Workspace terminal command +- `/workspace/sources/pkg/cmd/terminal.go` - Terminal alias command +- `/workspace/sources/pkg/runtime/podman/terminal_test.go` - Podman terminal tests +- `/workspace/sources/pkg/instances/manager_terminal_test.go` - Manager terminal tests +- `/workspace/sources/pkg/cmd/workspace_terminal_test.go` - Command tests +- `/workspace/sources/pkg/cmd/terminal_test.go` - Alias command tests + +### Modified Files: +- `/workspace/sources/pkg/runtime/runtime.go` - Add Terminal interface +- `/workspace/sources/pkg/runtime/podman/exec/exec.go` - Add RunInteractive method +- `/workspace/sources/pkg/runtime/podman/exec/fake.go` - Add RunInteractive to fake +- `/workspace/sources/pkg/instances/manager.go` - Add Terminal method to interface and implementation +- `/workspace/sources/pkg/cmd/workspace.go` - Register workspace terminal subcommand +- `/workspace/sources/pkg/cmd/root.go` - Register terminal alias command + +## Testing Strategy + +### Unit Tests: +1. **Executor tests** - Test RunInteractive method construction (fake executor) +2. **Runtime tests** - Test Terminal implementation with fake executor +3. **Manager tests** - Test Terminal method with fake runtime +4. **Command preRun tests** - Test validation and initialization + +### Integration Tests: +1. **E2E command tests** - Execute full command with fake runtime +2. **Agent command precedence** - Test args override vs runtime config vs default +3. **Argument handling**: + - Test with no command args (uses runtime config or default) + - Test with command args (overrides runtime config) + - Test with `--` separator for commands with flags +4. **Error cases**: + - Instance not found + - Instance not running (state != "running") + - Runtime doesn't support terminal + +### Manual Testing: +1. Create a workspace with `kortex-cli init` +2. Start the workspace with `kortex-cli start ` +3. Connect with `kortex-cli terminal ` (uses runtime config or default) +4. Verify Claude Code launches interactively +5. Test with custom command: `kortex-cli terminal bash` +6. Test with command arguments: `kortex-cli terminal claude-code --debug` +7. Test with -- separator: `kortex-cli terminal -- bash -c "echo test"` +8. Verify stdin/stdout/stderr work correctly (type commands, see output) + +## Verification + +After implementation, verify: + +1. **Build succeeds**: `make build` +2. **Tests pass**: `make test` +3. **Command appears in help**: `./kortex-cli --help` shows `terminal` command +4. **Workspace subcommand exists**: `./kortex-cli workspace --help` shows `terminal` +5. **Tab completion works**: Complete workspace IDs (running only) +6. **Manual test**: Create workspace, start it, connect with terminal +7. **Error handling**: Try terminal on stopped workspace (should error) + +## Implementation Notes + +- Terminal is an **optional** interface - runtimes that don't support it simply won't implement it +- The command only works on **running** instances (verified in manager) +- **Runtime-specific implementation:** Podman runtime uses `podman exec -it`, other runtimes would use their own mechanisms +- Agent command is configured in **podman runtime configuration** (PR #94) not workspace.json +- Command resolution: args override → runtime config → default `["claude"]` +- Command arguments after ID are passed directly to the runtime instance (e.g., `terminal ID bash -l`) +- Use `--` separator to prevent Cobra from parsing flags: `terminal ID -- bash -c "cmd"` +- No flags needed - command is specified via arguments, making the interface simpler +- No JSON output mode - terminal is inherently interactive +- Follows existing command patterns (struct + preRun + run, but no flag binding needed) +- Uses `completeRunningWorkspaceID` for tab completion (only running workspaces make sense) diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 51c088d..9ced344 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -52,6 +52,7 @@ func NewRootCmd() *cobra.Command { rootCmd.AddCommand(NewRemoveCmd()) rootCmd.AddCommand(NewStartCmd()) rootCmd.AddCommand(NewStopCmd()) + rootCmd.AddCommand(NewTerminalCmd()) // Global flags rootCmd.PersistentFlags().String("storage", defaultStoragePath, "Directory where kortex-cli will store all its files") diff --git a/pkg/cmd/terminal.go b/pkg/cmd/terminal.go new file mode 100644 index 0000000..253b770 --- /dev/null +++ b/pkg/cmd/terminal.go @@ -0,0 +1,44 @@ +/********************************************************************** + * 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 cmd + +import ( + "github.com/spf13/cobra" +) + +func NewTerminalCmd() *cobra.Command { + // Create the workspace terminal command + workspaceTerminalCmd := NewWorkspaceTerminalCmd() + + // Create an alias command that delegates to workspace terminal + cmd := &cobra.Command{ + Use: "terminal ID [COMMAND...]", + Short: workspaceTerminalCmd.Short, + Long: workspaceTerminalCmd.Long, + Example: AdaptExampleForAlias(workspaceTerminalCmd.Example, "workspace terminal", "terminal"), + Args: workspaceTerminalCmd.Args, + ValidArgsFunction: workspaceTerminalCmd.ValidArgsFunction, + PreRunE: workspaceTerminalCmd.PreRunE, + RunE: workspaceTerminalCmd.RunE, + } + + // No flags to copy - terminal command uses arguments only + + return cmd +} diff --git a/pkg/cmd/terminal_test.go b/pkg/cmd/terminal_test.go new file mode 100644 index 0000000..9ac24e7 --- /dev/null +++ b/pkg/cmd/terminal_test.go @@ -0,0 +1,149 @@ +/********************************************************************** + * 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 cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kortex-hub/kortex-cli/pkg/instances" +) + +func TestTerminalCmd(t *testing.T) { + t.Parallel() + + cmd := NewTerminalCmd() + if cmd == nil { + t.Fatal("NewTerminalCmd() returned nil") + } + + if cmd.Use != "terminal ID [COMMAND...]" { + t.Errorf("Expected Use to be 'terminal ID [COMMAND...]', got '%s'", cmd.Use) + } +} + +func TestTerminalCmd_DelegatesToWorkspaceTerminal(t *testing.T) { + t.Parallel() + + terminalCmd := NewTerminalCmd() + workspaceTerminalCmd := NewWorkspaceTerminalCmd() + + // Verify it delegates to workspace terminal + if terminalCmd.Short != workspaceTerminalCmd.Short { + t.Errorf("Short mismatch: alias=%s, workspace=%s", terminalCmd.Short, workspaceTerminalCmd.Short) + } + + if terminalCmd.Long != workspaceTerminalCmd.Long { + t.Errorf("Long mismatch") + } +} + +func TestTerminalCmd_ExamplesAdapted(t *testing.T) { + t.Parallel() + + terminalCmd := NewTerminalCmd() + + // Verify examples are adapted + if !strings.Contains(terminalCmd.Example, "kortex-cli terminal") { + t.Error("Expected examples to contain 'kortex-cli terminal'") + } + + if strings.Contains(terminalCmd.Example, "kortex-cli workspace terminal") { + t.Error("Examples should not contain 'kortex-cli workspace terminal'") + } +} + +func TestTerminalCmd_E2E(t *testing.T) { + t.Parallel() + + t.Run("fails for nonexistent workspace", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"terminal", "nonexistent-id", "--storage", storageDir}) + + var outBuf bytes.Buffer + rootCmd.SetOut(&outBuf) + rootCmd.SetErr(&outBuf) + + err := rootCmd.Execute() + if err == nil { + t.Fatal("Expected error for nonexistent workspace") + } + + output := outBuf.String() + if !strings.Contains(output, "workspace not found") && !strings.Contains(err.Error(), "workspace not found") { + t.Errorf("Expected 'workspace not found' error, got: %v (output: %s)", err, output) + } + }) + + t.Run("fails for stopped workspace", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourceDir := t.TempDir() + configDir := filepath.Join(sourceDir, ".kortex") + os.MkdirAll(configDir, 0755) + + // Initialize a workspace + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"init", sourceDir, "--storage", storageDir, "--runtime", "fake"}) + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Failed to init workspace: %v", err) + } + + // Get the workspace ID using the instances manager + 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) == 0 { + t.Fatal("No instances found after init") + } + workspaceID := instancesList[0].GetID() + + // Try to connect to terminal (workspace is not started) + rootCmd = NewRootCmd() + rootCmd.SetArgs([]string{"terminal", workspaceID, "--storage", storageDir}) + + var outBuf bytes.Buffer + rootCmd.SetOut(&outBuf) + rootCmd.SetErr(&outBuf) + + err = rootCmd.Execute() + if err == nil { + t.Fatal("Expected error for stopped workspace") + } + + // Should fail because workspace is not running + if !strings.Contains(err.Error(), "not running") { + t.Errorf("Expected 'not running' error, got: %v", err) + } + }) +} diff --git a/pkg/cmd/testutil/example_validator.go b/pkg/cmd/testutil/example_validator.go index fb8340c..8704414 100644 --- a/pkg/cmd/testutil/example_validator.go +++ b/pkg/cmd/testutil/example_validator.go @@ -90,6 +90,13 @@ func parseCommandLine(line string) (ExampleCommand, error) { for i := 1; i < len(parts); i++ { part := parts[i] + // Check for -- separator (stop flag parsing) + if part == "--" { + // Everything after -- is treated as positional arguments + cmd.Args = append(cmd.Args, parts[i+1:]...) + break + } + if strings.HasPrefix(part, "--") { // Long flag flagName, flagValue, hasValue := parseLongFlag(part, parts, &i) diff --git a/pkg/cmd/testutil/example_validator_test.go b/pkg/cmd/testutil/example_validator_test.go index 722942d..12adaa0 100644 --- a/pkg/cmd/testutil/example_validator_test.go +++ b/pkg/cmd/testutil/example_validator_test.go @@ -157,6 +157,49 @@ kortex-cli workspace list`, } }, }, + { + name: "command with -- separator", + example: `kortex-cli terminal abc123 -- bash -c 'echo hello'`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Binary != "kortex-cli" { + t.Errorf("Expected binary 'kortex-cli', got '%s'", commands[0].Binary) + } + // Should have: terminal, abc123, bash, -c, echo hello + expectedArgs := []string{"terminal", "abc123", "bash", "-c", "echo hello"} + if len(commands[0].Args) != len(expectedArgs) { + t.Errorf("Expected %d args, got %d: %v", len(expectedArgs), len(commands[0].Args), commands[0].Args) + } + for i, arg := range expectedArgs { + if i >= len(commands[0].Args) || commands[0].Args[i] != arg { + t.Errorf("Expected args[%d]=%s, got %v", i, arg, commands[0].Args) + } + } + // Should have no flags (-- stops flag parsing) + if len(commands[0].FlagPresent) != 0 { + t.Errorf("Expected no flags after --, got %v", commands[0].FlagPresent) + } + }, + }, + { + name: "command with flags before -- separator", + example: `kortex-cli terminal --storage /tmp abc123 -- bash -l`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + // Should have flag before -- + if !commands[0].FlagPresent["storage"] { + t.Error("Expected 'storage' flag to be present") + } + if commands[0].FlagValues["storage"] != "/tmp" { + t.Errorf("Expected storage=/tmp, got %s", commands[0].FlagValues["storage"]) + } + // Should have: terminal, abc123, bash, -l (after --) + expectedArgs := []string{"terminal", "abc123", "bash", "-l"} + if len(commands[0].Args) != len(expectedArgs) { + t.Errorf("Expected %d args, got %d: %v", len(expectedArgs), len(commands[0].Args), commands[0].Args) + } + }, + }, } for _, tt := range tests { diff --git a/pkg/cmd/workspace.go b/pkg/cmd/workspace.go index 905100b..686af2f 100644 --- a/pkg/cmd/workspace.go +++ b/pkg/cmd/workspace.go @@ -35,6 +35,7 @@ func NewWorkspaceCmd() *cobra.Command { cmd.AddCommand(NewWorkspaceRemoveCmd()) cmd.AddCommand(NewWorkspaceStartCmd()) cmd.AddCommand(NewWorkspaceStopCmd()) + cmd.AddCommand(NewWorkspaceTerminalCmd()) return cmd } diff --git a/pkg/cmd/workspace_terminal.go b/pkg/cmd/workspace_terminal.go new file mode 100644 index 0000000..c28f6e0 --- /dev/null +++ b/pkg/cmd/workspace_terminal.go @@ -0,0 +1,122 @@ +/********************************************************************** + * 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 cmd + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/kortex-hub/kortex-cli/pkg/instances" + "github.com/kortex-hub/kortex-cli/pkg/runtimesetup" + "github.com/spf13/cobra" +) + +// workspaceTerminalCmd contains the configuration for the workspace terminal command +type workspaceTerminalCmd struct { + manager instances.Manager + id string + command []string +} + +// preRun validates the parameters and flags +func (w *workspaceTerminalCmd) preRun(cmd *cobra.Command, args []string) error { + w.id = args[0] + + // Extract command from args[1:] if provided + if len(args) > 1 { + w.command = args[1:] + } else { + // Default command - will be configurable from runtime in PR #94 + // For now, use the default claude command + w.command = []string{"claude"} + } + + // Get storage directory from global flag + storageDir, err := cmd.Flags().GetString("storage") + if err != nil { + return fmt.Errorf("failed to read --storage flag: %w", err) + } + + // Normalize storage path to absolute path + absStorageDir, err := filepath.Abs(storageDir) + if err != nil { + return fmt.Errorf("failed to resolve absolute path for storage directory: %w", err) + } + + // Create manager + manager, err := instances.NewManager(absStorageDir) + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + // Register all available runtimes + if err := runtimesetup.RegisterAll(manager); err != nil { + return fmt.Errorf("failed to register runtimes: %w", err) + } + + w.manager = manager + + return nil +} + +// run executes the workspace terminal command logic +func (w *workspaceTerminalCmd) run(cmd *cobra.Command, args []string) error { + // Start terminal session + err := w.manager.Terminal(cmd.Context(), w.id, w.command) + if err != nil { + if errors.Is(err, instances.ErrInstanceNotFound) { + return fmt.Errorf("workspace not found: %s\nUse 'workspace list' to see available workspaces", w.id) + } + return err + } + + return nil +} + +func NewWorkspaceTerminalCmd() *cobra.Command { + c := &workspaceTerminalCmd{} + + cmd := &cobra.Command{ + Use: "terminal ID [COMMAND...]", + Short: "Connect to a running workspace with an interactive terminal", + Long: `Connect to a running workspace with an interactive terminal session. + +The terminal command starts an interactive session inside a running workspace instance. +By default, it launches the agent command configured in the runtime. You can override +this by providing a custom command. + +The workspace must be in a running state. Use 'workspace start' to start a workspace +before connecting.`, + Example: `# Connect using the default agent command +kortex-cli workspace terminal abc123 + +# Run a bash shell +kortex-cli workspace terminal abc123 bash + +# Run a command with flags (use -- to prevent kortex-cli from parsing them) +kortex-cli workspace terminal abc123 -- bash -c 'echo hello'`, + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeRunningWorkspaceID, + PreRunE: c.preRun, + RunE: c.run, + } + + return cmd +} diff --git a/pkg/cmd/workspace_terminal_test.go b/pkg/cmd/workspace_terminal_test.go new file mode 100644 index 0000000..095f7aa --- /dev/null +++ b/pkg/cmd/workspace_terminal_test.go @@ -0,0 +1,236 @@ +/********************************************************************** + * 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 cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" + "github.com/kortex-hub/kortex-cli/pkg/instances" + "github.com/spf13/cobra" +) + +func TestWorkspaceTerminalCmd(t *testing.T) { + t.Parallel() + + cmd := NewWorkspaceTerminalCmd() + if cmd == nil { + t.Fatal("NewWorkspaceTerminalCmd() returned nil") + } + + if cmd.Use != "terminal ID [COMMAND...]" { + t.Errorf("Expected Use to be 'terminal ID [COMMAND...]', got '%s'", cmd.Use) + } +} + +func TestWorkspaceTerminalCmd_PreRun(t *testing.T) { + t.Parallel() + + t.Run("extracts id from args and creates manager", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + + c := &workspaceTerminalCmd{} + cmd := &cobra.Command{} + cmd.Flags().String("storage", storageDir, "test storage flag") + + args := []string{"test-workspace-id"} + + err := c.preRun(cmd, args) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if c.manager == nil { + t.Error("Expected manager to be created") + } + + if c.id != "test-workspace-id" { + t.Errorf("Expected id to be 'test-workspace-id', got %s", c.id) + } + + // Verify default command is set when no command args provided + if len(c.command) != 1 || c.command[0] != "claude" { + t.Errorf("Expected default command ['claude'], got %v", c.command) + } + }) + + t.Run("handles id with command args", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + + c := &workspaceTerminalCmd{} + cmd := &cobra.Command{} + cmd.Flags().String("storage", storageDir, "test storage flag") + + // args contains ID and command + args := []string{"test-id", "bash", "-l"} + + err := c.preRun(cmd, args) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if c.id != "test-id" { + t.Errorf("Expected id to be 'test-id', got %s", c.id) + } + + // Verify command was extracted in preRun + if len(c.command) != 2 { + t.Errorf("Expected command length 2, got %d", len(c.command)) + } + if len(c.command) >= 2 && (c.command[0] != "bash" || c.command[1] != "-l") { + t.Errorf("Expected command ['bash', '-l'], got %v", c.command) + } + }) + + t.Run("creates absolute storage path", func(t *testing.T) { + t.Parallel() + + storageDir := "relative/path" + + c := &workspaceTerminalCmd{} + cmd := &cobra.Command{} + cmd.Flags().String("storage", storageDir, "test storage flag") + + args := []string{"test-id"} + + err := c.preRun(cmd, args) + if err != nil { + t.Fatalf("preRun() failed: %v", err) + } + + if c.manager == nil { + t.Error("Expected manager to be created") + } + }) +} + +func TestWorkspaceTerminalCmd_Examples(t *testing.T) { + t.Parallel() + + // Get the command + cmd := NewWorkspaceTerminalCmd() + + // Verify Example field is not empty + if cmd.Example == "" { + t.Fatal("Example field should not be empty") + } + + // Parse the examples + commands, err := testutil.ParseExampleCommands(cmd.Example) + if err != nil { + t.Fatalf("Failed to parse examples: %v", err) + } + + // Verify we have the expected number of examples + expectedCount := 3 + if len(commands) != expectedCount { + t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) + } + + // Validate all examples against the root command + rootCmd := NewRootCmd() + err = testutil.ValidateCommandExamples(rootCmd, cmd.Example) + if err != nil { + t.Errorf("Example validation failed: %v", err) + } +} + +func TestWorkspaceTerminalCmd_E2E(t *testing.T) { + t.Parallel() + + t.Run("fails for nonexistent workspace", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "terminal", "nonexistent-id", "--storage", storageDir}) + + var outBuf bytes.Buffer + rootCmd.SetOut(&outBuf) + rootCmd.SetErr(&outBuf) + + err := rootCmd.Execute() + if err == nil { + t.Fatal("Expected error for nonexistent workspace") + } + + output := outBuf.String() + if !strings.Contains(output, "workspace not found") && !strings.Contains(err.Error(), "workspace not found") { + t.Errorf("Expected 'workspace not found' error, got: %v (output: %s)", err, output) + } + }) + + t.Run("fails for stopped workspace", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourceDir := t.TempDir() + configDir := filepath.Join(sourceDir, ".kortex") + os.MkdirAll(configDir, 0755) + + // Initialize a workspace + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"init", sourceDir, "--storage", storageDir, "--runtime", "fake"}) + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Failed to init workspace: %v", err) + } + + // Get the workspace ID + 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) == 0 { + t.Fatal("No instances found after init") + } + workspaceID := instancesList[0].GetID() + + // Try to connect to terminal (workspace is not started) + rootCmd = NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "terminal", workspaceID, "--storage", storageDir}) + + var outBuf bytes.Buffer + rootCmd.SetOut(&outBuf) + rootCmd.SetErr(&outBuf) + + err = rootCmd.Execute() + if err == nil { + t.Fatal("Expected error for stopped workspace") + } + + // Should fail because workspace is not running + if !strings.Contains(err.Error(), "not running") { + t.Errorf("Expected 'not running' error, got: %v", err) + } + }) +} diff --git a/pkg/instances/manager.go b/pkg/instances/manager.go index 745768f..1fef3ff 100644 --- a/pkg/instances/manager.go +++ b/pkg/instances/manager.go @@ -63,6 +63,8 @@ type Manager interface { Start(ctx context.Context, id string) error // Stop stops a runtime instance by ID Stop(ctx context.Context, id string) error + // Terminal starts an interactive terminal session in a running instance + Terminal(ctx context.Context, id string, command []string) error // List returns all registered instances List() ([]Instance, error) // Get retrieves a specific instance by ID @@ -368,6 +370,57 @@ func (m *manager) Stop(ctx context.Context, id string) error { return m.saveInstances(instances) } +// Terminal starts an interactive terminal session in a running instance. +func (m *manager) Terminal(ctx context.Context, id string, command []string) error { + m.mu.RLock() + defer m.mu.RUnlock() + + instances, err := m.loadInstances() + if err != nil { + return err + } + + // Find the instance + var instanceToConnect Instance + found := false + for _, instance := range instances { + if instance.GetID() == id { + instanceToConnect = instance + found = true + break + } + } + + if !found { + return ErrInstanceNotFound + } + + runtimeData := instanceToConnect.GetRuntimeData() + if runtimeData.Type == "" || runtimeData.InstanceID == "" { + return errors.New("instance has no runtime configured") + } + + // Verify instance is running + if runtimeData.State != "running" { + return fmt.Errorf("instance is not running (current state: %s)", runtimeData.State) + } + + // Get the runtime + rt, err := m.runtimeRegistry.Get(runtimeData.Type) + if err != nil { + return fmt.Errorf("failed to get runtime: %w", err) + } + + // Type-assert to Terminal interface + terminalRT, ok := rt.(runtime.Terminal) + if !ok { + return fmt.Errorf("runtime %s does not support terminal sessions", runtimeData.Type) + } + + // Start terminal session + return terminalRT.Terminal(ctx, runtimeData.InstanceID, command) +} + // List returns all registered instances func (m *manager) List() ([]Instance, error) { m.mu.RLock() diff --git a/pkg/instances/manager_terminal_test.go b/pkg/instances/manager_terminal_test.go new file mode 100644 index 0000000..3b69e72 --- /dev/null +++ b/pkg/instances/manager_terminal_test.go @@ -0,0 +1,260 @@ +// Copyright 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. + +package instances + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/kortex-hub/kortex-cli/pkg/runtime" + "github.com/kortex-hub/kortex-cli/pkg/runtime/fake" +) + +// fakeTerminalRuntime is a fake runtime that implements runtime.Terminal +type fakeTerminalRuntime struct { + runtime.Runtime + terminalCalls []terminalCall + terminalErr error +} + +type terminalCall struct { + instanceID string + command []string +} + +// Compile-time check +var _ runtime.Terminal = (*fakeTerminalRuntime)(nil) + +func (f *fakeTerminalRuntime) Terminal(ctx context.Context, instanceID string, command []string) error { + f.terminalCalls = append(f.terminalCalls, terminalCall{ + instanceID: instanceID, + command: command, + }) + return f.terminalErr +} + +// newFakeTerminalRuntime creates a fake runtime that supports Terminal interface +func newFakeTerminalRuntime(terminalErr error) *fakeTerminalRuntime { + return &fakeTerminalRuntime{ + Runtime: fake.New(), + terminalCalls: make([]terminalCall, 0), + terminalErr: terminalErr, + } +} + +// newTestRegistryWithTerminal creates a registry with a terminal-supporting runtime +func newTestRegistryWithTerminal(tmpDir string, terminalErr error) runtime.Registry { + reg, _ := runtime.NewRegistry(filepath.Join(tmpDir, "runtimes")) + fakeRT := newFakeTerminalRuntime(terminalErr) + _ = reg.Register(fakeRT) + return reg +} + +func TestManager_Terminal(t *testing.T) { + t.Parallel() + + t.Run("connects to running instance successfully", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tmpDir := t.TempDir() + + // Create registry with terminal-supporting runtime + fakeRT := newFakeTerminalRuntime(nil) + reg, _ := runtime.NewRegistry(filepath.Join(tmpDir, "runtimes")) + _ = reg.Register(fakeRT) + + manager, _ := newManagerWithFactory(tmpDir, fakeInstanceFactory, newFakeGenerator(), reg, newFakeGitDetector()) + + instanceTmpDir := t.TempDir() + inst := newFakeInstance(newFakeInstanceParams{ + SourceDir: filepath.Join(instanceTmpDir, "source"), + ConfigDir: filepath.Join(instanceTmpDir, "config"), + Accessible: true, + }) + added, _ := manager.Add(ctx, AddOptions{Instance: inst, RuntimeType: "fake"}) + + // Start the instance + _ = manager.Start(ctx, added.GetID()) + + // Connect to terminal + command := []string{"bash"} + err := manager.Terminal(ctx, added.GetID(), command) + if err != nil { + t.Fatalf("Terminal() unexpected error = %v", err) + } + + // Verify Terminal was called on the runtime + if len(fakeRT.terminalCalls) != 1 { + t.Fatalf("Expected 1 Terminal call, got %d", len(fakeRT.terminalCalls)) + } + + call := fakeRT.terminalCalls[0] + if call.instanceID != added.GetRuntimeData().InstanceID { + t.Errorf("Terminal called with instanceID = %v, want %v", call.instanceID, added.GetRuntimeData().InstanceID) + } + + if len(call.command) != 1 || call.command[0] != "bash" { + t.Errorf("Terminal called with command = %v, want [bash]", call.command) + } + }) + + t.Run("connects with multiple command arguments", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tmpDir := t.TempDir() + + fakeRT := newFakeTerminalRuntime(nil) + reg, _ := runtime.NewRegistry(filepath.Join(tmpDir, "runtimes")) + _ = reg.Register(fakeRT) + + manager, _ := newManagerWithFactory(tmpDir, fakeInstanceFactory, newFakeGenerator(), reg, newFakeGitDetector()) + + instanceTmpDir := t.TempDir() + inst := newFakeInstance(newFakeInstanceParams{ + SourceDir: filepath.Join(instanceTmpDir, "source"), + ConfigDir: filepath.Join(instanceTmpDir, "config"), + Accessible: true, + }) + added, _ := manager.Add(ctx, AddOptions{Instance: inst, RuntimeType: "fake"}) + _ = manager.Start(ctx, added.GetID()) + + // Connect with command and arguments + command := []string{"claude-code", "--debug"} + err := manager.Terminal(ctx, added.GetID(), command) + if err != nil { + t.Fatalf("Terminal() unexpected error = %v", err) + } + + call := fakeRT.terminalCalls[0] + if len(call.command) != 2 || call.command[0] != "claude-code" || call.command[1] != "--debug" { + t.Errorf("Terminal called with command = %v, want [claude-code --debug]", call.command) + } + }) + + t.Run("returns error for nonexistent instance", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + manager, _ := newManagerWithFactory(tmpDir, fakeInstanceFactory, newFakeGenerator(), newTestRegistryWithTerminal(tmpDir, nil), newFakeGitDetector()) + + err := manager.Terminal(context.Background(), "nonexistent-id", []string{"bash"}) + if !errors.Is(err, ErrInstanceNotFound) { + t.Errorf("Terminal() error = %v, want %v", err, ErrInstanceNotFound) + } + }) + + t.Run("returns error for stopped instance", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tmpDir := t.TempDir() + + fakeRT := newFakeTerminalRuntime(nil) + reg, _ := runtime.NewRegistry(filepath.Join(tmpDir, "runtimes")) + _ = reg.Register(fakeRT) + + manager, _ := newManagerWithFactory(tmpDir, fakeInstanceFactory, newFakeGenerator(), reg, newFakeGitDetector()) + + instanceTmpDir := t.TempDir() + inst := newFakeInstance(newFakeInstanceParams{ + SourceDir: filepath.Join(instanceTmpDir, "source"), + ConfigDir: filepath.Join(instanceTmpDir, "config"), + Accessible: true, + }) + added, _ := manager.Add(ctx, AddOptions{Instance: inst, RuntimeType: "fake"}) + + // Instance is in "created" state (not running) + err := manager.Terminal(ctx, added.GetID(), []string{"bash"}) + if err == nil { + t.Fatal("Expected error for stopped instance") + } + + // Should contain message about state + if err.Error() == "" { + t.Error("Error message should not be empty") + } + }) + + t.Run("returns error when runtime doesn't support terminal", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tmpDir := t.TempDir() + + // Use regular fake runtime (doesn't implement Terminal) + reg, _ := runtime.NewRegistry(filepath.Join(tmpDir, "runtimes")) + regularFakeRT := fake.New() + _ = reg.Register(regularFakeRT) + + manager, _ := newManagerWithFactory(tmpDir, fakeInstanceFactory, newFakeGenerator(), reg, newFakeGitDetector()) + + instanceTmpDir := t.TempDir() + inst := newFakeInstance(newFakeInstanceParams{ + SourceDir: filepath.Join(instanceTmpDir, "source"), + ConfigDir: filepath.Join(instanceTmpDir, "config"), + Accessible: true, + }) + added, _ := manager.Add(ctx, AddOptions{Instance: inst, RuntimeType: "fake"}) + _ = manager.Start(ctx, added.GetID()) + + // Try to connect - should fail because runtime doesn't support Terminal + err := manager.Terminal(ctx, added.GetID(), []string{"bash"}) + if err == nil { + t.Fatal("Expected error when runtime doesn't support terminal") + } + + // Error message should mention terminal not supported + if err.Error() == "" { + t.Error("Error message should not be empty") + } + }) + + t.Run("propagates runtime terminal error", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tmpDir := t.TempDir() + + expectedErr := errors.New("terminal exec failed") + fakeRT := newFakeTerminalRuntime(expectedErr) + reg, _ := runtime.NewRegistry(filepath.Join(tmpDir, "runtimes")) + _ = reg.Register(fakeRT) + + manager, _ := newManagerWithFactory(tmpDir, fakeInstanceFactory, newFakeGenerator(), reg, newFakeGitDetector()) + + instanceTmpDir := t.TempDir() + inst := newFakeInstance(newFakeInstanceParams{ + SourceDir: filepath.Join(instanceTmpDir, "source"), + ConfigDir: filepath.Join(instanceTmpDir, "config"), + Accessible: true, + }) + added, _ := manager.Add(ctx, AddOptions{Instance: inst, RuntimeType: "fake"}) + _ = manager.Start(ctx, added.GetID()) + + // Terminal should propagate the runtime error + err := manager.Terminal(ctx, added.GetID(), []string{"bash"}) + if err == nil { + t.Fatal("Expected error to be propagated") + } + + if !errors.Is(err, expectedErr) { + t.Errorf("Expected error %v, got: %v", expectedErr, err) + } + }) +} diff --git a/pkg/runtime/podman/exec/exec.go b/pkg/runtime/podman/exec/exec.go index bec0325..cd0846f 100644 --- a/pkg/runtime/podman/exec/exec.go +++ b/pkg/runtime/podman/exec/exec.go @@ -17,6 +17,7 @@ package exec import ( "context" + "os" "os/exec" ) @@ -29,6 +30,11 @@ type Executor interface { // Output executes a podman command and returns its standard output. // Returns an error if the command fails. Output(ctx context.Context, args ...string) ([]byte, error) + + // RunInteractive executes a podman command with stdin/stdout/stderr connected to the terminal. + // This is used for interactive sessions where user input is required. + // Returns an error if the command fails. + RunInteractive(ctx context.Context, args ...string) error } // executor is the default implementation of Executor. @@ -53,3 +59,12 @@ func (e *executor) Output(ctx context.Context, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, "podman", args...) return cmd.Output() } + +// RunInteractive executes a podman command with stdin/stdout/stderr connected to the terminal. +func (e *executor) RunInteractive(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "podman", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/pkg/runtime/podman/exec/fake.go b/pkg/runtime/podman/exec/fake.go index 45bae44..07885d4 100644 --- a/pkg/runtime/podman/exec/fake.go +++ b/pkg/runtime/podman/exec/fake.go @@ -26,11 +26,17 @@ type FakeExecutor struct { // OutputFunc is called when Output is invoked. If nil, Output returns empty bytes. OutputFunc func(ctx context.Context, args ...string) ([]byte, error) + // RunInteractiveFunc is called when RunInteractive is invoked. If nil, RunInteractive returns nil. + RunInteractiveFunc func(ctx context.Context, args ...string) error + // RunCalls tracks all calls to Run with their arguments. RunCalls [][]string // OutputCalls tracks all calls to Output with their arguments. OutputCalls [][]string + + // RunInteractiveCalls tracks all calls to RunInteractive with their arguments. + RunInteractiveCalls [][]string } // Ensure FakeExecutor implements Executor at compile time. @@ -39,8 +45,9 @@ var _ Executor = (*FakeExecutor)(nil) // NewFake creates a new FakeExecutor. func NewFake() *FakeExecutor { return &FakeExecutor{ - RunCalls: make([][]string, 0), - OutputCalls: make([][]string, 0), + RunCalls: make([][]string, 0), + OutputCalls: make([][]string, 0), + RunInteractiveCalls: make([][]string, 0), } } @@ -62,6 +69,15 @@ func (f *FakeExecutor) Output(ctx context.Context, args ...string) ([]byte, erro return []byte{}, nil } +// RunInteractive executes the RunInteractiveFunc if set, otherwise returns nil. +func (f *FakeExecutor) RunInteractive(ctx context.Context, args ...string) error { + f.RunInteractiveCalls = append(f.RunInteractiveCalls, args) + if f.RunInteractiveFunc != nil { + return f.RunInteractiveFunc(ctx, args...) + } + return nil +} + // AssertRunCalledWith checks if Run was called with the expected arguments. func (f *FakeExecutor) AssertRunCalledWith(t interface { Errorf(format string, args ...interface{}) @@ -86,6 +102,18 @@ func (f *FakeExecutor) AssertOutputCalledWith(t interface { t.Errorf("Expected Output to be called with %v, but it was called with: %v", expectedArgs, f.OutputCalls) } +// AssertRunInteractiveCalledWith checks if RunInteractive was called with the expected arguments. +func (f *FakeExecutor) AssertRunInteractiveCalledWith(t interface { + Errorf(format string, args ...interface{}) +}, expectedArgs ...string) { + for _, call := range f.RunInteractiveCalls { + if argsEqual(call, expectedArgs) { + return + } + } + t.Errorf("Expected RunInteractive to be called with %v, but it was called with: %v", expectedArgs, f.RunInteractiveCalls) +} + // argsEqual compares two slices of strings for equality. func argsEqual(a, b []string) bool { if len(a) != len(b) { diff --git a/pkg/runtime/podman/terminal.go b/pkg/runtime/podman/terminal.go new file mode 100644 index 0000000..96b484f --- /dev/null +++ b/pkg/runtime/podman/terminal.go @@ -0,0 +1,41 @@ +// Copyright 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. + +package podman + +import ( + "context" + "fmt" + + "github.com/kortex-hub/kortex-cli/pkg/runtime" +) + +// Ensure podmanRuntime implements runtime.Terminal at compile time. +var _ runtime.Terminal = (*podmanRuntime)(nil) + +// Terminal starts an interactive terminal session inside a running instance. +func (p *podmanRuntime) Terminal(ctx context.Context, instanceID string, command []string) error { + if instanceID == "" { + return fmt.Errorf("%w: instance ID is required", runtime.ErrInvalidParams) + } + if len(command) == 0 { + return fmt.Errorf("%w: command is required", runtime.ErrInvalidParams) + } + + // Build podman exec -it + args := []string{"exec", "-it", instanceID} + args = append(args, command...) + + return p.executor.RunInteractive(ctx, args...) +} diff --git a/pkg/runtime/podman/terminal_test.go b/pkg/runtime/podman/terminal_test.go new file mode 100644 index 0000000..ec3d5c7 --- /dev/null +++ b/pkg/runtime/podman/terminal_test.go @@ -0,0 +1,134 @@ +// Copyright 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. + +package podman + +import ( + "context" + "errors" + "testing" + + "github.com/kortex-hub/kortex-cli/pkg/runtime" + "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/exec" +) + +func TestPodmanRuntime_Terminal(t *testing.T) { + t.Parallel() + + t.Run("executes podman exec -it with command", func(t *testing.T) { + t.Parallel() + + fakeExec := exec.NewFake() + rt := &podmanRuntime{ + executor: fakeExec, + } + + ctx := context.Background() + err := rt.Terminal(ctx, "container123", []string{"bash"}) + if err != nil { + t.Fatalf("Terminal() failed: %v", err) + } + + // Verify RunInteractive was called with correct arguments + expectedArgs := []string{"exec", "-it", "container123", "bash"} + fakeExec.AssertRunInteractiveCalledWith(t, expectedArgs...) + }) + + t.Run("executes with multiple command arguments", func(t *testing.T) { + t.Parallel() + + fakeExec := exec.NewFake() + rt := &podmanRuntime{ + executor: fakeExec, + } + + ctx := context.Background() + err := rt.Terminal(ctx, "container123", []string{"claude-code", "--debug"}) + if err != nil { + t.Fatalf("Terminal() failed: %v", err) + } + + // Verify RunInteractive was called with correct arguments + expectedArgs := []string{"exec", "-it", "container123", "claude-code", "--debug"} + fakeExec.AssertRunInteractiveCalledWith(t, expectedArgs...) + }) + + t.Run("returns error when instance ID is empty", func(t *testing.T) { + t.Parallel() + + fakeExec := exec.NewFake() + rt := &podmanRuntime{ + executor: fakeExec, + } + + ctx := context.Background() + err := rt.Terminal(ctx, "", []string{"bash"}) + if err == nil { + t.Fatal("Expected error for empty instance ID") + } + + if !errors.Is(err, runtime.ErrInvalidParams) { + t.Errorf("Expected ErrInvalidParams, got: %v", err) + } + }) + + t.Run("returns error when command is empty", func(t *testing.T) { + t.Parallel() + + fakeExec := exec.NewFake() + rt := &podmanRuntime{ + executor: fakeExec, + } + + ctx := context.Background() + err := rt.Terminal(ctx, "container123", []string{}) + if err == nil { + t.Fatal("Expected error for empty command") + } + + if !errors.Is(err, runtime.ErrInvalidParams) { + t.Errorf("Expected ErrInvalidParams, got: %v", err) + } + }) + + t.Run("propagates executor error", func(t *testing.T) { + t.Parallel() + + expectedErr := errors.New("exec failed") + fakeExec := exec.NewFake() + fakeExec.RunInteractiveFunc = func(ctx context.Context, args ...string) error { + return expectedErr + } + + rt := &podmanRuntime{ + executor: fakeExec, + } + + ctx := context.Background() + err := rt.Terminal(ctx, "container123", []string{"bash"}) + if err == nil { + t.Fatal("Expected error to be propagated") + } + + if !errors.Is(err, expectedErr) { + t.Errorf("Expected error %v, got: %v", expectedErr, err) + } + }) +} + +func TestPodmanRuntime_ImplementsTerminalInterface(t *testing.T) { + t.Parallel() + + var _ runtime.Terminal = (*podmanRuntime)(nil) +} diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index f98fe21..602ef01 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -72,3 +72,33 @@ type RuntimeInfo struct { // Examples: container_id, pid, created_at, network addresses. Info map[string]string } + +// Terminal is an optional interface for runtimes that support interactive terminal sessions. +// Runtimes implementing this interface enable the terminal command for connecting to running instances. +// +// When a runtime implements this interface, users can: +// 1. Connect to running instances with an interactive terminal +// 2. Execute commands directly inside the instance environment +// 3. Interact with agents or shells running in the instance +// +// Example implementation: +// +// type myRuntime struct { +// // ... other fields +// } +// +// func (r *myRuntime) Terminal(ctx context.Context, instanceID string, command []string) error { +// // Execute command interactively (stdin/stdout/stderr connected) +// return r.exec.RunInteractive(ctx, "exec", "-it", instanceID, command...) +// } +type Terminal interface { + // Terminal starts an interactive terminal session inside a running instance. + // The command is executed with stdin/stdout/stderr connected directly to the user's terminal. + // Returns an error if the instance is not running or command execution fails. + // + // Parameters: + // - ctx: Context for cancellation and timeout + // - instanceID: The runtime instance identifier + // - command: The command to execute (e.g., ["bash"], ["claude-code", "--debug"]) + Terminal(ctx context.Context, instanceID string, command []string) error +} From e5247470d671161e4c147d3a8831358447795196 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 26 Mar 2026 08:48:26 +0100 Subject: [PATCH 2/4] docs: README and AGENTS Signed-off-by: Philippe Martin Co-Authored-By: Claude Code (Claude Sonnet 4.5) --- AGENTS.md | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 97 +++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 85bda96..c85a761 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -221,6 +221,42 @@ if err := runtimesetup.RegisterAll(manager); err != nil { This automatically registers all runtimes from `pkg/runtimesetup/register.go` that report as available (e.g., only registers Podman if `podman` CLI is installed). +**Optional Runtime Interfaces:** + +Some runtimes may implement additional optional interfaces to provide extended functionality: + +**Terminal Interface** (`runtime.Terminal`): + +Runtimes implementing this interface enable interactive terminal sessions for connecting to running instances. This is used by the `terminal` command. + +```go +type Terminal interface { + // Terminal starts an interactive terminal session inside a running instance. + // The command is executed with stdin/stdout/stderr connected directly to the user's terminal. + Terminal(ctx context.Context, instanceID string, command []string) error +} +``` + +Example implementation (Podman runtime): +```go +func (p *podmanRuntime) Terminal(ctx context.Context, instanceID string, command []string) error { + if instanceID == "" { + return fmt.Errorf("%w: instance ID is required", runtime.ErrInvalidParams) + } + if len(command) == 0 { + return fmt.Errorf("%w: command is required", runtime.ErrInvalidParams) + } + + // Build podman exec -it + args := []string{"exec", "-it", instanceID} + args = append(args, command...) + + return p.executor.RunInteractive(ctx, args...) +} +``` + +The Terminal interface follows the same pattern as `StorageAware` - it's optional, and runtimes that don't support interactive sessions simply don't implement it. The instances manager checks for Terminal support at runtime using type assertion. + ### StepLogger System The StepLogger system provides user-facing progress feedback during runtime operations. It displays operational steps with spinners and completion messages in text mode, improving the user experience for long-running operations. @@ -954,6 +990,9 @@ kortex-cli example --flag value`, - Examples must use the actual binary name (`kortex-cli`) - All commands and flags in examples must exist - Keep examples concise and realistic +- Use `--` separator when passing flags to nested commands: `kortex-cli terminal ID -- bash -c 'echo hello'` + - The `--` tells the example validator to stop parsing flags and treat everything after as arguments + - This matches Cobra's behavior for passing arguments to nested commands **Validating Examples:** @@ -1266,6 +1305,91 @@ func outputErrorIfJSON(cmd interface{ OutOrStdout() io.Writer }, output string, **Reference:** See `pkg/cmd/init.go`, `pkg/cmd/workspace_remove.go`, and `pkg/cmd/workspace_list.go` for complete implementations. +### Interactive Commands (No JSON Output) + +Some commands are inherently interactive and do not support JSON output. These commands connect stdin/stdout/stderr directly to a user's terminal. + +**Example: Terminal Command** + +The `terminal` command provides an interactive session with a running workspace instance: + +```go +type workspaceTerminalCmd struct { + manager instances.Manager + id string + command []string +} + +func (w *workspaceTerminalCmd) preRun(cmd *cobra.Command, args []string) error { + w.id = args[0] + + // Extract command from args[1:] if provided + if len(args) > 1 { + w.command = args[1:] + } else { + // Default command (configurable from runtime) + w.command = []string{"claude"} + } + + // Standard setup: storage flag, manager, runtime registration + storageDir, _ := cmd.Flags().GetString("storage") + absStorageDir, _ := filepath.Abs(storageDir) + + manager, err := instances.NewManager(absStorageDir) + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + if err := runtimesetup.RegisterAll(manager); err != nil { + return fmt.Errorf("failed to register runtimes: %w", err) + } + + w.manager = manager + return nil +} + +func (w *workspaceTerminalCmd) run(cmd *cobra.Command, args []string) error { + // Connect to terminal - this is a blocking interactive call + err := w.manager.Terminal(cmd.Context(), w.id, w.command) + if err != nil { + if errors.Is(err, instances.ErrInstanceNotFound) { + return fmt.Errorf("workspace not found: %s\nUse 'workspace list' to see available workspaces", w.id) + } + return err + } + return nil +} +``` + +**Key differences from JSON-supporting commands:** +- **No `--output` flag** - Interactive commands don't need this +- **No JSON output helpers** - All output goes directly to terminal +- **Simpler error handling** - Just return errors normally (no `outputErrorIfJSON`) +- **Blocking execution** - The command runs until the user exits the interactive session +- **Command arguments** - Accept commands to run inside the instance: `terminal ID bash` or `terminal ID -- bash -c 'echo hello'` + +**Example command registration:** + +```go +func NewWorkspaceTerminalCmd() *cobra.Command { + c := &workspaceTerminalCmd{} + + cmd := &cobra.Command{ + Use: "terminal ID [COMMAND...]", + Short: "Connect to a running workspace with an interactive terminal", + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeRunningWorkspaceID, // Only show running workspaces + PreRunE: c.preRun, + RunE: c.run, + } + + // No flags needed - just uses global --storage flag + return cmd +} +``` + +**Reference:** See `pkg/cmd/workspace_terminal.go` for the complete implementation. + ### Testing Pattern for Commands Commands should have two types of tests following the pattern in `pkg/cmd/init_test.go`: @@ -1381,8 +1505,52 @@ err := manager.Delete(id) if err != nil { return fmt.Errorf("failed to delete instance: %w", err) } + +// Connect to a running instance with an interactive terminal +// Note: Instance must be running and runtime must implement Terminal interface +err := manager.Terminal(ctx, id, []string{"bash"}) +if err != nil { + return fmt.Errorf("failed to connect to terminal: %w", err) +} +``` + +**Terminal Method:** + +The `Terminal()` method enables interactive terminal sessions with running workspace instances: + +```go +func (m *manager) Terminal(ctx context.Context, id string, command []string) error +``` + +**Behavior:** +- Verifies the instance exists and is in a running state +- Checks if the runtime implements the `runtime.Terminal` interface +- Delegates to the runtime's Terminal implementation +- Returns an error if the instance is not running or runtime doesn't support terminals + +**Example usage in a command:** + +```go +func (w *workspaceTerminalCmd) run(cmd *cobra.Command, args []string) error { + // Start terminal session with the command extracted in preRun + err := w.manager.Terminal(cmd.Context(), w.id, w.command) + if err != nil { + if errors.Is(err, instances.ErrInstanceNotFound) { + return fmt.Errorf("workspace not found: %s\nUse 'workspace list' to see available workspaces", w.id) + } + return err + } + return nil +} ``` +**Key points:** +- Uses a read lock (doesn't modify instance state) +- Command is a slice of strings: `[]string{"bash"}` or `[]string{"claude-code", "--debug"}` +- Returns `ErrInstanceNotFound` if instance doesn't exist +- Returns an error if instance state is not "running" +- Returns an error if the runtime doesn't implement `runtime.Terminal` interface + ### Project Detection and Grouping Each workspace has a `project` field that enables grouping workspaces belonging to the same project across branches, forks, or subdirectories. diff --git a/README.md b/README.md index 9f5af1c..eaf0482 100644 --- a/README.md +++ b/README.md @@ -1487,6 +1487,103 @@ Output: - When using `--output json`, errors are also returned in JSON format for consistent parsing - **JSON error handling**: When `--output json` is used, errors are written to stdout (not stderr) in JSON format, and the CLI exits with code 1. Always check the exit code to determine success/failure +### `workspace terminal` - Connect to a Running Workspace + +Connects to a running workspace with an interactive terminal session. Also available as the shorter alias `terminal`. + +#### Usage + +```bash +kortex-cli workspace terminal ID [COMMAND...] [flags] +kortex-cli terminal ID [COMMAND...] [flags] +``` + +#### Arguments + +- `ID` - The unique identifier of the workspace to connect to (required) +- `COMMAND...` - Optional command to execute instead of the default agent command + +#### Flags + +- `--storage ` - Storage directory for kortex-cli data (default: `$HOME/.kortex-cli`) + +#### Examples + +**Connect using the default agent command:** +```bash +kortex-cli workspace terminal a1b2c3d4e5f6... +``` + +This starts an interactive session with the default agent (typically Claude Code) inside the running workspace container. + +**Use the short alias:** +```bash +kortex-cli terminal a1b2c3d4e5f6... +``` + +**Run a bash shell:** +```bash +kortex-cli terminal a1b2c3d4e5f6... bash +``` + +**Run a command with flags (use -- to prevent kortex-cli from parsing them):** +```bash +kortex-cli terminal a1b2c3d4e5f6... -- bash -c 'echo hello' +``` + +The `--` separator tells kortex-cli to stop parsing flags and pass everything after it directly to the container. This is useful when your command includes flags that would otherwise be interpreted by kortex-cli. + +**List workspaces and connect to a running one:** +```bash +# First, list all workspaces to find the ID +kortex-cli list + +# Start a workspace if it's not running +kortex-cli start a1b2c3d4e5f6... + +# Then connect with a terminal +kortex-cli terminal a1b2c3d4e5f6... +``` + +#### Error Handling + +**Workspace not found:** +```bash +kortex-cli terminal invalid-id +``` +Output: +```text +Error: workspace not found: invalid-id +Use 'workspace list' to see available workspaces +``` + +**Workspace not running:** +```bash +kortex-cli terminal a1b2c3d4e5f6... +``` +Output: +```text +Error: instance is not running (current state: created) +``` + +In this case, you need to start the workspace first: +```bash +kortex-cli start a1b2c3d4e5f6... +kortex-cli terminal a1b2c3d4e5f6... +``` + +#### Notes + +- The workspace must be in a **running state** before you can connect to it. Use `workspace start` to start a workspace first +- The workspace ID is required and can be obtained using the `workspace list` or `list` command +- By default, the command launches the agent configured in the runtime (typically Claude Code) +- You can override the default by providing a custom command (e.g., `bash`, `python`, or any executable available in the container) +- Use the `--` separator when your command includes flags to prevent kortex-cli from trying to parse them +- The terminal session is fully interactive with stdin/stdout/stderr connected to your terminal +- The command execution happens inside the workspace's container/runtime environment +- JSON output is **not supported** for this command as it's inherently interactive +- Runtime support: The terminal command requires the runtime to implement the Terminal interface. The Podman runtime supports this using `podman exec -it` + ### `workspace remove` - Remove a Workspace Removes a registered workspace by its ID. Also available as the shorter alias `remove`. From b0da660ea5454cc367bbdcf01d87a1a207318ca3 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Wed, 25 Mar 2026 07:45:31 +0100 Subject: [PATCH 3/4] docs: remove plan from sources Signed-off-by: Philippe Martin --- docs/plans/100.md | 289 ---------------------------------------------- 1 file changed, 289 deletions(-) delete mode 100644 docs/plans/100.md diff --git a/docs/plans/100.md b/docs/plans/100.md deleted file mode 100644 index 1b3b29b..0000000 --- a/docs/plans/100.md +++ /dev/null @@ -1,289 +0,0 @@ -# Implementation Plan: Terminal Command (Issue #100) - -## Context - -This feature enables users to connect to running workspace instances and launch interactive terminal sessions with AI agents (Claude Code, Goose, etc.). Currently, users can start/stop workspaces but cannot interact with the running agents inside them. The `terminal` command fills this gap by providing direct terminal access to running instances. - -**Problem:** No way to interact with running agents in workspace containers -**Solution:** Add `terminal` command with runtime-specific implementations (podman runtime uses `podman exec -it`) -**Benefit:** Users can work directly within their agent environments - -## Implementation Approach - -### 1. Extend Runtime System with Optional Terminal Interface - -**Add to `/workspace/sources/pkg/runtime/runtime.go`:** - -Add a new optional `Terminal` interface after the `StorageAware` interface definition: - -```go -// Terminal is an optional interface for runtimes that support interactive terminal sessions. -// Runtimes implementing this interface enable the terminal command for connecting to running instances. -type Terminal interface { - // Terminal starts an interactive terminal session inside a running instance. - // The command is executed with stdin/stdout/stderr connected directly to the user's terminal. - // Returns an error if the instance is not running or command execution fails. - Terminal(ctx context.Context, instanceID string, command []string) error -} -``` - -**Why optional?** Not all runtimes may support interactive terminals. Follows the existing `StorageAware` pattern. - -### 2. Extend Podman Executor for Interactive Execution - -**Update `/workspace/sources/pkg/runtime/podman/exec/exec.go`:** - -Add `RunInteractive` method to the `Executor` interface and implement it in the executor struct: - -```go -// In Executor interface: -RunInteractive(ctx context.Context, args ...string) error - -// In executor struct implementation: -func (e *executor) RunInteractive(ctx context.Context, args ...string) error { - cmd := exec.CommandContext(ctx, "podman", args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} -``` - -**Update fake executor** in `exec/fake.go` to track `RunInteractive` calls for testing. - -**Why separate method?** Existing `Run()` and `Output()` are for programmatic execution. Interactive sessions need direct terminal I/O connection. - -### 3. Implement Terminal Support in Podman Runtime - -**Create `/workspace/sources/pkg/runtime/podman/terminal.go`:** - -**Podman-specific implementation:** Uses `podman exec -it` to execute commands inside containers. - -```go -package podman - -import ( - "context" - "fmt" - "github.com/kortex-hub/kortex-cli/pkg/runtime" -) - -// Compile-time check -var _ runtime.Terminal = (*podmanRuntime)(nil) - -func (p *podmanRuntime) Terminal(ctx context.Context, instanceID string, command []string) error { - if instanceID == "" { - return fmt.Errorf("%w: instance ID is required", runtime.ErrInvalidParams) - } - if len(command) == 0 { - return fmt.Errorf("%w: command is required", runtime.ErrInvalidParams) - } - - // Podman-specific: Build podman exec -it - args := []string{"exec", "-it", instanceID} - args = append(args, command...) - - return p.executor.RunInteractive(ctx, args...) -} -``` - -### 4. Do not Implement Terminal Support in Fake Runtime - -### 5. Add Terminal Method to Instances Manager - -**Update `/workspace/sources/pkg/instances/manager.go`:** - -Add `Terminal` method to the `Manager` interface: - -```go -// Terminal starts an interactive terminal session in a running instance. -Terminal(ctx context.Context, id string, command []string) error -``` - -**Implement in manager struct:** - -```go -func (m *manager) Terminal(ctx context.Context, id string, command []string) error { - // Get instance by ID - // Verify instance is running (check RuntimeData.State == "running") - // Get runtime from registry by type - // Type-assert to runtime.Terminal interface - // Call runtime.Terminal(ctx, runtimeInstanceID, command) -} -``` - -**Key logic:** -- Uses read lock (doesn't modify state) -- Returns error if instance not found or not running -- Returns error if runtime doesn't implement Terminal interface -- Delegates to runtime's Terminal implementation - -### 6. Create Workspace Terminal Command - -**Create `/workspace/sources/pkg/cmd/workspace_terminal.go`:** - -Command struct: -```go -type workspaceTerminalCmd struct { - manager instances.Manager - id string - command []string // From args[1:] (arguments after ID) -} -``` - -**Command behavior:** -1. `preRun`: Create manager, register runtimes, extract ID from args[0] -2. `run`: - - Extract command from args[1:] (remaining arguments after ID) - - Determine command to execute: - - If args provided (len(args) > 1): use args[1:] as the command - - Otherwise load from podman runtime configuration (PR #94) - - Default to `["claude"]` if runtime doesn't specify - - Call `manager.Terminal(ctx, id, command)` - -**Command definition:** -- Use: `terminal ID [COMMAND...]` -- Args: `cobra.MinimumNArgs(1)` (ID required, command optional) -- ValidArgsFunction: `completeRunningWorkspaceID` (only running workspaces) -- ArgsLenAtDash: Cobra handles `--` separator automatically, stops parsing flags and treats rest as args -- Example: - ``` - # Connect using agent command from runtime configuration - kortex-cli workspace terminal abc123 - - # Run specific command - kortex-cli workspace terminal abc123 claude-code - - # Run command with arguments - kortex-cli workspace terminal abc123 claude-code --debug - - # Run shell command (use -- to prevent flag parsing) - kortex-cli workspace terminal abc123 -- bash -c "sleep 10000" - ``` - -**No JSON output support** - Terminal is inherently interactive, no `--output` flag. - -### 7. Create Terminal Alias Command - -**Create `/workspace/sources/pkg/cmd/terminal.go`:** - -Delegate to `NewWorkspaceTerminalCmd()` following the pattern in `start.go`, `stop.go`: - -```go -func NewTerminalCmd() *cobra.Command { - workspaceTerminalCmd := NewWorkspaceTerminalCmd() - cmd := &cobra.Command{ - Use: "terminal ID [COMMAND...]", - Short: workspaceTerminalCmd.Short, - Long: workspaceTerminalCmd.Long, - Example: AdaptExampleForAlias(workspaceTerminalCmd.Example, "workspace terminal", "terminal"), - Args: workspaceTerminalCmd.Args, - ValidArgsFunction: workspaceTerminalCmd.ValidArgsFunction, - PreRunE: workspaceTerminalCmd.PreRunE, - RunE: workspaceTerminalCmd.RunE, - } - // No flags to copy - terminal command uses arguments only - return cmd -} -``` - -### 8. Register Commands - -**Update `/workspace/sources/pkg/cmd/workspace.go`:** -```go -cmd.AddCommand(NewWorkspaceTerminalCmd()) -``` - -**Update `/workspace/sources/pkg/cmd/root.go`:** -```go -rootCmd.AddCommand(NewTerminalCmd()) -``` - -### 9. Agent Command Configuration - -The agent command is defined in the **podman runtime configuration** (being implemented in PR #94). - -**Command resolution logic in `run` method:** -1. If command args provided (len(args) > 1): use args[1:] directly -2. Otherwise: retrieve from podman runtime configuration (via PR #94 not merged yet - to be done later) -3. Fallback: use default `["claude"]` if runtime config doesn't specify - -**Integration with PR #94:** -- Podman runtime will have configuration for the agent command to execute -- The terminal command will query this configuration when no args are provided -- This keeps runtime-specific settings (like agent command) with the runtime, not the workspace - -## Critical Files to Create/Modify - -### New Files: -- `/workspace/sources/pkg/runtime/podman/terminal.go` - Podman Terminal implementation -- `/workspace/sources/pkg/cmd/workspace_terminal.go` - Workspace terminal command -- `/workspace/sources/pkg/cmd/terminal.go` - Terminal alias command -- `/workspace/sources/pkg/runtime/podman/terminal_test.go` - Podman terminal tests -- `/workspace/sources/pkg/instances/manager_terminal_test.go` - Manager terminal tests -- `/workspace/sources/pkg/cmd/workspace_terminal_test.go` - Command tests -- `/workspace/sources/pkg/cmd/terminal_test.go` - Alias command tests - -### Modified Files: -- `/workspace/sources/pkg/runtime/runtime.go` - Add Terminal interface -- `/workspace/sources/pkg/runtime/podman/exec/exec.go` - Add RunInteractive method -- `/workspace/sources/pkg/runtime/podman/exec/fake.go` - Add RunInteractive to fake -- `/workspace/sources/pkg/instances/manager.go` - Add Terminal method to interface and implementation -- `/workspace/sources/pkg/cmd/workspace.go` - Register workspace terminal subcommand -- `/workspace/sources/pkg/cmd/root.go` - Register terminal alias command - -## Testing Strategy - -### Unit Tests: -1. **Executor tests** - Test RunInteractive method construction (fake executor) -2. **Runtime tests** - Test Terminal implementation with fake executor -3. **Manager tests** - Test Terminal method with fake runtime -4. **Command preRun tests** - Test validation and initialization - -### Integration Tests: -1. **E2E command tests** - Execute full command with fake runtime -2. **Agent command precedence** - Test args override vs runtime config vs default -3. **Argument handling**: - - Test with no command args (uses runtime config or default) - - Test with command args (overrides runtime config) - - Test with `--` separator for commands with flags -4. **Error cases**: - - Instance not found - - Instance not running (state != "running") - - Runtime doesn't support terminal - -### Manual Testing: -1. Create a workspace with `kortex-cli init` -2. Start the workspace with `kortex-cli start ` -3. Connect with `kortex-cli terminal ` (uses runtime config or default) -4. Verify Claude Code launches interactively -5. Test with custom command: `kortex-cli terminal bash` -6. Test with command arguments: `kortex-cli terminal claude-code --debug` -7. Test with -- separator: `kortex-cli terminal -- bash -c "echo test"` -8. Verify stdin/stdout/stderr work correctly (type commands, see output) - -## Verification - -After implementation, verify: - -1. **Build succeeds**: `make build` -2. **Tests pass**: `make test` -3. **Command appears in help**: `./kortex-cli --help` shows `terminal` command -4. **Workspace subcommand exists**: `./kortex-cli workspace --help` shows `terminal` -5. **Tab completion works**: Complete workspace IDs (running only) -6. **Manual test**: Create workspace, start it, connect with terminal -7. **Error handling**: Try terminal on stopped workspace (should error) - -## Implementation Notes - -- Terminal is an **optional** interface - runtimes that don't support it simply won't implement it -- The command only works on **running** instances (verified in manager) -- **Runtime-specific implementation:** Podman runtime uses `podman exec -it`, other runtimes would use their own mechanisms -- Agent command is configured in **podman runtime configuration** (PR #94) not workspace.json -- Command resolution: args override → runtime config → default `["claude"]` -- Command arguments after ID are passed directly to the runtime instance (e.g., `terminal ID bash -l`) -- Use `--` separator to prevent Cobra from parsing flags: `terminal ID -- bash -c "cmd"` -- No flags needed - command is specified via arguments, making the interface simpler -- No JSON output mode - terminal is inherently interactive -- Follows existing command patterns (struct + preRun + run, but no flag binding needed) -- Uses `completeRunningWorkspaceID` for tab completion (only running workspaces make sense) From 4cfb8eaed033e15a62bbc44b79379a46bf5f4fb1 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 26 Mar 2026 11:08:28 +0100 Subject: [PATCH 4/4] test: fix tests Signed-off-by: Philippe Martin Co-Authored-By: Claude Code (Claude Sonnet 4.5) --- pkg/cmd/workspace_terminal_test.go | 21 --------------------- pkg/runtime/podman/create_test.go | 4 ++++ 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/pkg/cmd/workspace_terminal_test.go b/pkg/cmd/workspace_terminal_test.go index 095f7aa..601e653 100644 --- a/pkg/cmd/workspace_terminal_test.go +++ b/pkg/cmd/workspace_terminal_test.go @@ -105,27 +105,6 @@ func TestWorkspaceTerminalCmd_PreRun(t *testing.T) { t.Errorf("Expected command ['bash', '-l'], got %v", c.command) } }) - - t.Run("creates absolute storage path", func(t *testing.T) { - t.Parallel() - - storageDir := "relative/path" - - c := &workspaceTerminalCmd{} - cmd := &cobra.Command{} - cmd.Flags().String("storage", storageDir, "test storage flag") - - args := []string{"test-id"} - - err := c.preRun(cmd, args) - if err != nil { - t.Fatalf("preRun() failed: %v", err) - } - - if c.manager == nil { - t.Error("Expected manager to be created") - } - }) } func TestWorkspaceTerminalCmd_Examples(t *testing.T) { diff --git a/pkg/runtime/podman/create_test.go b/pkg/runtime/podman/create_test.go index 5fb092a..bfdfa3d 100644 --- a/pkg/runtime/podman/create_test.go +++ b/pkg/runtime/podman/create_test.go @@ -1068,6 +1068,10 @@ func (f *fakeExecutor) Output(ctx context.Context, args ...string) ([]byte, erro return f.output, nil } +func (f *fakeExecutor) RunInteractive(ctx context.Context, args ...string) error { + return f.runErr +} + // assertDirectoryRemoved checks that a directory has been removed. // On Windows, file locks may delay cleanup, so this retries with a timeout. func assertDirectoryRemoved(t *testing.T, dir string) {