Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/cli/ghost_init.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ ghost init [flags]
### SEE ALSO

* [ghost](ghost.md) - CLI for managing Postgres databases
* [ghost init completion](ghost_init_completion.md) - Install shell completions
* [ghost init path](ghost_init_path.md) - Add Ghost to your PATH
36 changes: 36 additions & 0 deletions docs/cli/ghost_init_completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
title: "ghost init completion"
slug: "ghost_init_completion"
description: "CLI reference for ghost init completion"
---

## ghost init completion

Install shell completions

### Synopsis

Install Ghost shell completions by appending a sourcing line to your shell rc file. This command does not prompt for confirmation, so it can be used from scripts.

```
ghost init completion [flags]
```

### Options

```
-h, --help help for completion
```

### Options inherited from parent commands

```
--analytics enable/disable usage analytics (default true)
--color enable colored output (default true)
--config-dir string config directory (default "~/.config/ghost")
--version-check check for updates (default true)
```

### SEE ALSO

* [ghost init](ghost_init.md) - Interactively configure Ghost
77 changes: 70 additions & 7 deletions internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func buildInitCmd(app *common.App) *cobra.Command {
cmd.Flags().BoolVar(&skipIfConfigured, "skip-if-configured", false, "Exit with a short message if every step is already configured")

cmd.AddCommand(buildInitPathCmd())
cmd.AddCommand(buildInitCompletionCmd())

return cmd
}
Expand All @@ -84,14 +85,32 @@ func buildInitPathCmd() *cobra.Command {
return cmd
}

func buildInitCompletionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "completion",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make this completions with completion as an alias. I'm not really sure which is more correct, but we use the plural in the descriptions 😅

Short: "Install shell completions",
Long: `Install Ghost shell completions by appending a sourcing line to your shell rc file. This command does not prompt for confirmation, so it can be used from scripts.`,
Args: cobra.NoArgs,
ValidArgsFunction: cobra.NoFileCompletions,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
changed, err := runInitCompletions(cmd)
if err != nil {
return err
}
if changed {
cmd.PrintErrln("Restart your shell to apply changes.")
}
return nil
},
}
return cmd
}

func runInit(cmd *cobra.Command, app *common.App, skipIfConfigured bool) error {
ctx := cmd.Context()
stdinIsTerminal := util.IsTerminal(cmd.InOrStdin())

if !stdinIsTerminal && !skipIfConfigured {
return errors.New("ghost init requires an interactive terminal; run it from a TTY")
}

states := detectInitStates(ctx, app)

if skipIfConfigured && allConfigured(states) {
Expand All @@ -100,7 +119,7 @@ func runInit(cmd *cobra.Command, app *common.App, skipIfConfigured bool) error {
}

if !stdinIsTerminal {
return errors.New("ghost init requires an interactive terminal; run it from a TTY")
return nonInteractiveError(states, !skipIfConfigured)
}

mainItems := buildMainMenuItems(states)
Expand Down Expand Up @@ -150,6 +169,8 @@ func runSelectedInitSteps(cmd *cobra.Command, app *common.App, indices []int) er
return err
}
case stepCompletions:
cmd.PrintErrln()
cmd.PrintErrln("--- Shell completions ---")
changed, err := runInitCompletions(cmd)
if err != nil {
return err
Expand Down Expand Up @@ -182,6 +203,50 @@ func allConfigured(states []initStepState) bool {
})
}

// nonInteractiveCommandHints maps each init step to the standalone command
// that completes it without prompts, plus a short explanation for the user.
// Order matches the natural execution order so that printed lists read
// top-to-bottom.
var nonInteractiveCommandHints = []struct {
step initStep
command string
comment string
}{
{stepPATH, "ghost init path", "add ghost to your PATH"},
{stepLogin, "ghost login", "authenticate (or use --api-key)"},
{stepMCP, "ghost mcp install all", "install MCP server in all detected clients (or pass a specific client name)"},
{stepCompletions, "ghost init completion", "install shell completions in your shell rc file"},
}

// nonInteractiveError builds the error returned when `ghost init` is invoked
// without a TTY. It lists only the commands for steps that aren't already
// configured, so callers see exactly what's left to do. includeSkipHint
// appends a reminder about --skip-if-configured (omit when the flag was
// already passed).
func nonInteractiveError(states []initStepState, includeSkipHint bool) error {
maxCmdLen := 0
for _, hint := range nonInteractiveCommandHints {
if states[hint.step].configured {
continue
}
if len(hint.command) > maxCmdLen {
maxCmdLen = len(hint.command)
}
}
var b strings.Builder
b.WriteString("ghost init requires an interactive terminal and cannot run here. To complete setup non-interactively, run these commands in order:")
for _, hint := range nonInteractiveCommandHints {
if states[hint.step].configured {
continue
}
fmt.Fprintf(&b, "\n %-*s # %s", maxCmdLen, hint.command, hint.comment)
}
if includeSkipHint {
b.WriteString("\nOr pass --skip-if-configured to exit cleanly when everything is already set up")
}
return errors.New(b.String())
}

func buildMainMenuItems(states []initStepState) []common.MultiSelectItem {
items := make([]common.MultiSelectItem, len(states))
for i, s := range states {
Expand Down Expand Up @@ -341,8 +406,6 @@ func runInitMCP(cmd *cobra.Command) error {
// runInitCompletions appends Ghost's completion snippet to the user's rc
// file. The returned bool reports whether the rc file was actually modified.
func runInitCompletions(cmd *cobra.Command) (bool, error) {
cmd.PrintErrln()
cmd.PrintErrln("--- Shell completions ---")
if runtime.GOOS == "windows" {
cmd.PrintErrln("Shell completions are not supported on Windows; skipping.")
return false, nil
Expand Down
119 changes: 110 additions & 9 deletions internal/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package cmd
import (
"bytes"
"context"
"errors"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

Expand All @@ -17,17 +19,116 @@ import (
"github.com/timescale/ghost/internal/common"
)

func TestInit(t *testing.T) {
tests := []cmdTest{
{
name: "non-interactive stdin returns error before detecting state",
args: []string{"init"},
opts: []runOption{withIsTerminal(false)},
wantErr: "ghost init requires an interactive terminal; run it from a TTY",
},
// TestInit_NonInteractiveAllUnconfigured verifies that running `ghost init`
// without a TTY when nothing is configured returns an error listing every
// standalone command, with the --skip-if-configured hint at the bottom.
func TestInit_NonInteractiveAllUnconfigured(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("env-based completion detection is skipped on Windows")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("SHELL", "/bin/bash")
t.Setenv("PATH", filepath.Join(home, "not-in-path"))
t.Setenv("ZDOTDIR", "")
t.Setenv("XDG_CONFIG_HOME", "")
withMCPClientCommandRunner(t, func(_ context.Context, _ string, _ ...string) ([]byte, error) {
return nil, exec.ErrNotFound
})

runCmdTests(t, tests)
result := runCommand(t, []string{"init"}, nil,
withIsTerminal(false),
withClientError(errors.New("not logged in")),
)
if result.err == nil {
t.Fatalf("expected error, got nil\nstderr: %s", result.stderr)
}
expected := `ghost init requires an interactive terminal and cannot run here. To complete setup non-interactively, run these commands in order:
ghost init path # add ghost to your PATH
ghost login # authenticate (or use --api-key)
ghost mcp install all # install MCP server in all detected clients (or pass a specific client name)
ghost init completion # install shell completions in your shell rc file
Or pass --skip-if-configured to exit cleanly when everything is already set up`
assertOutput(t, result.err.Error(), expected)
}

// TestInit_NonInteractiveOnlyLoginUnconfigured verifies that when most steps
// are already configured, the error only lists the remaining commands and
// omits the --skip-if-configured hint (since the flag was already passed).
func TestInit_NonInteractiveOnlyLoginUnconfigured(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("env-based completion detection is skipped on Windows")
}
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
installDir := filepath.Dir(exe)
t.Setenv("PATH", installDir)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("SHELL", "/bin/bash")
t.Setenv("ZDOTDIR", "")
t.Setenv("XDG_CONFIG_HOME", "")
if err := os.WriteFile(filepath.Join(home, ".bashrc"),
[]byte("source <(ghost completion bash)\n"), 0o644); err != nil {
t.Fatal(err)
}
cursorPath := filepath.Join(home, ".cursor", "mcp.json")
if err := os.MkdirAll(filepath.Dir(cursorPath), 0o755); err != nil {
t.Fatal(err)
}
cursorCfg := `{"mcpServers":{"ghost":{"command":"/usr/local/bin/ghost","args":["mcp","start"]}}}`
if err := os.WriteFile(cursorPath, []byte(cursorCfg), 0o644); err != nil {
t.Fatal(err)
}
withMCPClientCommandRunner(t, func(_ context.Context, _ string, _ ...string) ([]byte, error) {
return nil, exec.ErrNotFound
})

result := runCommand(t, []string{"init", "--skip-if-configured"}, nil,
withIsTerminal(false),
withClientError(errors.New("not logged in")),
)
if result.err == nil {
t.Fatalf("expected error, got nil\nstderr: %s", result.stderr)
}
expected := `ghost init requires an interactive terminal and cannot run here. To complete setup non-interactively, run these commands in order:
ghost login # authenticate (or use --api-key)`
assertOutput(t, result.err.Error(), expected)
}

func TestInitCompletionSubcommandNonInteractive(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell completions are not supported on Windows")
}
home := t.TempDir()
rcPath := filepath.Join(home, ".bashrc")
executablePath, err := getGhostExecutablePath()
if err != nil {
t.Fatalf("getGhostExecutablePath: %v", err)
}

result := runCommand(t, []string{"init", "completion"}, nil,
withEnv("HOME", home),
withEnv("SHELL", "/bin/bash"),
withEnv("ZDOTDIR", ""),
withEnv("XDG_CONFIG_HOME", ""),
withIsTerminal(false),
)
if result.err != nil {
t.Fatalf("unexpected error: %v\nstderr: %s", result.err, result.stderr)
}
assertOutput(t, result.stdout, "")
assertOutput(t, result.stderr, "Added bash completions to "+rcPath+".\nRestart your shell to apply changes.\n")

gotRC, err := os.ReadFile(rcPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(gotRC), common.CompletionSnippet("bash", executablePath)) {
t.Fatalf("completion snippet not found in rc:\n%s", string(gotRC))
}
}

func TestInitPathSubcommandNonInteractive(t *testing.T) {
Expand Down
6 changes: 5 additions & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,11 @@ run_ghost_init() {
local binary_path="$1"
if [ ! -r /dev/tty ] || [ ! -w /dev/tty ]; then
"${binary_path}" --version-check=false init path || true
printf "\nRun '%s init' to finish configuring Ghost.\n" "${binary_path}" >&2
printf "\nNo interactive terminal detected. PATH has been configured; to finish setup non-interactively, run each of these:\n" >&2
printf " %s login # authenticate (or use --api-key)\n" "${binary_path}" >&2
printf " %s mcp install all # install MCP server in all detected clients (or pass a specific client name)\n" "${binary_path}" >&2
printf " %s init completion # install shell completions in your shell rc file\n" "${binary_path}" >&2
printf "Or run '%s init' from an interactive terminal to do all of the above with prompts.\n" "${binary_path}" >&2
return 0
fi
"${binary_path}" --version-check=false init --skip-if-configured </dev/tty >/dev/tty 2>/dev/tty || true
Expand Down