From 88aee6c0ef403ea414c9e25e8ff8118fe3c263e8 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 20:55:39 -0300 Subject: [PATCH] feat(cli): shell-init eval hook, prompt segment & completions (spec 30 phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "execute, don't print" shell integration on top of the active-context model (phase 1). - `devstack shell-init zsh|bash|fish` emits: the install dir on PATH, a `devstack` wrapper that eval's `use`'s output (so `use` cd's + sets DEVSTACK_* in the LIVE shell), completion loading, and a `devstack_prompt` helper. The wrapper is named after the invoked binary, so aliases (rq) get an rq() wrapper. - `use --print --shell ` emits POSIX or fish syntax; bare `use --print` routes its hint to stderr so stdout stays an eval-safe (empty) script. - `context --prompt` is a cheap prompt segment (config + DEVSTACK_PROJECT only — no Docker/ledger): prints `workspace` or `workspace:project`. - install.sh detects the shell and prints the exact eval line (never edits rc). Verified end-to-end: `eval "$(devstack shell-init bash)"; devstack use web` sets DEVSTACK_PROJECT and the prompt segment shows smoke:web. make ci + determinism green. Co-Authored-By: Claude Opus 4.8 (1M context) --- install.sh | 13 +++++ internal/cli/context.go | 67 ++++++++++++++++++++---- internal/cli/root.go | 1 + internal/cli/shell_init.go | 92 +++++++++++++++++++++++++++++++++ internal/cli/shell_init_test.go | 88 +++++++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 internal/cli/shell_init.go create mode 100644 internal/cli/shell_init_test.go diff --git a/install.sh b/install.sh index a2ffeca..8fcc544 100755 --- a/install.sh +++ b/install.sh @@ -133,5 +133,18 @@ case ":${PATH}:" in printf " export PATH=\"%s:\$PATH\"\n" "$install_dir" >&2 ;; esac +# --- shell-integration hint ------------------------------------------------ +# The eval hook puts the install dir on PATH, loads completions, and (the point) +# lets `${BINARY} use` switch your shell's workspace/project. Opt-in — we only +# print the line for the detected shell; we never edit your rc. The \$(...) is an +# escaped literal for the user to copy. +_ds_shell="$(basename "${SHELL:-sh}")" +case "$_ds_shell" in + zsh) info "shell integration — add to ~/.zshrc: eval \"\$(${BINARY} shell-init zsh)\"" ;; + bash) info "shell integration — add to ~/.bashrc: eval \"\$(${BINARY} shell-init bash)\"" ;; + fish) info "shell integration — add to ~/.config/fish/config.fish: ${BINARY} shell-init fish | source" ;; + *) info "shell integration (zsh/bash/fish): eval \"\$(${BINARY} shell-init )\" — enables '${BINARY} use' to switch your shell" ;; +esac + printf '\n%s%s installed.%s run %s%s doctor%s to verify your environment.\n' \ "$GREEN" "$BINARY" "$RESET" "$BOLD" "$BINARY" "$RESET" diff --git a/internal/cli/context.go b/internal/cli/context.go index 686368e..fae8668 100644 --- a/internal/cli/context.go +++ b/internal/cli/context.go @@ -2,11 +2,14 @@ package cli import ( "fmt" + "io" + "os" "strings" "text/tabwriter" "github.com/spf13/cobra" + "github.com/open-source-cloud/devstack/internal/config" "github.com/open-source-cloud/devstack/internal/lock" "github.com/open-source-cloud/devstack/internal/version" "github.com/open-source-cloud/devstack/internal/workspace" @@ -76,14 +79,40 @@ func renderContextHeader(cmd *cobra.Command, mgr *workspace.Manager, g *GlobalOp fmt.Fprintf(cmd.OutOrStdout(), "devstack · %s\n\n", strings.Join(parts, " · ")) } +// renderPromptSegment prints a terse "workspace" or "workspace:project" segment +// for a shell prompt (spec 30). It is deliberately cheap: it discovers the +// workspace from config only (no Docker client, no ledger) and reads the project +// from DEVSTACK_PROJECT (set by the `use` shell hook). Outside a workspace it +// prints nothing, so the prompt segment simply disappears. +func renderPromptSegment(cmd *cobra.Command) error { + cwd, err := os.Getwd() + if err != nil { + return nil + } + m, err := config.Load(cwd) + if err != nil { + return nil + } + seg := m.Workspace.Name + if p := os.Getenv("DEVSTACK_PROJECT"); p != "" { + seg += ":" + p + } + fmt.Fprintln(cmd.OutOrStdout(), seg) + return nil +} + // newContextCmd wires the read-only `context` command: print the resolved active // workspace/project/role/docker-context/version. Lock-free. func newContextCmd(g *GlobalOpts) *cobra.Command { - return &cobra.Command{ + var promptMode bool + cmd := &cobra.Command{ Use: "context", Short: "Show the active workspace, project, role and Docker context", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + if promptMode { + return renderPromptSegment(cmd) + } mgr, closeFn, err := buildManager(cmd) if err != nil { return err @@ -110,6 +139,8 @@ func newContextCmd(g *GlobalOpts) *cobra.Command { return tw.Flush() }, } + cmd.Flags().BoolVar(&promptMode, "prompt", false, "terse single-line output for a shell prompt segment (cheap; no Docker/ledger)") + return cmd } // newUseCmd wires `use [name]`: set the active project (or switch to a registered @@ -119,6 +150,7 @@ func newContextCmd(g *GlobalOpts) *cobra.Command { func newUseCmd(g *GlobalOpts) *cobra.Command { var project string var printScript bool + var shell string cmd := &cobra.Command{ Use: "use [name]", Short: "Set the active project (or switch workspace); persists across terminals", @@ -162,9 +194,13 @@ func newUseCmd(g *GlobalOpts) *cobra.Command { targetRoot = root } default: - // Bare `use`: report current context + candidates (the fuzzy picker - // TUI lands in the shell-integration phase). - return printUseHint(cmd, mgr, projects) + // Bare `use`: report current context + candidates. Under --print + // the hint goes to stderr so stdout stays an eval-safe (empty) script. + out := cmd.OutOrStdout() + if printScript { + out = cmd.ErrOrStderr() + } + return printUseHint(out, mgr, projects) } if err := lock.WithLock(cmd.Context(), mgr.LockPath, func() error { @@ -174,7 +210,7 @@ func newUseCmd(g *GlobalOpts) *cobra.Command { } if printScript { - emitUseScript(cmd, targetRoot, targetProject) + emitUseScript(cmd, targetRoot, targetProject, shell) return nil } if g.JSON { @@ -194,6 +230,8 @@ func newUseCmd(g *GlobalOpts) *cobra.Command { } cmd.Flags().StringVar(&project, "project", "", "force-select a project in the current workspace") cmd.Flags().BoolVar(&printScript, "print", false, "emit an eval-able shell script (cd + export) instead of persisting only") + cmd.Flags().StringVar(&shell, "shell", "", "syntax for --print output: fish (else POSIX sh/zsh/bash)") + _ = cmd.Flags().MarkHidden("shell") return cmd } @@ -211,11 +249,21 @@ func lookupWorkspaceRoot(mgr *workspace.Manager, name string) (string, bool, err return "", false, nil } -// emitUseScript writes the POSIX eval script the shell wrapper runs. Fish support -// is handled by the `shell-init` wrapper (it re-emits in fish syntax). -func emitUseScript(cmd *cobra.Command, root, project string) { +// emitUseScript writes the eval script the shell wrapper runs: POSIX (sh/zsh/bash) +// by default, fish syntax when shell=="fish". Single-quoted values are valid in +// both. The `devstack` wrapper from `shell-init` eval's this to mutate the shell. +func emitUseScript(cmd *cobra.Command, root, project, shell string) { w := cmd.OutOrStdout() fmt.Fprintf(w, "cd %s\n", shellQuote(root)) + if shell == "fish" { + fmt.Fprintf(w, "set -gx DEVSTACK_WORKSPACE %s\n", shellQuote(root)) + if project != "" { + fmt.Fprintf(w, "set -gx DEVSTACK_PROJECT %s\n", shellQuote(project)) + } else { + fmt.Fprintln(w, "set -e DEVSTACK_PROJECT") + } + return + } fmt.Fprintf(w, "export DEVSTACK_WORKSPACE=%s\n", shellQuote(root)) if project != "" { fmt.Fprintf(w, "export DEVSTACK_PROJECT=%s\n", shellQuote(project)) @@ -226,8 +274,7 @@ func emitUseScript(cmd *cobra.Command, root, project string) { // printUseHint reports the current active context and the selectable projects when // `use` is invoked with no target. -func printUseHint(cmd *cobra.Command, mgr *workspace.Manager, projects []string) error { - w := cmd.OutOrStdout() +func printUseHint(w io.Writer, mgr *workspace.Manager, projects []string) error { active := resolveActiveProject(mgr.Model, mgr.DB) if active != "" { fmt.Fprintf(w, "active project: %s\n", active) diff --git a/internal/cli/root.go b/internal/cli/root.go index 4b21d85..7cedee4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -85,6 +85,7 @@ func NewRootCmd(opts Options) *cobra.Command { newStatusCmd(g), newUseCmd(g), newContextCmd(g), + newShellInitCmd(g), newLogsCmd(g), newDashboardCmd(g), newDnsCmd(g), diff --git a/internal/cli/shell_init.go b/internal/cli/shell_init.go new file mode 100644 index 0000000..d68d360 --- /dev/null +++ b/internal/cli/shell_init.go @@ -0,0 +1,92 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +// newShellInitCmd wires `shell-init ` (spec 30): print the shell code a +// user eval's from their rc to get (a) the install dir on PATH, (b) a `devstack` +// wrapper function that eval's `use`'s output so switching mutates the live shell +// — the "execute, don't print" fix — (c) completion loading, and (d) an opt-in +// prompt-segment helper. The wrapper is named after the invoked binary, so aliases +// (`rq shell-init zsh`) generate a matching `rq()` wrapper. +func newShellInitCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "shell-init ", + Short: "Print shell integration to eval (PATH, `use` wrapper, completions, prompt)", + Long: "Print shell integration code for the given shell. Add it to your shell rc:\n\n" + + " zsh/bash: eval \"$(devstack shell-init zsh)\"\n" + + " fish: devstack shell-init fish | source\n\n" + + "It puts the install dir on PATH, defines a `devstack` wrapper so `devstack use`\n" + + "changes your current shell's directory + DEVSTACK_* env, loads completions, and\n" + + "defines a `devstack_prompt` helper you can splice into your prompt.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := cmd.Root().Name() + bindir := "" + if exe, err := os.Executable(); err == nil { + bindir = filepath.Dir(exe) + } + script, err := shellInitScript(name, args[0], bindir) + if err != nil { + return err + } + fmt.Fprint(cmd.OutOrStdout(), script) + return nil + }, + } +} + +const posixShellInit = `# devstack shell integration — add to your rc: eval "$(%[1]s shell-init %[3]s)" +case ":$PATH:" in + *":%[2]s:"*) ;; + *) export PATH="%[2]s:$PATH" ;; +esac +%[1]s() { + if [ "$1" = use ]; then + local _ds_out + _ds_out="$(command %[1]s "$@" --print --shell %[3]s)" || return $? + eval "$_ds_out" + else + command %[1]s "$@" + fi +} +if command -v %[1]s >/dev/null 2>&1; then + source <(command %[1]s completion %[3]s) 2>/dev/null || true +fi +%[1]s_prompt() { command %[1]s context --prompt 2>/dev/null; } +` + +const fishShellInit = `# devstack shell integration — add to config.fish: %[1]s shell-init fish | source +if not contains %[2]s $PATH + set -gx PATH %[2]s $PATH +end +function %[1]s + if test "$argv[1]" = use + command %[1]s $argv --print --shell fish | source + else + command %[1]s $argv + end +end +command %[1]s completion fish | source +function %[1]s_prompt + command %[1]s context --prompt 2>/dev/null +end +` + +// shellInitScript renders the integration for one shell. name is the wrapper +// function name (the invoked binary), bindir the install dir to add to PATH. +func shellInitScript(name, shell, bindir string) (string, error) { + switch shell { + case "zsh", "bash": + return fmt.Sprintf(posixShellInit, name, bindir, shell), nil + case "fish": + return fmt.Sprintf(fishShellInit, name, bindir), nil + default: + return "", fmt.Errorf("unsupported shell %q (want zsh, bash or fish)", shell) + } +} diff --git a/internal/cli/shell_init_test.go b/internal/cli/shell_init_test.go new file mode 100644 index 0000000..8bc7ab1 --- /dev/null +++ b/internal/cli/shell_init_test.go @@ -0,0 +1,88 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestShellInitRegistered(t *testing.T) { + if !findCmd(t, "shell-init") { + t.Fatal("shell-init must be a real RunE command") + } +} + +func TestShellInitScript(t *testing.T) { + cases := map[string][]string{ + "zsh": { + "devstack() {", + "--print --shell zsh", + "completion zsh", + "devstack_prompt()", + `export PATH="/opt/bin:$PATH"`, + }, + "bash": {"devstack() {", "--print --shell bash", "completion bash"}, + "fish": { + "function devstack", + "--print --shell fish | source", + "completion fish | source", + "function devstack_prompt", + "set -gx PATH /opt/bin", + }, + } + for shell, wants := range cases { + got, err := shellInitScript("devstack", shell, "/opt/bin") + if err != nil { + t.Fatalf("%s: %v", shell, err) + } + for _, w := range wants { + if !strings.Contains(got, w) { + t.Errorf("%s script missing %q\n---\n%s", shell, w, got) + } + } + } + if _, err := shellInitScript("devstack", "tcsh", "/opt/bin"); err == nil { + t.Error("unsupported shell should error") + } +} + +// TestShellInitUsesInvokedName verifies the wrapper is named after the binary, so +// an alias (rq) gets an rq() wrapper. +func TestShellInitUsesInvokedName(t *testing.T) { + got, err := shellInitScript("rq", "zsh", "/opt/bin") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "rq() {") || !strings.Contains(got, "command rq") { + t.Errorf("alias wrapper not named rq:\n%s", got) + } +} + +func TestContextPromptSegment(t *testing.T) { + root := writeWS(t, + "apiVersion: devstack/v1\nkind: Workspace\nname: smoke\n"+ + "projects:\n - { name: api, path: api }\n", + map[string]string{"api": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n app: { template: node.vite }\n"}, + ) + t.Chdir(root) + + run := func() string { + c := NewRootCmd(Options{}) + var buf bytes.Buffer + c.SetOut(&buf) + c.SetErr(&buf) + c.SetArgs([]string{"context", "--prompt"}) + if err := c.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + return strings.TrimSpace(buf.String()) + } + + if got := run(); got != "smoke" { + t.Errorf("prompt without project = %q, want smoke", got) + } + t.Setenv("DEVSTACK_PROJECT", "api") + if got := run(); got != "smoke:api" { + t.Errorf("prompt with project = %q, want smoke:api", got) + } +}