Support interactive fzf selection for kubectx -s with no arguments

When `kubectx -s` (or `--shell`) is invoked without a context name and
fzf is available in an interactive terminal, launch fzf to let the user
pick a context, then start an isolated shell scoped to that selection.

This mirrors the existing behavior where `kubectx` with no arguments
launches fzf for context switching, and `kubectx -d` with no arguments
launches fzf for context deletion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmet Alp Balkan
2026-03-23 16:28:25 -07:00
parent ccddf675d5
commit 2cb7500e34
4 changed files with 66 additions and 5 deletions

View File

@@ -41,10 +41,16 @@ func parseArgs(argv []string) Op {
}
if argv[0] == "--shell" || argv[0] == "-s" {
if len(argv) != 2 {
return UnsupportedOp{Err: fmt.Errorf("'%s' requires exactly one context name argument", argv[0])}
if len(argv) == 1 {
if cmdutil.IsInteractiveMode(os.Stdout) {
return InteractiveShellOp{SelfCmd: os.Args[0]}
}
return UnsupportedOp{Err: fmt.Errorf("'%s' requires a context name argument (or fzf for interactive mode)", argv[0])}
}
return ShellOp{Target: argv[1]}
if len(argv) == 2 {
return ShellOp{Target: argv[1]}
}
return UnsupportedOp{Err: fmt.Errorf("'%s' accepts at most one context name argument", argv[0])}
}
if argv[0] == "-d" {

View File

@@ -80,10 +80,10 @@ func Test_parseArgs_new(t *testing.T) {
want: ShellOp{Target: "prod"}},
{name: "shell without context name",
args: []string{"-s"},
want: UnsupportedOp{Err: fmt.Errorf("'-s' requires exactly one context name argument")}},
want: UnsupportedOp{Err: fmt.Errorf("'-s' requires a context name argument (or fzf for interactive mode)")}},
{name: "shell with too many args",
args: []string{"--shell", "a", "b"},
want: UnsupportedOp{Err: fmt.Errorf("'--shell' requires exactly one context name argument")}},
want: UnsupportedOp{Err: fmt.Errorf("'--shell' accepts at most one context name argument")}},
{name: "unrecognized flag",
args: []string{"-x"},
want: UnsupportedOp{Err: fmt.Errorf("unsupported option '-x'")}},

View File

@@ -42,6 +42,7 @@ func printUsage(out io.Writer) error {
%SPAC% (this command won't delete the user/cluster entry
%SPAC% referenced by the context entry)
%PROG% -s, --shell <NAME> : start a shell scoped to context <NAME>
%PROG% -s, --shell : interactively select a context to start a shell
%PROG% -h,--help : show this message
%PROG% -V,--version : show version`
help = strings.ReplaceAll(help, "%PROG%", selfName())

View File

@@ -1,24 +1,78 @@
package main
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"github.com/fatih/color"
"github.com/ahmetb/kubectx/internal/cmdutil"
"github.com/ahmetb/kubectx/internal/env"
"github.com/ahmetb/kubectx/internal/kubeconfig"
"github.com/ahmetb/kubectx/internal/printer"
)
// InteractiveShellOp launches fzf to pick a context, then starts an isolated shell.
type InteractiveShellOp struct {
SelfCmd string
}
// ShellOp indicates intention to start a scoped sub-shell for a context.
type ShellOp struct {
Target string
}
func (op InteractiveShellOp) Run(_, stderr io.Writer) error {
if err := checkIsolatedMode(); err != nil {
return err
}
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
defer kc.Close()
if err := kc.Parse(); err != nil {
if cmdutil.IsNotFoundErr(err) {
printer.Warning(stderr, "kubeconfig file not found")
return nil
}
return fmt.Errorf("kubeconfig error: %w", err)
}
ctxNames, err := kc.ContextNames()
if err != nil {
return fmt.Errorf("failed to get context names: %w", err)
}
if len(ctxNames) == 0 {
return errors.New("no contexts found in the kubeconfig file")
}
cmd := exec.Command("fzf", "--ansi", "--no-preview")
var out bytes.Buffer
cmd.Stdin = os.Stdin
cmd.Stderr = stderr
cmd.Stdout = &out
cmd.Env = append(os.Environ(),
fmt.Sprintf("FZF_DEFAULT_COMMAND=%s", op.SelfCmd),
fmt.Sprintf("%s=1", env.EnvForceColor))
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
return err
}
}
choice := strings.TrimSpace(out.String())
if choice == "" {
return errors.New("you did not choose any of the options")
}
return ShellOp{Target: choice}.Run(nil, stderr)
}
func (op ShellOp) Run(_, stderr io.Writer) error {
if err := checkIsolatedMode(); err != nil {
return err