Module: internal/cli (use, context, shell-init, project, env), internal/activectx (the persisted-context store, new), internal/branding (logo asset, new), internal/prompt/internal/tui (the shared TUI substrate), internal/state (active-context row), internal/alias (Branding) · Milestone: post-GA DX lane (0.x beta line; never forces v1.0.0) · Effort: ~6w (phased) · ADR: D18
devstack is fast and correct but cold: every command starts with no context banner, there is no notion of an "active" workspace or project (each data-plane command re-derives "the single/first project" via defaultProject — internal/cli/resource.go line ~337 — and defaultProjectFromModel, internal/cli/messaging.go line ~113), switching context means cd-ing by hand, and the installer only prints a PATH hint (install.sh lines ~128-134) — it never wires zsh/fish, completions, or an eval hook. There are also no TUIs for authoring projects or env/secrets, even though the interactive substrate already exists (internal/prompt, the dashboard Bubble Tea model). This spec adds the DX layer: an active-context model, a use/context pair, an opt-in shell-init eval hook (the "execute, don't print" fix — the enabler for real switching, completions, and a prompt segment), a project TUI, an env/secrets TUI, a console context header, and ASCII-logo branding. Everything is built on the substrate devstack already ships; nothing here changes the deterministic generate pipeline or the lock-first concurrency model.
This spec extends the interactive lineage of spec 22 (init wizard), spec 23 (template new) and spec 24 (secrets ingest), reusing their internal/prompt theme and the non-TTY fallback contract (ARCHITECTURE §7.9). The JS/monorepo template library and the task-graph runner are a separate RFC, spec 31.
- Two-layer resolution: a persisted default + a per-shell override. A machine-global "active context" is the default; any terminal overrides it per-shell via env. Resolution order for every command, in precedence:
--projectflag →DEVSTACK_PROJECTenv → persisted active project → the existingdefaultProjectfallback (single/first project). Workspace resolution likewise:DEVSTACK_WORKSPACEenv (already honored,internal/config/discover.go) → persisted active workspace → the upwardworkspace.yamlwalk. No behavior changes when nothing is set — the current single/first-project default still wins, so existing scripts are unaffected. - The persisted default lives in the SQLite ledger, keyed by Docker context. A new single-row
active(ctx, workspace_root, project, updated_at)table ininternal/statevia an append-only, forward-only migration (spec 08 rule; never edit a released migration). Keyed by Docker context like the rest of the ledger (DECISIONS D6) so WSL2's two daemons don't share an active context. Written under the flock (it is a state mutation); reads are lock-free snapshots. See[Q-CTX-SCOPE]. defaultProject/defaultProjectFromModelbecome the single resolution seam. Both helpers gain the env→persisted lookup ahead of their current sorted-first fallback, so all db/s3/queue/topic/stream/resource/shell commands inherit active-context awareness with no per-command change.
devstack use [name]sets the active context; bare + TTY opens a fuzzy picker.namemay be a workspace (registered in the ledger —workspace list) or a project in the current workspace. With no arg on a TTY it launches a Bubble Tea v2 fuzzy-list picker (thedashboardmodel pattern,internal/cli/dashboard_model.go,bubbles/v2/list). Because a child process cannot mutate its parent shell,usebehaves in two modes: (a) under the shell hook it printscd <root>/export DEVSTACK_WORKSPACE=… DEVSTACK_PROJECT=…on stdout for the wrapper function toeval; (b) without the hook it writes the persisted default and prints a human confirmation + a one-line "addeval \"$(devstack shell-init …)\"for in-shell switching" hint. This is the direct answer to "today it only prints, it should execute".devstack contextprints the resolved context; honors--json. Workspace name + root, active project, the project's Postgres role + grant tier (internal/provision, read/write/admin from spec 29), Docker context +backendtarget (local vs remote —config.BackendConfig.IsRemote()), and version. All fields come frombuildManager(internal/cli/shared.goline ~147); no new data sources.--jsonemits the same as a machine object. This is the non-shell way to answer "which workspace/project/role am I in".
- One command emits everything a shell needs; the user
evals it.devstack shell-init zsh|fish|bashprints, for the target shell: (a) the install-dir PATH export (replacing the print-only hint ininstall.sh), (b) completion loading via fang's already-availablecompletionsubcommand (internal/cli/root.goline ~124; fang providescompletion— spec 07), (c) thedevstack()wrapper function that runs the binary and, foruse/cd,evals its stdout so switching mutates the live shell, and (d) an opt-in prompt-segment function. Users addeval "$(devstack shell-init zsh)"to~/.zshrc/~/.config/fish/config.fish. This is a purely opt-in hook — it does not reintroduce mandatory direnv (spec 07 deliberately dropped direnv forargv[0]symlinks); the symlink aliasing keeps working untouched. - Ship real plugin artifacts + teach the installer. Add a
completions/tree (generated bash/zsh/fish) and a minimal oh-my-zsh plugin dir + a*.plugin.fish, packaged by.goreleaser.yaml(today it packages only docs).install.shgains shell detection ($SHELL/$ZSH_VERSION/$FISH_VERSION) and, interactively, offers to append theevalline to the right rc file (never silently; prints the exact line under--quiet/non-TTY). Completions today are generatable but installed nowhere — this closes that gap. - A prompt segment surfaces context always-on. The
shell-initoutput defines adevstack_prompt_infohelper (workspace ∕ project ∕ role, kube-ps1 style) users can splice intoPROMPT/fish_prompt. It shellsdevstack context --quiet --prompt(a fast, lock-free path that reads the ledger snapshot only). See[Q-PROMPT-COST].
devstack projectgroup —new/edit/list/rm. TTY → a Bubble Tea v2 model (thedashboardpattern +internal/prompt.IsInteractivegate,internal/prompt/prompt.goline ~17); non-TTY/--json/--no-input→ a flag path. Follows the "two faces, one builder" rule from spec 22 (internal/cli/init_tui.goline ~23): wizard and flags feed one pure builder that emits or editsdevstack.yaml(Servicetemplate/params/uses/ports/env/healthcheck), and registers the project inworkspace.yaml'sprojects:.- Editing existing files must preserve comments/order — an AST round-trip, not struct marshal.
project edit/envmutate a hand-written userdevstack.yaml, so they use a goccy AST-level editor (surgical node updates) rather than the generate-sidewriteIfChanged(which owns generated artifacts and would reflow the file). New emitters live besidescaffold.EmitWorkspaceYAML(spec 22) asscaffold.EditProjectYAML. See[Q-YAML-ROUNDTRIP]. devstack envgroup — a key→value editor for local vars AND secrets. An interactive editor over a service'senv.raw/env.prefixed(plain local KV — no external store needed, the mechanism documented in the usage guide) andsecret://values, reusing the huh-form pattern ofinternal/cli/secrets_ingest_tui.go+prompt.Theme(). Local vs secret classification mirrorssecrets ingest(spec 24); secret values route through the existing providers and are never written to disk (the spec 04 valueless-env-key coupling). "Workspace-scoped env" is a TUI convenience meaning "write this key to every project" — it is written per-project into eachdevstack.yaml, with noworkspace.yamlschema change (deliberate decision — keeps spec 01 untouched).
- A shared
renderContextHeader(mgr, g)helper, gated like the update notice. A compact one/two-line header (workspace · project · role · docker-context · version) printed atopstatusandupoutput, suppressedif g.JSON || g.Quiet— the established precedent (the self-update notice returns early on--json/--quiet,internal/cli/root.goline ~61). The same projection backsdevstack contextand the prompt segment (one code path, three surfaces). - ASCII logo via cobra's help hook — fang has no logo option. Embed a
devstacklogo (internal/branding/logo.txt,go:embed), renderedlipgloss-styled through cobraroot.SetHelpFunc/SetUsageTemplate(currently unused — confirmed) on--help, on a bare invocation, and aboveversion. fang v2.0.1 exposes no banner/logo injection (onlyWithVersion/WithCommit/WithTheme/WithColorSchemeFunc/… — verified in the module cache), so the cobra help func is the injection point. All decorative output is gated under--json/--quiet. Add optionalLogo/Taglinefields toalias.Branding(internal/alias/alias.goline ~243) sorq/uranusaliases can rebrand.
- No new mutation escapes the lock. The only state write here is the active-context row → under the flock. Everything else (
context, header,shell-init, logo) is read-only or pure output. TUIs never enter the Bubble Tea runtime on a non-TTY (prompt.IsInteractive), preserving the headline--json/--quietcontract. - New deps: none beyond the v2 charm stack already added in spec 22 (
bubbletea/v2/bubbles/v2/huh/v2/lipgloss/v2/fang/v2) — all pure-Go, CGO-free.
devstack use [name] # set active workspace/project; bare+TTY → fuzzy picker
--project <name> # force-select a project in the current workspace
--print # emit the eval script even without the shell hook
devstack context # print resolved workspace/project/role/context/version
--json # machine object
--prompt # terse single-line form for a shell prompt segment
devstack shell-init <zsh|fish|bash> # emit PATH + completions + devstack() wrapper + prompt helper
# usage: eval "$(devstack shell-init zsh)"
devstack project new [name] # author a devstack.yaml (+ register in workspace.yaml)
devstack project edit [name] # AST round-trip edit of an existing devstack.yaml
devstack project list # list workspace projects (+ --json)
devstack project rm <name> # unregister a project (--yes; never deletes repo files)
# shared: --template --uses --port --env k=v ... --dry-run --force --no-input
devstack env [service] # interactive key→value editor (local env.raw + secret://)
--project <name> # target project (default: active/first)
--all-projects # apply a key to every project (written per-project)
--set KEY=VALUE # non-interactive set (repeatable)
--secret KEY # mark KEY as a secret:// value (routed to a provider)
--unset KEY # remove a key
--json --no-input # scriptable / CI
# branding: no new command — logo renders on `--help`, bare invocation, and `version`
- With
eval "$(devstack shell-init zsh)"loaded,devstack use <ws>changes the current shell's directory and setsDEVSTACK_WORKSPACE/DEVSTACK_PROJECT; without the hook it persists the default and prints a hint (exit 0).<TAB>completes commands + live project/service names. devstack context --jsonreturns the resolved workspace, project, Postgres role + grant tier, Docker context/backend, and version; the plain form prints the same as a header.- All db/s3/queue/topic/stream/resource/shell commands honor
DEVSTACK_PROJECT/the persisted active project; with nothing set, behavior is byte-identical to today (single/first project). devstack project new/editproduce a config that passesconfig validate;editpreserves comments and key order in the targetdevstack.yaml.devstack envsetsenv.rawkeys for local values and routes--secretkeys through a provider with no secret value in any written file (CI-asserted, as in spec 04).- The logo renders on
--helpand bare invocation but never under--json/--quiet;status/upshow the context header, suppressed under--json/--quiet. make ci+make determinismstay green; the active-context migration is append-only; no existing generated artifact changes.
[Q-CTX-SCOPE]— persist the active context per-Docker-context (consistent with the ledger, and correct for WSL2's split daemons) vs. one global default. Lean: per-Docker-context.[Q-YAML-ROUNDTRIP]— the comment/order-preserving goccy AST editor forproject edit/env: build it ininternal/scaffold, or adopt a small third-party YAML-edit helper (must stay pure-Go/CGO-free).[Q-PROMPT-COST]— the prompt segment shellsdevstack contexton every prompt render; measure cost and consider a cached, ledger-only fast path (or a background-refreshed cache file) so shells stay snappy.[Q-USE-TARGET]— shoulduse <name>disambiguate workspace vs project by lookup order (project-in-current-workspace first, then registered workspaces), or requireuse --project/use --workspace? Lean: lookup order + a--workspace/--projectdisambiguator.