diff --git a/docs/cli/ghost_init.md b/docs/cli/ghost_init.md index 356667b..9daccc2 100644 --- a/docs/cli/ghost_init.md +++ b/docs/cli/ghost_init.md @@ -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 diff --git a/docs/cli/ghost_init_completion.md b/docs/cli/ghost_init_completion.md new file mode 100644 index 0000000..f9eb496 --- /dev/null +++ b/docs/cli/ghost_init_completion.md @@ -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 diff --git a/internal/cmd/init.go b/internal/cmd/init.go index ff4e220..6da3a8a 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -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 } @@ -84,14 +85,32 @@ func buildInitPathCmd() *cobra.Command { return cmd } +func buildInitCompletionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion", + 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) { @@ -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) @@ -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 @@ -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 { @@ -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 diff --git a/internal/cmd/init_test.go b/internal/cmd/init_test.go index ce14f82..d35ac4a 100644 --- a/internal/cmd/init_test.go +++ b/internal/cmd/init_test.go @@ -3,10 +3,12 @@ package cmd import ( "bytes" "context" + "errors" "net/http" "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -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) { diff --git a/scripts/install.sh b/scripts/install.sh index bb695c1..5440afc 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 2>/dev/tty || true