Skip to content

Commit 88aee6c

Browse files
gustavobertoiclaude
andcommitted
feat(cli): shell-init eval hook, prompt segment & completions (spec 30 phase 2)
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 <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) <noreply@anthropic.com>
1 parent ef795da commit 88aee6c

5 files changed

Lines changed: 251 additions & 10 deletions

File tree

install.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,18 @@ case ":${PATH}:" in
133133
printf " export PATH=\"%s:\$PATH\"\n" "$install_dir" >&2 ;;
134134
esac
135135

136+
# --- shell-integration hint ------------------------------------------------
137+
# The eval hook puts the install dir on PATH, loads completions, and (the point)
138+
# lets `${BINARY} use` switch your shell's workspace/project. Opt-in — we only
139+
# print the line for the detected shell; we never edit your rc. The \$(...) is an
140+
# escaped literal for the user to copy.
141+
_ds_shell="$(basename "${SHELL:-sh}")"
142+
case "$_ds_shell" in
143+
zsh) info "shell integration — add to ~/.zshrc: eval \"\$(${BINARY} shell-init zsh)\"" ;;
144+
bash) info "shell integration — add to ~/.bashrc: eval \"\$(${BINARY} shell-init bash)\"" ;;
145+
fish) info "shell integration — add to ~/.config/fish/config.fish: ${BINARY} shell-init fish | source" ;;
146+
*) info "shell integration (zsh/bash/fish): eval \"\$(${BINARY} shell-init <shell>)\" — enables '${BINARY} use' to switch your shell" ;;
147+
esac
148+
136149
printf '\n%s%s installed.%s run %s%s doctor%s to verify your environment.\n' \
137150
"$GREEN" "$BINARY" "$RESET" "$BOLD" "$BINARY" "$RESET"

internal/cli/context.go

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package cli
22

33
import (
44
"fmt"
5+
"io"
6+
"os"
57
"strings"
68
"text/tabwriter"
79

810
"github.com/spf13/cobra"
911

12+
"github.com/open-source-cloud/devstack/internal/config"
1013
"github.com/open-source-cloud/devstack/internal/lock"
1114
"github.com/open-source-cloud/devstack/internal/version"
1215
"github.com/open-source-cloud/devstack/internal/workspace"
@@ -76,14 +79,40 @@ func renderContextHeader(cmd *cobra.Command, mgr *workspace.Manager, g *GlobalOp
7679
fmt.Fprintf(cmd.OutOrStdout(), "devstack · %s\n\n", strings.Join(parts, " · "))
7780
}
7881

82+
// renderPromptSegment prints a terse "workspace" or "workspace:project" segment
83+
// for a shell prompt (spec 30). It is deliberately cheap: it discovers the
84+
// workspace from config only (no Docker client, no ledger) and reads the project
85+
// from DEVSTACK_PROJECT (set by the `use` shell hook). Outside a workspace it
86+
// prints nothing, so the prompt segment simply disappears.
87+
func renderPromptSegment(cmd *cobra.Command) error {
88+
cwd, err := os.Getwd()
89+
if err != nil {
90+
return nil
91+
}
92+
m, err := config.Load(cwd)
93+
if err != nil {
94+
return nil
95+
}
96+
seg := m.Workspace.Name
97+
if p := os.Getenv("DEVSTACK_PROJECT"); p != "" {
98+
seg += ":" + p
99+
}
100+
fmt.Fprintln(cmd.OutOrStdout(), seg)
101+
return nil
102+
}
103+
79104
// newContextCmd wires the read-only `context` command: print the resolved active
80105
// workspace/project/role/docker-context/version. Lock-free.
81106
func newContextCmd(g *GlobalOpts) *cobra.Command {
82-
return &cobra.Command{
107+
var promptMode bool
108+
cmd := &cobra.Command{
83109
Use: "context",
84110
Short: "Show the active workspace, project, role and Docker context",
85111
Args: cobra.NoArgs,
86112
RunE: func(cmd *cobra.Command, _ []string) error {
113+
if promptMode {
114+
return renderPromptSegment(cmd)
115+
}
87116
mgr, closeFn, err := buildManager(cmd)
88117
if err != nil {
89118
return err
@@ -110,6 +139,8 @@ func newContextCmd(g *GlobalOpts) *cobra.Command {
110139
return tw.Flush()
111140
},
112141
}
142+
cmd.Flags().BoolVar(&promptMode, "prompt", false, "terse single-line output for a shell prompt segment (cheap; no Docker/ledger)")
143+
return cmd
113144
}
114145

115146
// newUseCmd wires `use [name]`: set the active project (or switch to a registered
@@ -119,6 +150,7 @@ func newContextCmd(g *GlobalOpts) *cobra.Command {
119150
func newUseCmd(g *GlobalOpts) *cobra.Command {
120151
var project string
121152
var printScript bool
153+
var shell string
122154
cmd := &cobra.Command{
123155
Use: "use [name]",
124156
Short: "Set the active project (or switch workspace); persists across terminals",
@@ -162,9 +194,13 @@ func newUseCmd(g *GlobalOpts) *cobra.Command {
162194
targetRoot = root
163195
}
164196
default:
165-
// Bare `use`: report current context + candidates (the fuzzy picker
166-
// TUI lands in the shell-integration phase).
167-
return printUseHint(cmd, mgr, projects)
197+
// Bare `use`: report current context + candidates. Under --print
198+
// the hint goes to stderr so stdout stays an eval-safe (empty) script.
199+
out := cmd.OutOrStdout()
200+
if printScript {
201+
out = cmd.ErrOrStderr()
202+
}
203+
return printUseHint(out, mgr, projects)
168204
}
169205

170206
if err := lock.WithLock(cmd.Context(), mgr.LockPath, func() error {
@@ -174,7 +210,7 @@ func newUseCmd(g *GlobalOpts) *cobra.Command {
174210
}
175211

176212
if printScript {
177-
emitUseScript(cmd, targetRoot, targetProject)
213+
emitUseScript(cmd, targetRoot, targetProject, shell)
178214
return nil
179215
}
180216
if g.JSON {
@@ -194,6 +230,8 @@ func newUseCmd(g *GlobalOpts) *cobra.Command {
194230
}
195231
cmd.Flags().StringVar(&project, "project", "", "force-select a project in the current workspace")
196232
cmd.Flags().BoolVar(&printScript, "print", false, "emit an eval-able shell script (cd + export) instead of persisting only")
233+
cmd.Flags().StringVar(&shell, "shell", "", "syntax for --print output: fish (else POSIX sh/zsh/bash)")
234+
_ = cmd.Flags().MarkHidden("shell")
197235
return cmd
198236
}
199237

@@ -211,11 +249,21 @@ func lookupWorkspaceRoot(mgr *workspace.Manager, name string) (string, bool, err
211249
return "", false, nil
212250
}
213251

214-
// emitUseScript writes the POSIX eval script the shell wrapper runs. Fish support
215-
// is handled by the `shell-init` wrapper (it re-emits in fish syntax).
216-
func emitUseScript(cmd *cobra.Command, root, project string) {
252+
// emitUseScript writes the eval script the shell wrapper runs: POSIX (sh/zsh/bash)
253+
// by default, fish syntax when shell=="fish". Single-quoted values are valid in
254+
// both. The `devstack` wrapper from `shell-init` eval's this to mutate the shell.
255+
func emitUseScript(cmd *cobra.Command, root, project, shell string) {
217256
w := cmd.OutOrStdout()
218257
fmt.Fprintf(w, "cd %s\n", shellQuote(root))
258+
if shell == "fish" {
259+
fmt.Fprintf(w, "set -gx DEVSTACK_WORKSPACE %s\n", shellQuote(root))
260+
if project != "" {
261+
fmt.Fprintf(w, "set -gx DEVSTACK_PROJECT %s\n", shellQuote(project))
262+
} else {
263+
fmt.Fprintln(w, "set -e DEVSTACK_PROJECT")
264+
}
265+
return
266+
}
219267
fmt.Fprintf(w, "export DEVSTACK_WORKSPACE=%s\n", shellQuote(root))
220268
if project != "" {
221269
fmt.Fprintf(w, "export DEVSTACK_PROJECT=%s\n", shellQuote(project))
@@ -226,8 +274,7 @@ func emitUseScript(cmd *cobra.Command, root, project string) {
226274

227275
// printUseHint reports the current active context and the selectable projects when
228276
// `use` is invoked with no target.
229-
func printUseHint(cmd *cobra.Command, mgr *workspace.Manager, projects []string) error {
230-
w := cmd.OutOrStdout()
277+
func printUseHint(w io.Writer, mgr *workspace.Manager, projects []string) error {
231278
active := resolveActiveProject(mgr.Model, mgr.DB)
232279
if active != "" {
233280
fmt.Fprintf(w, "active project: %s\n", active)

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ func NewRootCmd(opts Options) *cobra.Command {
8585
newStatusCmd(g),
8686
newUseCmd(g),
8787
newContextCmd(g),
88+
newShellInitCmd(g),
8889
newLogsCmd(g),
8990
newDashboardCmd(g),
9091
newDnsCmd(g),

internal/cli/shell_init.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/spf13/cobra"
9+
)
10+
11+
// newShellInitCmd wires `shell-init <shell>` (spec 30): print the shell code a
12+
// user eval's from their rc to get (a) the install dir on PATH, (b) a `devstack`
13+
// wrapper function that eval's `use`'s output so switching mutates the live shell
14+
// — the "execute, don't print" fix — (c) completion loading, and (d) an opt-in
15+
// prompt-segment helper. The wrapper is named after the invoked binary, so aliases
16+
// (`rq shell-init zsh`) generate a matching `rq()` wrapper.
17+
func newShellInitCmd(g *GlobalOpts) *cobra.Command {
18+
return &cobra.Command{
19+
Use: "shell-init <zsh|bash|fish>",
20+
Short: "Print shell integration to eval (PATH, `use` wrapper, completions, prompt)",
21+
Long: "Print shell integration code for the given shell. Add it to your shell rc:\n\n" +
22+
" zsh/bash: eval \"$(devstack shell-init zsh)\"\n" +
23+
" fish: devstack shell-init fish | source\n\n" +
24+
"It puts the install dir on PATH, defines a `devstack` wrapper so `devstack use`\n" +
25+
"changes your current shell's directory + DEVSTACK_* env, loads completions, and\n" +
26+
"defines a `devstack_prompt` helper you can splice into your prompt.",
27+
Args: cobra.ExactArgs(1),
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
name := cmd.Root().Name()
30+
bindir := ""
31+
if exe, err := os.Executable(); err == nil {
32+
bindir = filepath.Dir(exe)
33+
}
34+
script, err := shellInitScript(name, args[0], bindir)
35+
if err != nil {
36+
return err
37+
}
38+
fmt.Fprint(cmd.OutOrStdout(), script)
39+
return nil
40+
},
41+
}
42+
}
43+
44+
const posixShellInit = `# devstack shell integration — add to your rc: eval "$(%[1]s shell-init %[3]s)"
45+
case ":$PATH:" in
46+
*":%[2]s:"*) ;;
47+
*) export PATH="%[2]s:$PATH" ;;
48+
esac
49+
%[1]s() {
50+
if [ "$1" = use ]; then
51+
local _ds_out
52+
_ds_out="$(command %[1]s "$@" --print --shell %[3]s)" || return $?
53+
eval "$_ds_out"
54+
else
55+
command %[1]s "$@"
56+
fi
57+
}
58+
if command -v %[1]s >/dev/null 2>&1; then
59+
source <(command %[1]s completion %[3]s) 2>/dev/null || true
60+
fi
61+
%[1]s_prompt() { command %[1]s context --prompt 2>/dev/null; }
62+
`
63+
64+
const fishShellInit = `# devstack shell integration — add to config.fish: %[1]s shell-init fish | source
65+
if not contains %[2]s $PATH
66+
set -gx PATH %[2]s $PATH
67+
end
68+
function %[1]s
69+
if test "$argv[1]" = use
70+
command %[1]s $argv --print --shell fish | source
71+
else
72+
command %[1]s $argv
73+
end
74+
end
75+
command %[1]s completion fish | source
76+
function %[1]s_prompt
77+
command %[1]s context --prompt 2>/dev/null
78+
end
79+
`
80+
81+
// shellInitScript renders the integration for one shell. name is the wrapper
82+
// function name (the invoked binary), bindir the install dir to add to PATH.
83+
func shellInitScript(name, shell, bindir string) (string, error) {
84+
switch shell {
85+
case "zsh", "bash":
86+
return fmt.Sprintf(posixShellInit, name, bindir, shell), nil
87+
case "fish":
88+
return fmt.Sprintf(fishShellInit, name, bindir), nil
89+
default:
90+
return "", fmt.Errorf("unsupported shell %q (want zsh, bash or fish)", shell)
91+
}
92+
}

internal/cli/shell_init_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestShellInitRegistered(t *testing.T) {
10+
if !findCmd(t, "shell-init") {
11+
t.Fatal("shell-init must be a real RunE command")
12+
}
13+
}
14+
15+
func TestShellInitScript(t *testing.T) {
16+
cases := map[string][]string{
17+
"zsh": {
18+
"devstack() {",
19+
"--print --shell zsh",
20+
"completion zsh",
21+
"devstack_prompt()",
22+
`export PATH="/opt/bin:$PATH"`,
23+
},
24+
"bash": {"devstack() {", "--print --shell bash", "completion bash"},
25+
"fish": {
26+
"function devstack",
27+
"--print --shell fish | source",
28+
"completion fish | source",
29+
"function devstack_prompt",
30+
"set -gx PATH /opt/bin",
31+
},
32+
}
33+
for shell, wants := range cases {
34+
got, err := shellInitScript("devstack", shell, "/opt/bin")
35+
if err != nil {
36+
t.Fatalf("%s: %v", shell, err)
37+
}
38+
for _, w := range wants {
39+
if !strings.Contains(got, w) {
40+
t.Errorf("%s script missing %q\n---\n%s", shell, w, got)
41+
}
42+
}
43+
}
44+
if _, err := shellInitScript("devstack", "tcsh", "/opt/bin"); err == nil {
45+
t.Error("unsupported shell should error")
46+
}
47+
}
48+
49+
// TestShellInitUsesInvokedName verifies the wrapper is named after the binary, so
50+
// an alias (rq) gets an rq() wrapper.
51+
func TestShellInitUsesInvokedName(t *testing.T) {
52+
got, err := shellInitScript("rq", "zsh", "/opt/bin")
53+
if err != nil {
54+
t.Fatal(err)
55+
}
56+
if !strings.Contains(got, "rq() {") || !strings.Contains(got, "command rq") {
57+
t.Errorf("alias wrapper not named rq:\n%s", got)
58+
}
59+
}
60+
61+
func TestContextPromptSegment(t *testing.T) {
62+
root := writeWS(t,
63+
"apiVersion: devstack/v1\nkind: Workspace\nname: smoke\n"+
64+
"projects:\n - { name: api, path: api }\n",
65+
map[string]string{"api": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n app: { template: node.vite }\n"},
66+
)
67+
t.Chdir(root)
68+
69+
run := func() string {
70+
c := NewRootCmd(Options{})
71+
var buf bytes.Buffer
72+
c.SetOut(&buf)
73+
c.SetErr(&buf)
74+
c.SetArgs([]string{"context", "--prompt"})
75+
if err := c.Execute(); err != nil {
76+
t.Fatalf("execute: %v", err)
77+
}
78+
return strings.TrimSpace(buf.String())
79+
}
80+
81+
if got := run(); got != "smoke" {
82+
t.Errorf("prompt without project = %q, want smoke", got)
83+
}
84+
t.Setenv("DEVSTACK_PROJECT", "api")
85+
if got := run(); got != "smoke:api" {
86+
t.Errorf("prompt with project = %q, want smoke:api", got)
87+
}
88+
}

0 commit comments

Comments
 (0)