An interactive shell for any Cobra CLI — with tab completion and persistent history — requiring zero changes to the target binary.
$ cobra-shell --binary kubectl --prompt "k8s"
╭─ k8s
╰─❯ get po[TAB]
pods poddisruptionbudgets podtemplates
╭─ k8s
╰─❯ get pods -n kube-system
NAME READY STATUS RESTARTS AGE
coredns-5d78c9869d-p9f2k 1/1 Running 0 3d
...
╭─ k8s
╰─❯ ▌
Every Cobra binary (≥ v1.2) automatically exposes a hidden __completeNoDesc command. cobra-shell calls it on every Tab press to get context-aware completions — subcommands, flags, and dynamic values — without knowing anything about the binary's internals. Command execution spawns the binary as a subprocess with stdin/stdout/stderr inherited from the terminal.
go get github.com/pable/cobra-shellgo install github.com/pable/cobra-shell/cmd/cobra-shell@latestWrap any Cobra binary by name or path:
cobra-shell --binary kubectl --prompt "k8s"
cobra-shell --binary gh --prompt "gh"
cobra-shell --binary ./myappAll flags:
cobra-shell --binary kubectl \
--prompt "k8s" \
--history ~/.kubectl_shell_history \
--timeout 2sSession transcript:
$ cobra-shell --binary gh --prompt "gh"
╭─ gh
╰─❯ pr li[TAB]
list
╭─ gh
╰─❯ pr list --repo cli/cli
#1234 Fix tab completion feature about 2 hours ago
╭─ gh
╰─❯ repo clone[TAB]
clone
╭─ gh
╰─❯ repo clone cli/cli
Cloning into 'cli'...
╭─ gh
╰─❯ exit
$
Use New to wrap any binary and call Run to start the shell loop. Run
blocks until the user exits and returns nil on a clean exit.
import (
"log"
cobrashell "github.com/pable/cobra-shell"
)
func main() {
sh := cobrashell.New(cobrashell.Config{
BinaryPath: "/usr/local/bin/kubectl",
Prompt: "k8s> ",
})
if err := sh.Run(); err != nil {
log.Fatal(err)
}
}Session transcript:
k8s> get [TAB]
configmaps deployments ingresses namespaces nodes pods services ...
k8s> get pods --namespace [TAB]
default kube-system monitoring
k8s> get pods --namespace kube-system
NAME READY STATUS RESTARTS AGE
coredns-5d78c9869d-p9f2k 1/1 Running 0 3d
k8s> exit
Add a shell subcommand to your own Cobra CLI so users can enter an
interactive session with myapp shell:
import (
"os"
cobrashell "github.com/pable/cobra-shell"
)
func init() {
rootCmd.AddCommand(cobrashell.Command(cobrashell.Config{
BinaryPath: os.Args[0], // wrap the binary itself
Prompt: "myapp> ",
}))
}Session transcript:
$ myapp shell
myapp> serve --po[TAB]
--port
myapp> serve --port 9090
Listening on :9090
^C
myapp> version
v1.4.2
myapp> exit
$
All four hooks are optional; nil values are silently skipped.
import (
"fmt"
"log"
"os"
"path/filepath"
cobrashell "github.com/pable/cobra-shell"
)
func main() {
sh := cobrashell.New(cobrashell.Config{
BinaryPath: "/usr/local/bin/myapp",
Prompt: "myapp> ",
HistoryFile: filepath.Join(os.Getenv("HOME"), ".myapp_history"),
Hooks: cobrashell.Hooks{
// OnStart runs once before the first prompt.
OnStart: func(sh *cobrashell.Shell) {
fmt.Println("Welcome! Type 'exit' or Ctrl-D to quit.")
},
// BeforeExec runs before each command. Return a non-nil error
// to cancel execution and print the message to stderr.
BeforeExec: func(args []string) error {
if !auth.TokenValid() {
return fmt.Errorf("auth token expired — run 'login' first")
}
return nil
},
// AfterExec runs after each command with its exit code.
AfterExec: func(args []string, code int) {
if code != 0 {
fmt.Fprintf(os.Stderr, "[exit %d]\n", code)
}
},
// OnExit runs once on a clean exit (Ctrl-D or "exit").
OnExit: func() {
fmt.Println("Goodbye!")
},
},
})
if err := sh.Run(); err != nil {
log.Fatal(err)
}
}Session transcript:
Welcome! Type 'exit' or Ctrl-D to quit.
myapp> deploy production
Deploying to production...done.
myapp> deploy staging
Error: permission denied
[exit 1]
myapp> refresh-token # token expired mid-session
auth token expired — run 'login' first
myapp> login
Logged in.
myapp> exit
Goodbye!
Use NewEmbedded when commands need to share in-process state (database
handles, caches, auth tokens) across invocations. Commands run via
cobra.Command.Execute in the same process; completion walks the command tree
directly rather than spawning a subprocess.
import (
"fmt"
"log"
cobrashell "github.com/pable/cobra-shell"
)
func main() {
// rootCmd is your existing *cobra.Command tree.
sh := cobrashell.NewEmbedded(cobrashell.EmbeddedConfig{
RootCmd: rootCmd,
Prompt: "myapp> ",
// DynamicCompletions adds live candidates sourced from in-process
// state. Keyed by the command name returned by cmd.Name().
DynamicCompletions: map[string]cobrashell.CompletionFunc{
"show": func(args []string, toComplete string) []string {
// db is accessible because we're in the same process.
return db.ListIDs(toComplete)
},
"delete": func(args []string, toComplete string) []string {
return db.ListIDs(toComplete)
},
},
Hooks: cobrashell.EmbeddedHooks{
OnStart: func(sh *cobrashell.EmbeddedShell) {
fmt.Printf("Connected to %s. Type 'exit' to quit.\n", db.Name())
},
AfterExec: func(args []string, code int) {
if code != 0 {
fmt.Printf("[exit %d]\n", code)
}
},
OnExit: func() {
db.Close()
},
},
})
if err := sh.Run(); err != nil {
log.Fatal(err)
}
}Session transcript:
Connected to mydb. Type 'exit' to quit.
myapp> show [TAB]
user:42 user:99 order:7
myapp> show user:42
id: 42
name: Alice
email: alice@example.com
myapp> delete [TAB]
user:42 user:99 order:7
myapp> delete user:99
Deleted.
myapp> serve --po[TAB]
--port
myapp> serve --port 9090
Listening on :9090
^C
myapp> exit
Completion sources (in priority order): static subcommand names →
DynamicCompletions → ValidArgsFunction on the matched command → flag
names.
Flag state is reset to defaults between commands so that flags from one run do not bleed into the next.
Many CLI tools use environment variables for credentials or configuration. cobra-shell lets you manage these at the prompt without restarting the shell.
Pass --env-builtin with the name to use for the built-in command:
cobra-shell --binary heroku --prompt "heroku" --env-builtin env╭─ heroku
╰─❯ env set HEROKU_API_TOKEN secret123
╭─ heroku
╰─❯ env set HEROKU_APP myapp-staging
╭─ heroku
╰─❯ env list
HEROKU_API_TOKEN=secret123
HEROKU_APP=myapp-staging
╭─ heroku
╰─❯ env uns[TAB]
unset
╭─ heroku
╰─❯ env unset HEROKU_[TAB]
HEROKU_API_TOKEN HEROKU_APP
╭─ heroku
╰─❯ env unset HEROKU_APP
╭─ heroku
╰─❯ apps # HEROKU_API_TOKEN still injected into subprocess env
=== My Apps
myapp-production
Enable the built-in by setting Config.EnvBuiltin:
sh := cobrashell.New(cobrashell.Config{
BinaryPath: "/usr/local/bin/heroku",
Prompt: "heroku> ",
EnvBuiltin: "env",
})Session transcript:
heroku> env set HEROKU_API_TOKEN secret123
heroku> env set HEROKU_APP myapp-staging
heroku> env list
HEROKU_API_TOKEN=secret123
HEROKU_APP=myapp-staging
heroku> env uns[TAB]
unset
heroku> env unset HEROKU_[TAB]
HEROKU_API_TOKEN HEROKU_APP
heroku> env unset HEROKU_APP
heroku> heroku apps # HEROKU_API_TOKEN injected into subprocess env
=== My Apps
myapp-staging
myapp-production
Session variables are merged into the subprocess environment at spawn time,
with precedence over os.Environ() and Config.Env. os.Setenv is never
called — the current process is unaffected.
You can also manage session env programmatically (e.g. from an OnStart hook):
Hooks: cobrashell.Hooks{
OnStart: func(sh *cobrashell.Shell) {
sh.SetEnv("KUBECONFIG", "/etc/k8s/admin.conf")
sh.SetEnv("NAMESPACE", "production")
},
},And inspect or clear it at runtime:
sh.SetEnv("KEY", "value") // add or overwrite
sh.UnsetEnv("KEY") // remove
pairs := sh.SessionEnv() // sorted ["KEY=VALUE", ...] snapshotNote: Session env is a subprocess-mode feature. Embedded mode does not expose it because in-process commands share the same OS environment.
| Field | Type | Default | Description |
|---|---|---|---|
BinaryPath |
string |
(required) | Path or bare name of the binary to wrap. Resolved to an absolute path by New. |
Prompt |
string |
"> " |
Prompt string displayed before each input line. |
PrePrompt |
string |
"" |
When non-empty, printed to stdout before each readline prompt. Use for a context line above the input line (e.g. "╭─ k8s\n"). Should end with "\n". |
HistoryFile |
string |
~/.<binary>_history |
File for persistent command history. Empty string disables persistence. |
Env |
[]string |
nil |
Static extra environment variables ("KEY=VALUE"), additive to the current environment. Applied before session env. |
CompletionTimeout |
time.Duration |
500ms |
Maximum time to wait for __completeNoDesc. Increase for network-backed binaries. |
EnvBuiltin |
string |
"" |
When non-empty, enables the built-in env management command with this name. |
DynamicPrompt |
func(int) string |
nil |
When set, called after each command with its exit code to produce the next prompt. Overrides Prompt. Use Colorize for ANSI colors. |
Hooks |
Hooks |
— | Lifecycle callbacks; all fields optional. |
| Key | Action |
|---|---|
| Tab | Complete current word |
| Ctrl-C | Cancel current command (child receives SIGINT); shell continues |
| Ctrl-D | Exit the shell |
| ↑ / ↓ | Navigate history |
| Ctrl-R | Reverse history search |
Not all Cobra binaries register dynamic completions. The shell degrades gracefully:
| Binary capability | Shell behaviour |
|---|---|
Full __completeNoDesc (Cobra ≥ 1.2) |
Full dynamic completion |
| Partial (subcommands and flag names only) | Subcommand + flag name completion |
No __completeNoDesc (old or non-Cobra) |
Subcommand and flag name completion via --help parsing (flag values not completed) |
No --help output parseable |
History only |
Use PrePrompt for a static top line and DynamicPrompt for a colored
indicator that reflects the last exit code. The helper Colorize wraps text
with ANSI codes safely for readline (cursor positioning stays correct):
sh := cobrashell.New(cobrashell.Config{
BinaryPath: "/usr/local/bin/kubectl",
PrePrompt: "╭─ k8s\n",
DynamicPrompt: func(code int) string {
c := cobrashell.ColorGreen
if code != 0 {
c = cobrashell.ColorRed
}
return "╰─" + cobrashell.Colorize("❯", c) + " "
},
})Session transcript (colors shown as text here):
╭─ k8s
[green]╰─❯[/green] get pods
NAME READY STATUS RESTARTS
coredns-5d78c9869d 1/1 Running 0
╭─ k8s
[green]╰─❯[/green] delete pod nonexistent
Error from server (NotFound): pod "nonexistent" not found
╭─ k8s
[red]╰─❯[/red] get pods
Available color constants: ColorRed, ColorGreen, ColorYellow,
ColorBlue, ColorMagenta, ColorCyan, ColorBold, ColorReset.
cobra-shell also colors its own error messages (parse errors, BeforeExec rejections, spawn failures) in red when stderr is a terminal.
cobra-shell allocates a PTY for each subprocess, so binaries see a real TTY
and enable color automatically. No FORCE_COLOR override is needed for most
tools.
If a binary checks a non-standard variable, you can still pass it via
Config.Env:
Env: []string{"KUBECOLOR_FORCE_COLORS=true"},- Unix only. PTY allocation,
chzyer/readline, and Unix signal semantics are not portable to Windows. - Pipes require spaces.
cmd | grep fooworks;cmd|grep(no surrounding spaces) is treated as a literal argument. - Env built-in + pipe.
env list | grep FOO— the env built-in is handled in-process before the pipe is evaluated, so grep never runs. Useenv listseparately. - No aliasing or multi-line input.
See DESIGN.md for the full design rationale and adr/ for architectural decision records.
Tab press → Completer.Do()
→ shlex.Split(line[:cursor])
→ binary __completeNoDesc contextArgs... toComplete [plain subprocess, no PTY]
→ parseCompletions() → candidates → readline
Enter → Shell.execute()
→ shlex.Split(line)
→ handleEnvBuiltin() (if EnvBuiltin configured)
→ hasPipe() → executePipeline() → sh -c '<binary>' <line>
→ BeforeExec hook
→ spawnCommand(binary, tokens, env)
├── stdin is TTY → pty.Start() → runWithPTY [MakeRaw + bidirectional copy]
└── stdin not TTY / PTY fail → runPlain [SIGINT suppressed in parent]
→ AfterExec hook