diff --git a/CLAUDE.md b/CLAUDE.md index 994dab9..8fd89d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ - **`cmd/`** - Binary entry points. Contains `ghost/main.go` (the main CLI binary, which sets up context/signal handling and delegates to the internal command infrastructure), `npm-publisher/` (a CI tool that generates and publishes npm packages for each platform), and `generate-docs/` (generates Markdown CLI reference docs to `docs/cli/`). - **`internal/`** - All core application logic (non-public Go packages). - - **`internal/cmd/`** - Cobra command implementations for all CLI commands (create, fork, list, delete, pause, resume, connect, psql, sql, schema, logs, password, pricing, rename, status, feedback, api-key, login, logout, config, mcp, version, upgrade, completion, payment). Each command lives in its own file, named to match the command in snake_case (e.g. `ghost payment list` → `payment_list.go`). Helper files like `completion.go`, `errors.go`, and `logger.go` contain shared utilities. Commands that are not yet ready for public release can be gated behind the `GHOST_EXPERIMENTAL` env var (see `internal/common/app.go`'s `App.Experimental` field). + - **`internal/cmd/`** - Cobra command implementations for all CLI commands (init, create, fork, list, delete, pause, resume, connect, psql, sql, schema, logs, password, pricing, rename, status, feedback, api-key, login, logout, config, mcp, version, upgrade, completion, payment). Each command lives in its own file, named to match the command in snake_case (e.g. `ghost payment list` → `payment_list.go`). Helper files like `completion.go`, `errors.go`, and `logger.go` contain shared utilities. Commands that are not yet ready for public release can be gated behind the `GHOST_EXPERIMENTAL` env var (see `internal/common/app.go`'s `App.Experimental` field). - **`internal/api/`** - API client layer. Includes an OpenAPI-generated REST client (`client.go`, `types.go`), shared HTTP client singleton, and request/response types. **Do not edit `client.go` or `types.go` by hand** — they are generated from `openapi.yaml` (see [Code Generation](#code-generation)). The `mock/` subdirectory contains a generated mock of `ClientWithResponsesInterface` for use in tests. - **`internal/config/`** - Configuration management. Handles config file loading (via Viper), credential storage (keyring with file fallback), and version checking. - **`internal/common/`** - Shared business logic used across commands and MCP tools. Includes API client initialization, database connection/schema/query utilities, error handling with exit codes, and version update checks. @@ -18,20 +18,7 @@ ## Build & Test -Always run the following after editing Go code: - -- Build: - - `go install ./...` -- Format: - - `go fmt ./...` -- Fix: - - `go mod tidy` - - `go fix -omitzero=false ./...` -- Lint: - - `go vet ./...` - - `go tool staticcheck ./...` -- Test: - - `go test ./...` +After editing Go code, run `./check` from the repo root. It runs `go install`, `go fmt`, `go mod tidy`, `go fix`, `go vet`, `staticcheck`, and `go test` in one shot. ## Testing diff --git a/README.md b/README.md index aaf5419..7501724 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,7 @@ npm install -g @ghost.build/cli ## Usage ```bash -ghost login # Authenticate with GitHub OAuth -ghost mcp install # Install the MCP server +ghost init # Interactively configure Ghost (PATH, login, MCP, completions) ghost create # Create a new Postgres database ghost list # List all databases ``` @@ -68,6 +67,7 @@ ghost list # List all databases | `fork` | Fork a database | | `fork dedicated` | Fork a database as dedicated | | `help` | Help about any command | +| `init` | Interactively configure Ghost | | `invoice` | View invoices | | `list` | List all databases | | `logs` | View logs for a database | diff --git a/check b/check new file mode 100755 index 0000000..b460556 --- /dev/null +++ b/check @@ -0,0 +1,10 @@ +#!/bin/sh +set -ex + +go install ./... +go fmt ./... +go mod tidy +go fix -omitzero=false ./... +go vet ./... +go tool staticcheck ./... +go test ./... diff --git a/docs/cli/ghost.md b/docs/cli/ghost.md index 181b265..de04bf8 100644 --- a/docs/cli/ghost.md +++ b/docs/cli/ghost.md @@ -31,6 +31,7 @@ Ghost is a command-line interface for managing PostgreSQL databases. * [ghost delete](ghost_delete.md) - Delete a database * [ghost feedback](ghost_feedback.md) - Submit feedback, a bug report, or a support request * [ghost fork](ghost_fork.md) - Fork a database +* [ghost init](ghost_init.md) - Interactively configure Ghost * [ghost invoice](ghost_invoice.md) - View invoices * [ghost list](ghost_list.md) - List all databases * [ghost login](ghost_login.md) - Authenticate with GitHub OAuth diff --git a/docs/cli/ghost_init.md b/docs/cli/ghost_init.md new file mode 100644 index 0000000..356667b --- /dev/null +++ b/docs/cli/ghost_init.md @@ -0,0 +1,38 @@ +--- +title: "ghost init" +slug: "ghost_init" +description: "CLI reference for ghost init" +--- + +## ghost init + +Interactively configure Ghost + +### Synopsis + +Interactively configure Ghost. Walks through adding Ghost to your PATH, login, MCP server installation, and shell completions. + +``` +ghost init [flags] +``` + +### Options + +``` + -h, --help help for init + --skip-if-configured Exit with a short message if every step is already configured +``` + +### 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](ghost.md) - CLI for managing Postgres databases +* [ghost init path](ghost_init_path.md) - Add Ghost to your PATH diff --git a/docs/cli/ghost_init_path.md b/docs/cli/ghost_init_path.md new file mode 100644 index 0000000..ca86e10 --- /dev/null +++ b/docs/cli/ghost_init_path.md @@ -0,0 +1,36 @@ +--- +title: "ghost init path" +slug: "ghost_init_path" +description: "CLI reference for ghost init path" +--- + +## ghost init path + +Add Ghost to your PATH + +### Synopsis + +Add Ghost's install directory to your PATH by appending a snippet to your shell rc file. This command does not prompt for confirmation, so it can be used from scripts. + +``` +ghost init path [flags] +``` + +### Options + +``` + -h, --help help for path +``` + +### 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/docs/cli/ghost_mcp_install.md b/docs/cli/ghost_mcp_install.md index 88c5acf..e5aa627 100644 --- a/docs/cli/ghost_mcp_install.md +++ b/docs/cli/ghost_mcp_install.md @@ -17,13 +17,13 @@ configuration files for the specified client. Supported Clients: claude-code Configure for Claude Code - cursor Configure for Cursor - windsurf Configure for Windsurf codex Configure for Codex + cursor Configure for Cursor gemini Configure for Gemini CLI - vscode Configure for VS Code antigravity Configure for Google Antigravity kiro-cli Configure for Kiro CLI + vscode Configure for VS Code + windsurf Configure for Windsurf The command will: - Automatically detect the appropriate configuration file location @@ -32,7 +32,7 @@ The command will: - Merge with existing MCP server configurations (doesn't overwrite other servers) - Validate the configuration after installation -Pass "all" to configure every supported client. If no client is specified, you'll be prompted to select one interactively. +Pass "all" to configure every supported client. If no client is specified, you'll be prompted to pick one or more clients interactively. ``` ghost mcp install [client] [flags] @@ -41,7 +41,7 @@ ghost mcp install [client] [flags] ### Examples ``` - # Interactive client selection + # Interactive client selection (multi-select) ghost mcp install # Install for Claude Code (User scope - available in all projects) @@ -78,4 +78,3 @@ ghost mcp install [client] [flags] ### SEE ALSO * [ghost mcp](ghost_mcp.md) - Ghost Model Context Protocol (MCP) server - diff --git a/docs/cli/ghost_mcp_uninstall.md b/docs/cli/ghost_mcp_uninstall.md index 221b851..088509e 100644 --- a/docs/cli/ghost_mcp_uninstall.md +++ b/docs/cli/ghost_mcp_uninstall.md @@ -12,7 +12,7 @@ Uninstall Ghost MCP server configuration from a client Uninstall the Ghost MCP server configuration from a supported MCP client. -Pass "all" to uninstall from all supported clients. If no client is specified, you'll be prompted to select one interactively. +Pass "all" to uninstall from all supported clients. If no client is specified, you'll be prompted to select one or more interactively. Only the Ghost MCP server entry named "ghost" is removed; other MCP server entries are left untouched. ``` @@ -22,7 +22,7 @@ ghost mcp uninstall [client] [flags] ### Examples ``` - # Interactive client selection + # Interactive client selection (multi-select) ghost mcp uninstall # Uninstall from Cursor @@ -56,4 +56,3 @@ ghost mcp uninstall [client] [flags] ### SEE ALSO * [ghost mcp](ghost_mcp.md) - Ghost Model Context Protocol (MCP) server - diff --git a/docs/cli/ghost_pricing.md b/docs/cli/ghost_pricing.md index 2cd16c1..d0f46cb 100644 --- a/docs/cli/ghost_pricing.md +++ b/docs/cli/ghost_pricing.md @@ -36,4 +36,3 @@ ghost pricing [flags] ### SEE ALSO * [ghost](ghost.md) - CLI for managing Postgres databases - diff --git a/go.mod b/go.mod index 72f33df..c0e50df 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( golang.org/x/mod v0.35.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 + golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -90,7 +91,6 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect - golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.43.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/internal/cmd/init.go b/internal/cmd/init.go new file mode 100644 index 0000000..ff4e220 --- /dev/null +++ b/internal/cmd/init.go @@ -0,0 +1,436 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "net/http" + "path/filepath" + "runtime" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/timescale/ghost/internal/common" + "github.com/timescale/ghost/internal/util" +) + +// initStep identifies a top-level step of `ghost init`. +type initStep int + +const ( + stepPATH initStep = iota + stepLogin + stepMCP + stepCompletions + stepCount +) + +// initStepState carries the detected status for a single step. +type initStepState struct { + label string + configured bool + status string +} + +func buildInitCmd(app *common.App) *cobra.Command { + var skipIfConfigured bool + + cmd := &cobra.Command{ + Use: "init", + Short: "Interactively configure Ghost", + Long: `Interactively configure Ghost. Walks through adding Ghost to your PATH, login, MCP server installation, and shell completions.`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + err := runInit(cmd, app, skipIfConfigured) + if err != nil && err.Error() == "" { + // MCP install reports failures via its table and returns an + // ExitCodeError with no message; suppress cobra's "Error: ..." line. + cmd.SilenceErrors = true + } + return err + }, + } + + cmd.Flags().BoolVar(&skipIfConfigured, "skip-if-configured", false, "Exit with a short message if every step is already configured") + + cmd.AddCommand(buildInitPathCmd()) + + return cmd +} + +func buildInitPathCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "path", + Short: "Add Ghost to your PATH", + Long: `Add Ghost's install directory to your PATH by appending a snippet 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 := runInitPath(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) { + cmd.PrintErrln("Ghost is already fully configured. Run `ghost init` to reconfigure.") + return nil + } + + if !stdinIsTerminal { + return errors.New("ghost init requires an interactive terminal; run it from a TTY") + } + + mainItems := buildMainMenuItems(states) + result, err := common.RunMultiSelect(ctx, cmd.InOrStdin(), cmd.ErrOrStderr(), "Select what to configure:", mainItems) + if err != nil { + return err + } + switch result.Reason { + case common.MultiSelectAborted, common.MultiSelectCanceled: + cmd.PrintErrln("Canceled.") + return nil + } + + if len(result.Indices) == 0 { + cmd.PrintErrln("Nothing selected.") + return nil + } + + if err := runSelectedInitSteps(cmd, app, result.Indices); err != nil { + if errors.Is(err, common.ErrMultiSelectCanceled) || errors.Is(err, common.ErrMultiSelectAborted) { + cmd.PrintErrln("Canceled.") + return nil + } + return err + } + return nil +} + +func runSelectedInitSteps(cmd *cobra.Command, app *common.App, indices []int) error { + rcChanged := false + for _, idx := range indices { + switch initStep(idx) { + case stepPATH: + cmd.PrintErrln() + cmd.PrintErrln("--- PATH ---") + changed, err := runInitPath(cmd) + if err != nil { + return err + } + rcChanged = rcChanged || changed + case stepLogin: + if err := runInitLogin(cmd, app); err != nil { + return err + } + case stepMCP: + if err := runInitMCP(cmd); err != nil { + return err + } + case stepCompletions: + changed, err := runInitCompletions(cmd) + if err != nil { + return err + } + rcChanged = rcChanged || changed + } + } + cmd.PrintErrln() + if rcChanged { + cmd.PrintErrln("All done. Restart your shell to apply changes.") + } else { + cmd.PrintErrln("All done.") + } + cmd.PrintErrln("\nGet started with:\n ghost create\nFor help:\n ghost --help") + return nil +} + +func detectInitStates(ctx context.Context, app *common.App) []initStepState { + states := make([]initStepState, stepCount) + states[stepPATH] = detectPathState() + states[stepLogin] = detectLoginState(ctx, app) + states[stepMCP] = detectMCPState(ctx) + states[stepCompletions] = detectCompletionsState() + return states +} + +func allConfigured(states []initStepState) bool { + return !slices.ContainsFunc(states, func(s initStepState) bool { + return !s.configured + }) +} + +func buildMainMenuItems(states []initStepState) []common.MultiSelectItem { + items := make([]common.MultiSelectItem, len(states)) + for i, s := range states { + items[i] = common.MultiSelectItem{ + Label: s.label, + Status: s.status, + Selected: !s.configured, + Dimmed: s.configured, + } + } + return items +} + +// detectLoginState validates that the stored credentials are still functional +// by calling /auth/info. +func detectLoginState(ctx context.Context, app *common.App) initStepState { + state := initStepState{label: "Login to Ghost"} + client, _, err := app.GetClient() + if err != nil || client == nil { + state.status = "not logged in" + return state + } + resp, err := client.AuthInfoWithResponse(ctx) + if err != nil || resp.StatusCode() != http.StatusOK || resp.JSON200 == nil { + state.status = "credentials invalid (re-login required)" + return state + } + email := "" + if resp.JSON200.User != nil { + email = resp.JSON200.User.Email + } else if resp.JSON200.ApiKey != nil { + email = resp.JSON200.ApiKey.UserEmail + } + if email != "" { + state.status = "already configured (" + email + ")" + } else { + state.status = "already configured" + } + state.configured = true + return state +} + +// detectMCPState reports whether any supported MCP client is configured. The +// status shows up to three configured client names. +func detectMCPState(ctx context.Context) initStepState { + state := initStepState{label: "Install MCP server"} + var configuredNames []string + for _, clientCfg := range supportedClients { + result := detectMCPClientStatus(ctx, clientCfg) + if result.Status == mcpStatusConfigured { + configuredNames = append(configuredNames, clientCfg.Name) + } + } + if len(configuredNames) == 0 { + state.status = "no MCP clients configured" + return state + } + state.configured = true + if len(configuredNames) > 3 { + state.status = fmt.Sprintf("already configured (%d clients)", len(configuredNames)) + } else { + state.status = "already configured (" + strings.Join(configuredNames, ", ") + ")" + } + return state +} + +// detectCompletionsState reports whether the shell rc already sources Ghost's +// completions. +func detectCompletionsState() initStepState { + state := initStepState{label: "Shell completions"} + if runtime.GOOS == "windows" { + state.status = "unsupported on Windows — skipping" + state.configured = true + return state + } + shellType := common.DetectShellType() + rc := common.DetectShellRC() + if shellType == "" { + state.status = "unsupported shell — skipping" + state.configured = true + return state + } + mentioned, err := common.ShellRCMentionsGhostCompletion(rc) + if err != nil { + state.status = fmt.Sprintf("could not read %s", rc) + return state + } + if mentioned { + state.configured = true + state.status = fmt.Sprintf("already configured in %s", util.DisplayPath(rc)) + return state + } + state.status = fmt.Sprintf("not configured (would write to %s)", util.DisplayPath(rc)) + return state +} + +// detectPathState reports whether the install dir is already in $PATH. On +// Windows it also consults the persistent user Path in the registry, since +// `ghost init` is typically run right after install and the current shell +// session's %PATH% hasn't been refreshed yet. +func detectPathState() initStepState { + state := initStepState{label: "Add to PATH"} + installDir, err := currentGhostInstallDir() + if err != nil { + state.status = "could not determine install location" + return state + } + if installDir == "" { + state.status = "not installed in a directory (e.g. run from source or via `npx ghost`)" + state.configured = true + return state + } + inPath := common.IsInPath(installDir) + if !inPath && runtime.GOOS == "windows" { + inUserPath, checkErr := common.IsInWindowsUserPath(installDir) + if checkErr != nil { + state.status = fmt.Sprintf("could not read user Path: %v", checkErr) + return state + } + inPath = inUserPath + } + if inPath { + state.configured = true + state.status = fmt.Sprintf("already in PATH (%s)", util.DisplayPath(installDir)) + return state + } + state.status = fmt.Sprintf("not in PATH (%s)", util.DisplayPath(installDir)) + return state +} + +func runInitLogin(cmd *cobra.Command, app *common.App) error { + cmd.PrintErrln() + cmd.PrintErrln("--- Login ---") + result, err := common.Login(cmd.Context(), app, false, cmd.ErrOrStderr()) + if err != nil { + return err + } + cmd.PrintErrf("Logged in as %s\n", result.Email) + return nil +} + +func runInitMCP(cmd *cobra.Command) error { + cmd.PrintErrln() + cmd.PrintErrln("--- MCP server ---") + + clients, err := selectMCPClientsInteractively(cmd, mcpInstallSelectionOptions()) + if err != nil { + return err + } + if len(clients) == 0 { + cmd.PrintErrln("No MCP clients selected.") + return nil + } + return installGhostMCPForClients(cmd, clients, true, false, false) +} + +// 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 + } + shellType := common.DetectShellType() + if shellType == "" { + cmd.PrintErrln("Could not detect your shell from $SHELL; skipping completions.") + return false, nil + } + rc := common.DetectShellRC() + mentioned, err := common.ShellRCMentionsGhostCompletion(rc) + if err != nil { + return false, fmt.Errorf("failed to read %s: %w", rc, err) + } + if mentioned { + cmd.PrintErrf("Completions already configured in %s.\n", rc) + return false, nil + } + + binaryPath, err := getGhostExecutablePath() + if err != nil { + return false, fmt.Errorf("failed to determine Ghost executable path: %w", err) + } + if err := common.AppendCompletionsToShellRC(rc, shellType, binaryPath); err != nil { + return false, err + } + cmd.PrintErrf("Added %s completions to %s.\n", shellType, rc) + return true, nil +} + +// runInitPath adds Ghost's install dir to the user's PATH. On Unix it +// appends a snippet to the shell rc file; on Windows it updates the user +// Path environment variable in the registry. The returned bool reports +// whether the change requires a shell restart to take effect. +func runInitPath(cmd *cobra.Command) (bool, error) { + installDir, err := currentGhostInstallDir() + if err != nil { + return false, fmt.Errorf("failed to determine install directory: %w", err) + } + if common.IsInPath(installDir) { + cmd.PrintErrf("%s is already in PATH.\n", installDir) + return false, nil + } + if runtime.GOOS == "windows" { + return runInitPathWindows(cmd, installDir) + } + rc := common.DetectShellRC() + mentioned, err := common.ShellRCMentions(rc, installDir) + if err != nil { + return false, fmt.Errorf("failed to read %s: %w", rc, err) + } + if mentioned { + cmd.PrintErrf("%s is already referenced in %s. Restart your shell to apply.\n", installDir, rc) + return false, nil + } + if err := common.AppendPathToShellRC(rc, installDir); err != nil { + return false, err + } + cmd.PrintErrf("Added %s to PATH in %s.\n", installDir, rc) + return true, nil +} + +// runInitPathWindows handles the PATH step on Windows by updating the user +// Path in the registry. It assumes the caller has already verified that +// installDir is not in the current session's %PATH%. +func runInitPathWindows(cmd *cobra.Command, installDir string) (bool, error) { + inUserPath, err := common.IsInWindowsUserPath(installDir) + if err != nil { + return false, err + } + if inUserPath { + cmd.PrintErrf("%s is already in your user Path. Restart your shell to apply.\n", installDir) + return false, nil + } + if err := common.AddToWindowsUserPath(installDir); err != nil { + return false, err + } + cmd.PrintErrf("Added %s to your user Path.\n", installDir) + return true, nil +} + +func currentGhostInstallDir() (string, error) { + executablePath, err := getGhostExecutablePath() + if err != nil { + return "", err + } + if executablePath == "ghost" { + return "", nil + } + return filepath.Dir(executablePath), nil +} diff --git a/internal/cmd/init_test.go b/internal/cmd/init_test.go new file mode 100644 index 0000000..ce14f82 --- /dev/null +++ b/internal/cmd/init_test.go @@ -0,0 +1,178 @@ +package cmd + +import ( + "bytes" + "context" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/timescale/ghost/internal/api" + "github.com/timescale/ghost/internal/api/mock" + "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", + }, + } + + runCmdTests(t, tests) +} + +func TestInitPathSubcommandNonInteractive(t *testing.T) { + home := t.TempDir() + executablePath, err := getGhostExecutablePath() + if err != nil { + t.Fatalf("getGhostExecutablePath: %v", err) + } + installDir := filepath.Dir(executablePath) + rcPath := filepath.Join(home, ".bashrc") + + result := runCommand(t, []string{"init", "path"}, nil, + withEnv("HOME", home), + withEnv("SHELL", "/bin/bash"), + withEnv("PATH", filepath.Join(home, "not-in-path")), + withIsTerminal(false), + ) + if result.err != nil { + t.Fatalf("unexpected error: %v", result.err) + } + assertOutput(t, result.stdout, "") + assertOutput(t, result.stderr, "Added "+installDir+" to PATH in "+rcPath+".\nRestart your shell to apply changes.\n") + + gotRC, err := os.ReadFile(rcPath) + if err != nil { + t.Fatal(err) + } + assertOutput(t, string(gotRC), "\n# Added by ghost init\nexport PATH=\""+installDir+":$PATH\"\n") +} + +func TestRunSelectedInitSteps_ConfiguresPathBeforeCompletions(t *testing.T) { + 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", "") + + executablePath, err := getGhostExecutablePath() + if err != nil { + t.Fatalf("getGhostExecutablePath: %v", err) + } + installDir := filepath.Dir(executablePath) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + if err := runSelectedInitSteps(cmd, &common.App{}, []int{int(stepPATH), int(stepCompletions)}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertOutput(t, stdout.String(), "") + + rcPath := filepath.Join(home, ".bashrc") + gotRCBytes, err := os.ReadFile(rcPath) + if err != nil { + t.Fatal(err) + } + gotRC := string(gotRCBytes) + pathIndex := strings.Index(gotRC, "export PATH=\""+installDir+":$PATH\"") + if pathIndex == -1 { + t.Fatalf("PATH snippet not found in rc file:\n%s", gotRC) + } + completionSnippet := common.CompletionSnippet("bash", executablePath) + completionIndex := strings.Index(gotRC, completionSnippet) + if completionIndex == -1 { + t.Fatalf("completion snippet %q not found in rc file:\n%s", completionSnippet, gotRC) + } + if completionIndex < pathIndex { + t.Fatalf("completion snippet should appear after PATH snippet in rc file:\n%s", gotRC) + } +} + +func TestInit_SkipIfConfiguredAllConfigured(t *testing.T) { + // This test sets up enough state for every detection to report + // "configured", then verifies --skip-if-configured exits cleanly with + // the expected hint on stderr. + + // Capture the executable path so we can ensure its directory is in + // $PATH for the duration of the test. os.Executable() inside the test + // binary points at the binary itself, so adding its dir to PATH makes + // the PATH detection report "configured". + exe, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable: %v", err) + } + installDir := filepath.Dir(exe) + t.Setenv("PATH", installDir) + + // Point HOME at a temp dir holding a shellrc that already sources + // ghost completion. Also set SHELL so DetectShellType reports a known + // value. + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SHELL", "/bin/bash") + t.Setenv("ZDOTDIR", "") + t.Setenv("XDG_CONFIG_HOME", "") + bashrc := filepath.Join(home, ".bashrc") + if err := os.WriteFile(bashrc, []byte("source <(ghost completion bash)\n"), 0o644); err != nil { + t.Fatal(err) + } + + // MCP detection: write a JSON-config client (Cursor) so detectMCPState + // reports at least one configured client. Cursor uses ~/.cursor/mcp.json + // with MCPServersPathPrefix=/mcpServers. + cursorPath := filepath.Join(home, ".cursor", "mcp.json") + if err := os.MkdirAll(filepath.Dir(cursorPath), 0o755); err != nil { + t.Fatal(err) + } + // isGhostExecutableCommand keys off filepath.Base(command) == "ghost", + // not the actual binary, so a synthetic path is fine here. + cursorCfg := `{"mcpServers":{"ghost":{"command":"/usr/local/bin/ghost","args":["mcp","start"]}}}` + if err := os.WriteFile(cursorPath, []byte(cursorCfg), 0o644); err != nil { + t.Fatal(err) + } + + // Stub every external MCP-client CLI (claude / codex / gemini, etc.) + // to behave as if the binary is not installed. Detection helpers treat + // exec.ErrNotFound as "not configured", which keeps the test + // hermetic regardless of what's actually on the developer's PATH. + withMCPClientCommandRunner(t, func(_ context.Context, _ string, _ ...string) ([]byte, error) { + return nil, exec.ErrNotFound + }) + + setup := func(m *mock.MockClientWithResponsesInterface) { + m.EXPECT(). + AuthInfoWithResponse(validCtx). + Return(&api.AuthInfoResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &api.AuthInfo{ + Type: api.AuthInfoType("user"), + User: &api.UserInfo{Email: "you@example.com"}, + }, + }, nil).AnyTimes() + } + + result := runCommand(t, []string{"init", "--skip-if-configured"}, setup, + withIsTerminal(false), + ) + if result.err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", result.err, result.stderr) + } + if !strings.Contains(result.stderr, "Ghost is already fully configured") { + t.Fatalf("expected 'already fully configured' on stderr, got:\nstderr: %s", result.stderr) + } +} diff --git a/internal/cmd/mcp_install.go b/internal/cmd/mcp_install.go index 464f838..abae033 100644 --- a/internal/cmd/mcp_install.go +++ b/internal/cmd/mcp_install.go @@ -10,12 +10,10 @@ import ( "os" "os/exec" "path/filepath" - "sort" - "strconv" + "slices" "strings" "time" - tea "charm.land/bubbletea/v2" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/tw" "github.com/spf13/cobra" @@ -55,7 +53,7 @@ const ( ) // buildMCPInstallCmd creates the install subcommand for configuring editors -func buildMCPInstallCmd(app *common.App) *cobra.Command { +func buildMCPInstallCmd(_ *common.App) *cobra.Command { var noBackup bool var jsonOutput bool var yamlOutput bool @@ -77,8 +75,8 @@ The command will: - Merge with existing MCP server configurations (doesn't overwrite other servers) - Validate the configuration after installation -Pass "all" to configure every supported client. If no client is specified, you'll be prompted to select one interactively.`, generateSupportedEditorsHelp()), - Example: ` # Interactive client selection +Pass "all" to configure every supported client. If no client is specified, you'll be prompted to pick one or more clients interactively.`, generateSupportedEditorsHelp()), + Example: ` # Interactive client selection (multi-select) ghost mcp install # Install for Claude Code (User scope - available in all projects) @@ -96,28 +94,21 @@ Pass "all" to configure every supported client. If no client is specified, you'l ValidArgs: getValidMCPClientTargetNames(), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - var clientName string - if len(args) == 0 { - if !util.IsTerminal(cmd.InOrStdin()) { - return errors.New("no client specified and stdin is not a terminal; pass the client name or 'all' as an argument") - } - // No client specified, prompt user to select one - var err error - clientName, err = selectClientInteractively(cmd) - if err != nil { - return fmt.Errorf("failed to select client: %w", err) - } - if clientName == "" { - return errors.New("no client selected") + clients, err := resolveMCPClients(cmd, args, mcpInstallSelectionOptions()) + if err != nil { + if errors.Is(err, common.ErrMultiSelectCanceled) || errors.Is(err, common.ErrMultiSelectAborted) { + cmd.PrintErrln("Canceled.") + return nil } - } else { - clientName = args[0] + return err } - - if strings.EqualFold(clientName, mcpAllTarget) { - return installGhostMCPForAllClients(cmd, !noBackup, jsonOutput, yamlOutput) + if err := installGhostMCPForClients(cmd, clients, !noBackup, jsonOutput, yamlOutput); err != nil { + // The per-row errors are already shown in the table, so suppress + // cobra's "Error: ..." line. + cmd.SilenceErrors = true + return err } - return installGhostMCPForClient(cmd, clientName, !noBackup, jsonOutput, yamlOutput) + return nil }, } @@ -130,6 +121,20 @@ Pass "all" to configure every supported client. If no client is specified, you'l return cmd } +func resolveMCPClients(cmd *cobra.Command, args []string, opts mcpClientSelectionOptions) ([]clientConfig, error) { + if len(args) > 0 { + return mcpClientConfigsForTargetName(args[0]) + } + clients, err := selectMCPClientsInteractively(cmd, opts) + if err != nil { + return nil, err + } + if len(clients) == 0 { + return nil, errors.New("no clients selected") + } + return clients, nil +} + // MCPClient represents our internal client types type MCPClient string @@ -161,9 +166,13 @@ type clientConfig struct { // Parameters: serverName (name to register), command (binary path), args (arguments to binary) buildInstallCommand func(serverName, command string, args []string) ([]string, error) buildUninstallCommand func(serverName string) ([]string, error) - // Optionally provide the check function for status detection (via CLI or other means) - // If not provided, will default to JSON config detection + // Optionally provide the check function for status detection (via CLI or other means). + // If not provided, will default to JSON config detection. detectInstallStatus func(ctx context.Context) (MCPClientStatus, string) + // Optionally provide best-effort client install detection. This is used only + // to decide whether interactive install menus should preselect the client; + // users can still manually select any supported client. + detectClientInstalled func(ctx context.Context) bool } // supportedClients defines the clients we support for Ghost MCP installation @@ -181,25 +190,8 @@ var supportedClients = []clientConfig{ buildUninstallCommand: func(serverName string) ([]string, error) { return []string{"claude", "mcp", "remove", "-s", "user", serverName}, nil }, - detectInstallStatus: detectClaudeCodeMCPConfiguration, - }, - { - ClientType: Cursor, - Name: "Cursor", - EditorNames: []string{"cursor"}, - MCPServersPathPrefix: "/mcpServers", - ConfigPaths: []string{ - "~/.cursor/mcp.json", - }, - }, - { - ClientType: Windsurf, - Name: "Windsurf", - EditorNames: []string{"windsurf"}, - MCPServersPathPrefix: "/mcpServers", - ConfigPaths: []string{ - "~/.codeium/windsurf/mcp_config.json", - }, + detectInstallStatus: detectClaudeCodeMCPConfiguration, + detectClientInstalled: detectClientExecutable("claude"), }, { ClientType: Codex, @@ -215,7 +207,25 @@ var supportedClients = []clientConfig{ buildUninstallCommand: func(serverName string) ([]string, error) { return []string{"codex", "mcp", "remove", serverName}, nil }, - detectInstallStatus: detectCodexMCPConfiguration, + detectInstallStatus: detectCodexMCPConfiguration, + detectClientInstalled: detectClientExecutable("codex"), + }, + { + ClientType: Cursor, + Name: "Cursor", + EditorNames: []string{"cursor"}, + MCPServersPathPrefix: "/mcpServers", + ConfigPaths: []string{ + "~/.cursor/mcp.json", + }, + detectClientInstalled: detectClientExecutableOrPath([]string{"cursor"}, []string{ + "/Applications/Cursor.app", + "~/Applications/Cursor.app", + "/usr/share/applications/cursor.desktop", + "~/.local/share/applications/cursor.desktop", + "/opt/Cursor", + "/opt/cursor", + }), }, { ClientType: Gemini, @@ -230,7 +240,41 @@ var supportedClients = []clientConfig{ buildUninstallCommand: func(serverName string) ([]string, error) { return []string{"gemini", "mcp", "remove", "-s", "user", serverName}, nil }, - detectInstallStatus: detectGeminiMCPConfiguration, + detectInstallStatus: detectGeminiMCPConfiguration, + detectClientInstalled: detectClientExecutable("gemini"), + }, + { + ClientType: Antigravity, + Name: "Google Antigravity", + EditorNames: []string{"antigravity", "agy"}, + MCPServersPathPrefix: "/mcpServers", + ConfigPaths: []string{ + "~/.gemini/antigravity/mcp_config.json", + }, + detectClientInstalled: detectClientExecutableOrPath([]string{"antigravity", "agy"}, []string{ + "/Applications/Antigravity.app", + "/Applications/Google Antigravity.app", + "~/Applications/Antigravity.app", + "~/Applications/Google Antigravity.app", + "/usr/share/applications/antigravity.desktop", + "~/.local/share/applications/antigravity.desktop", + }), + }, + { + ClientType: KiroCLI, + Name: "Kiro CLI", + EditorNames: []string{"kiro-cli"}, + MCPServersPathPrefix: "/mcpServers", + ConfigPaths: []string{ + "~/.kiro/settings/mcp.json", + }, + buildInstallCommand: func(serverName, command string, args []string) ([]string, error) { + return []string{"kiro-cli", "mcp", "add", "--name", serverName, "--scope", "global", "--force", "--command", command, "--args", strings.Join(args, ",")}, nil + }, + buildUninstallCommand: func(serverName string) ([]string, error) { + return []string{"kiro-cli", "mcp", "remove", "--name", serverName, "--scope", "global"}, nil + }, + detectClientInstalled: detectClientExecutable("kiro-cli"), }, { ClientType: VSCode, @@ -253,33 +297,57 @@ var supportedClients = []clientConfig{ } return []string{"code", "--add-mcp", string(j)}, nil }, + detectClientInstalled: detectClientExecutable("code"), }, { - ClientType: Antigravity, - Name: "Google Antigravity", - EditorNames: []string{"antigravity", "agy"}, - MCPServersPathPrefix: "/mcpServers", - ConfigPaths: []string{ - "~/.gemini/antigravity/mcp_config.json", - }, - }, - { - ClientType: KiroCLI, - Name: "Kiro CLI", - EditorNames: []string{"kiro-cli"}, + ClientType: Windsurf, + Name: "Windsurf", + EditorNames: []string{"windsurf"}, MCPServersPathPrefix: "/mcpServers", ConfigPaths: []string{ - "~/.kiro/settings/mcp.json", - }, - buildInstallCommand: func(serverName, command string, args []string) ([]string, error) { - return []string{"kiro-cli", "mcp", "add", "--name", serverName, "--scope", "global", "--force", "--command", command, "--args", strings.Join(args, ",")}, nil - }, - buildUninstallCommand: func(serverName string) ([]string, error) { - return []string{"kiro-cli", "mcp", "remove", "--name", serverName, "--scope", "global"}, nil + "~/.codeium/windsurf/mcp_config.json", }, + detectClientInstalled: detectClientExecutableOrPath([]string{"windsurf"}, []string{ + "/Applications/Windsurf.app", + "~/Applications/Windsurf.app", + "/usr/share/applications/windsurf.desktop", + "~/.local/share/applications/windsurf.desktop", + "/opt/Windsurf", + "/opt/windsurf", + }), }, } +func detectClientExecutable(executableNames ...string) func(context.Context) bool { + return detectClientExecutableOrPath(executableNames, nil) +} + +func detectClientExecutableOrPath(executableNames []string, paths []string) func(context.Context) bool { + return func(ctx context.Context) bool { + if ctx.Err() != nil { + return false + } + for _, executableName := range executableNames { + if _, err := exec.LookPath(executableName); err == nil { + return true + } + } + for _, path := range paths { + if _, err := os.Stat(util.ExpandPath(path)); err == nil { + return true + } + } + return false + } +} + +func detectMCPClientInstalled(ctx context.Context, clientCfg clientConfig) bool { + if clientCfg.detectClientInstalled == nil { + return true + } + return clientCfg.detectClientInstalled(ctx) +} + var supportedClientsMap = func() map[MCPClient]clientConfig { m := make(map[MCPClient]clientConfig) for _, client := range supportedClients { @@ -314,48 +382,14 @@ func mcpClientConfigsForTargetName(targetName string) ([]clientConfig, error) { return []clientConfig{*clientCfg}, nil } -// installGhostMCPForClient installs the Ghost MCP server configuration for the specified client. -// This is the Ghost-specific wrapper used by the CLI that handles defaults and success messages. -func installGhostMCPForClient(cmd *cobra.Command, clientName string, createBackup bool, jsonOutput, yamlOutput bool) error { - clientCfg, err := findClientConfig(clientName) - if err != nil { - return err - } - - statusOutput, err := installGhostMCPForClientWithoutOutput(cmd.Context(), *clientCfg, createBackup) - if jsonOutput || yamlOutput { - if outputErr := writeMCPInstallOutput(cmd, []MCPClientStatusOutput{statusOutput}, jsonOutput, yamlOutput); outputErr != nil { - return outputErr - } - if err != nil { - return err - } - return nil - } - if err != nil { - return err - } - - if statusOutput.Status == mcpStatusAlreadyConfigured { - cmd.Printf("Ghost MCP server configuration for %s is already present\n", clientName) - return nil - } - - cmd.Printf("Successfully installed Ghost MCP server configuration for %s\n", clientName) - cmd.Printf("Configuration file: %s\n", statusOutput.Detail) - - cmd.Printf("\nNext steps:\n") - cmd.Printf(" 1. Restart %s to load the new configuration\n", clientName) - cmd.Printf(" 2. The Ghost MCP server will be available as '%s'\n", mcp.ServerName) - - return nil -} - -func installGhostMCPForAllClients(cmd *cobra.Command, createBackup bool, jsonOutput, yamlOutput bool) error { - rows := make([]MCPClientStatusOutput, len(supportedClients)) +// installGhostMCPForClients installs Ghost MCP for the given client configs and +// renders the standard summary in the requested output format. A non-nil error +// is returned (after the table is written) when any single install fails. +func installGhostMCPForClients(cmd *cobra.Command, clients []clientConfig, createBackup bool, jsonOutput, yamlOutput bool) error { + rows := make([]MCPClientStatusOutput, len(clients)) anyError := false - for i, clientCfg := range supportedClients { - row, err := installGhostMCPForClientWithoutOutput(cmd.Context(), clientCfg, createBackup) + for i, clientCfg := range clients { + row, err := installGhostMCPForClient(cmd.Context(), clientCfg, createBackup) rows[i] = row if err != nil { anyError = true @@ -366,8 +400,7 @@ func installGhostMCPForAllClients(cmd *cobra.Command, createBackup bool, jsonOut return err } if anyError { - cmd.SilenceErrors = true - return common.ExitWithCode(common.ExitGeneralError, errors.New("failed to install Ghost MCP server for one or more clients")) + return common.ExitWithCode(common.ExitGeneralError, nil) } return nil } @@ -379,7 +412,19 @@ func writeMCPInstallOutput(cmd *cobra.Command, rows []MCPClientStatusOutput, jso case yamlOutput: return util.SerializeToYAML(cmd.OutOrStdout(), rows) default: - return outputMCPClientResultTable(cmd.OutOrStdout(), rows) + if err := outputMCPClientResultTable(cmd.OutOrStdout(), rows); err != nil { + return err + } + if slices.ContainsFunc(rows, func(row MCPClientStatusOutput) bool { return row.Status == mcpStatusInstalled }) { + cmd.Printf("\nNext steps:\n") + what := "the client(s)" + if len(rows) == 1 { + what = supportedClientsMap[rows[0].Client].Name + } + cmd.Printf(" 1. Restart %s to load the new configuration\n", what) + cmd.Printf(" 2. The Ghost MCP server will be available as '%s'\n", mcp.ServerName) + } + return nil } } @@ -428,7 +473,7 @@ func outputMCPClientResultTable(w io.Writer, rows []MCPClientStatusOutput) error return table.Render() } -func installGhostMCPForClientWithoutOutput(ctx context.Context, clientCfg clientConfig, createBackup bool) (MCPClientStatusOutput, error) { +func installGhostMCPForClient(ctx context.Context, clientCfg clientConfig, createBackup bool) (MCPClientStatusOutput, error) { makeErrorResult := func(err error) (MCPClientStatusOutput, error) { return MCPClientStatusOutput{ @@ -526,7 +571,7 @@ func generateSupportedEditorsHelp() string { for _, cfg := range supportedClients { // Show only the primary editor name in help text primaryName := cfg.EditorNames[0] - result.WriteString(fmt.Sprintf(" %-24s Configure for %s\n", primaryName, cfg.Name)) + fmt.Fprintf(&result, " %-24s Configure for %s\n", primaryName, cfg.Name) } return result.String() } @@ -551,16 +596,8 @@ func findClientConfigFile(clientCfg clientConfig) (string, error) { return util.ExpandPath(clientCfg.ConfigPaths[0]), nil } -// ghostExecutablePathFunc can be overridden in tests to return a fixed path -var ghostExecutablePathFunc = defaultGetGhostExecutablePath - -// getGhostExecutablePath returns the full path to the currently executing Ghost binary -func getGhostExecutablePath() (string, error) { - return ghostExecutablePathFunc() -} - -// defaultGetGhostExecutablePath is the default implementation -func defaultGetGhostExecutablePath() (string, error) { +// path to the binary, but if we're running via 'go run' return "ghost" to allow detection in development without requiring a build +var getGhostExecutablePath = func() (string, error) { ghostPath, err := os.Executable() if err != nil { return "", fmt.Errorf("failed to get executable path: %w", err) @@ -568,146 +605,87 @@ func defaultGetGhostExecutablePath() (string, error) { // If running via 'go run', os.Executable() returns a temp path like /tmp/go-build*/exe/ghost // In this case, return "ghost" assuming it's in PATH for development - if strings.Contains(ghostPath, "go-build") && strings.Contains(ghostPath, "/exe/") { + if (strings.Contains(ghostPath, "/go-build") && strings.Contains(ghostPath, "/exe/")) || strings.Contains(ghostPath, "/Caches/go-build") { return "ghost", nil } return ghostPath, nil } -// ClientOption represents a client choice for interactive selection -type ClientOption struct { - Name string // Display name - ClientName string // Client name to pass to installMCPForClient -} - -// selectClientInteractively prompts the user to select a client using Bubble Tea -func selectClientInteractively(cmd *cobra.Command) (string, error) { - // Build client options from supportedClients - clientOptions := make([]ClientOption, 0, len(supportedClients)) - for _, cfg := range supportedClients { - // Use the first client name as the primary identifier - primaryName := cfg.EditorNames[0] - clientOptions = append(clientOptions, ClientOption{ - Name: cfg.Name, - ClientName: primaryName, - }) - } - - // Sort options alphabetically by name, with "all" pinned at the top. - sort.Slice(clientOptions, func(i, j int) bool { - return clientOptions[i].Name < clientOptions[j].Name - }) - options := append([]ClientOption{{Name: "All supported clients", ClientName: mcpAllTarget}}, clientOptions...) - - model := clientSelectModel{ - options: options, - cursor: 0, - } - - program := tea.NewProgram(model, tea.WithInput(cmd.InOrStdin()), tea.WithOutput(cmd.OutOrStdout())) - finalModel, err := program.Run() - if err != nil { - return "", fmt.Errorf("failed to run editor selection: %w", err) - } - - result := finalModel.(clientSelectModel) - if result.selected == "" { - return "", errors.New("no editor selected") - } - - return result.selected, nil -} - -// clientSelectModel represents the Bubble Tea model for client selection -type clientSelectModel struct { - options []ClientOption - cursor int - selected string - numberBuffer string +type mcpClientSelectionOptions struct { + title string + statusText func(MCPClientStatus, bool) string + selectedByDefault func(MCPClientStatus, bool) bool + dimmedByDefault func(MCPClientStatus, bool) bool + checkInstalled bool } -func (m clientSelectModel) Init() tea.Cmd { - return nil -} - -func (m clientSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch msg.String() { - case "ctrl+c", "q", "esc": - return m, tea.Quit - case "up", "k": - // Clear buffer when using arrows - m.numberBuffer = "" - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - // Clear buffer when using arrows - m.numberBuffer = "" - if m.cursor < len(m.options)-1 { - m.cursor++ - } - case "enter", "space": - m.selected = m.options[m.cursor].ClientName - return m, tea.Quit - case "backspace": - // Handle backspace to remove last character from buffer - if len(m.numberBuffer) > 0 { - m.updateNumberBuffer(m.numberBuffer[:len(m.numberBuffer)-1]) +// mcpInstallSelectionOptions returns the multi-select options for picking +// MCP clients to install Ghost into. Shared by `ghost mcp install` and the +// MCP step of `ghost init`. +func mcpInstallSelectionOptions() mcpClientSelectionOptions { + return mcpClientSelectionOptions{ + title: "Select MCP clients to install:", + checkInstalled: true, + statusText: func(status MCPClientStatus, clientInstalled bool) string { + switch status { + case mcpStatusConfigured: + return "already configured" + case mcpStatusNotConfigured: + if !clientInstalled { + return "not configured (client not detected)" + } + return "not configured" + case mcpStatusError: + return "could not detect" + default: + return string(status) } - case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9": - // Add digit to buffer and update cursor position - m.updateNumberBuffer(m.numberBuffer + msg.String()) - case "ctrl+w": - // Clear buffer - m.numberBuffer = "" - } + }, + selectedByDefault: func(status MCPClientStatus, clientInstalled bool) bool { + return status == mcpStatusNotConfigured && clientInstalled + }, + dimmedByDefault: func(status MCPClientStatus, _ bool) bool { + return status == mcpStatusConfigured + }, } - return m, nil } -// updateNumberBuffer moves the cursor to the editor matching the number buffer -func (m *clientSelectModel) updateNumberBuffer(newBuffer string) { - if newBuffer == "" { - m.numberBuffer = newBuffer - return +var selectMCPClientsInteractively = func(cmd *cobra.Command, options mcpClientSelectionOptions) ([]clientConfig, error) { + if !util.IsTerminal(cmd.InOrStdin()) { + return nil, errors.New("no client specified and stdin is not a terminal; pass the client name or 'all' as an argument") } - // Parse the buffer as a number - num, err := strconv.Atoi(newBuffer) - if err != nil { - return + items := make([]common.MultiSelectItem, len(supportedClients)) + for i, cfg := range supportedClients { + status := detectMCPClientStatus(cmd.Context(), cfg) + clientInstalled := true + if options.checkInstalled { + clientInstalled = detectMCPClientInstalled(cmd.Context(), cfg) + } + items[i] = common.MultiSelectItem{ + Label: cfg.Name, + Status: options.statusText(status.Status, clientInstalled), + Selected: options.selectedByDefault(status.Status, clientInstalled), + Dimmed: options.dimmedByDefault(status.Status, clientInstalled), + } } - // Convert from 1-based to 0-based index and validate bounds - index := num - 1 - if index >= 0 && index < len(m.options) { - m.numberBuffer = newBuffer - m.cursor = index + result, err := common.RunMultiSelect(cmd.Context(), cmd.InOrStdin(), cmd.ErrOrStderr(), options.title, items) + if err != nil { + return nil, fmt.Errorf("failed to run client selection: %w", err) } -} - -func (m clientSelectModel) View() tea.View { - var s strings.Builder - s.WriteString("Select an MCP client to configure:\n\n") - - for i, option := range m.options { - cursor := " " - if m.cursor == i { - cursor = ">" - } - s.WriteString(fmt.Sprintf("%s %d. %s\n", cursor, i+1, option.Name)) + switch result.Reason { + case common.MultiSelectAborted: + return nil, common.ErrMultiSelectAborted + case common.MultiSelectCanceled: + return nil, common.ErrMultiSelectCanceled } - - // Show the current number buffer if user is typing - if m.numberBuffer != "" { - s.WriteString(fmt.Sprintf("\nTyping: %s", m.numberBuffer)) + selected := make([]clientConfig, len(result.Indices)) + for i, idx := range result.Indices { + selected[i] = supportedClients[idx] } - - s.WriteString("\nUse up/down arrows or number keys to navigate, enter to select, q to quit") - return tea.NewView(s.String()) + return selected, nil } // addMCPServerViaCLI adds an MCP server using a CLI command configured in clientConfig. diff --git a/internal/cmd/mcp_install_test.go b/internal/cmd/mcp_install_test.go index 4e9a196..6b8b569 100644 --- a/internal/cmd/mcp_install_test.go +++ b/internal/cmd/mcp_install_test.go @@ -75,12 +75,16 @@ func TestMCPInstallCmd(t *testing.T) { name: "single client text output", args: []string{"mcp", "install", "cursor", "--no-backup"}, ghostPath: "/opt/bin/ghost", - wantStdout: "Successfully installed Ghost MCP server configuration for cursor\n" + - "Configuration file: {{HOME}}/.cursor/mcp.json\n" + - "\n" + - "Next steps:\n" + - " 1. Restart cursor to load the new configuration\n" + - " 2. The Ghost MCP server will be available as 'ghost'\n", + wantStdoutFunc: func(homeDir string) string { + configPath := homeDir + "/.cursor/mcp.json" + detailPad := strings.Repeat(" ", len(configPath)-len("DETAIL")) + return "CLIENT STATUS DETAIL" + detailPad + " \n" + + "Cursor installed " + configPath + " \n" + + "\n" + + "Next steps:\n" + + " 1. Restart Cursor to load the new configuration\n" + + " 2. The Ghost MCP server will be available as 'ghost'\n" + }, after: assertCursorHasGhost, }, { @@ -130,21 +134,25 @@ func TestMCPInstallCmd(t *testing.T) { ` "status": "already configured"` + "\n" + " },\n" + " {\n" + + ` "client": "codex",` + "\n" + + ` "status": "already configured"` + "\n" + + " },\n" + + " {\n" + ` "client": "cursor",` + "\n" + ` "status": "installed",` + "\n" + ` "detail": "{{HOME}}/.cursor/mcp.json"` + "\n" + " },\n" + " {\n" + - ` "client": "windsurf",` + "\n" + - ` "status": "installed",` + "\n" + - ` "detail": "{{HOME}}/.codeium/windsurf/mcp_config.json"` + "\n" + + ` "client": "gemini",` + "\n" + + ` "status": "already configured"` + "\n" + " },\n" + " {\n" + - ` "client": "codex",` + "\n" + - ` "status": "already configured"` + "\n" + + ` "client": "antigravity",` + "\n" + + ` "status": "installed",` + "\n" + + ` "detail": "{{HOME}}/.gemini/antigravity/mcp_config.json"` + "\n" + " },\n" + " {\n" + - ` "client": "gemini",` + "\n" + + ` "client": "kiro-cli",` + "\n" + ` "status": "already configured"` + "\n" + " },\n" + " {\n" + @@ -152,13 +160,9 @@ func TestMCPInstallCmd(t *testing.T) { ` "status": "already configured"` + "\n" + " },\n" + " {\n" + - ` "client": "antigravity",` + "\n" + + ` "client": "windsurf",` + "\n" + ` "status": "installed",` + "\n" + - ` "detail": "{{HOME}}/.gemini/antigravity/mcp_config.json"` + "\n" + - " },\n" + - " {\n" + - ` "client": "kiro-cli",` + "\n" + - ` "status": "already configured"` + "\n" + + ` "detail": "{{HOME}}/.codeium/windsurf/mcp_config.json"` + "\n" + " }\n" + "]\n", after: assertCursorHasGhost, @@ -167,3 +171,19 @@ func TestMCPInstallCmd(t *testing.T) { runMCPCmdTests(t, tests) } + +func TestMCPInstallSelectionOptions_DefaultSelection(t *testing.T) { + opts := mcpInstallSelectionOptions() + if !opts.selectedByDefault(mcpStatusNotConfigured, true) { + t.Fatal("not-configured installed clients should be selected by default") + } + if opts.selectedByDefault(mcpStatusNotConfigured, false) { + t.Fatal("not-configured clients that are not detected should not be selected by default") + } + if opts.selectedByDefault(mcpStatusConfigured, true) { + t.Fatal("already-configured clients should not be selected by default") + } + if opts.selectedByDefault(mcpStatusError, true) { + t.Fatal("clients with detection errors should not be selected by default") + } +} diff --git a/internal/cmd/mcp_status.go b/internal/cmd/mcp_status.go index 252daae..d948257 100644 --- a/internal/cmd/mcp_status.go +++ b/internal/cmd/mcp_status.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -61,18 +60,14 @@ A configured client must have a Ghost MCP server entry named "ghost" that runs " } results := detectMCPClientStatuses(cmd.Context(), clients) - output := make([]MCPClientStatusOutput, len(results)) - for i, result := range results { - output[i] = MCPClientStatusOutput(result) - } switch { case jsonOutput: - err = util.SerializeToJSON(cmd.OutOrStdout(), output) + err = util.SerializeToJSON(cmd.OutOrStdout(), results) case yamlOutput: - err = util.SerializeToYAML(cmd.OutOrStdout(), output) + err = util.SerializeToYAML(cmd.OutOrStdout(), results) default: - err = outputMCPClientStatuses(cmd.OutOrStdout(), output) + err = outputMCPClientResultTable(cmd.OutOrStdout(), results) } if err != nil { return err @@ -139,18 +134,6 @@ func mcpStatusExitCode(results []MCPClientStatusOutput) int { return mcpExitNoneConfigured } -func outputMCPClientStatuses(w io.Writer, statuses []MCPClientStatusOutput) error { - rows := make([]MCPClientStatusOutput, len(statuses)) - for i, status := range statuses { - rows[i] = MCPClientStatusOutput{ - Client: status.Client, - Status: status.Status, - Detail: status.Detail, - } - } - return outputMCPClientResultTable(w, rows) -} - func detectClaudeCodeMCPConfiguration(ctx context.Context) (MCPClientStatus, string) { output, err := runMCPClientCommand(ctx, "claude", "mcp", "get", mcp.ServerName) outputString := string(output) diff --git a/internal/cmd/mcp_status_test.go b/internal/cmd/mcp_status_test.go index 8aff320..67d93df 100644 --- a/internal/cmd/mcp_status_test.go +++ b/internal/cmd/mcp_status_test.go @@ -49,31 +49,31 @@ func TestMCPStatusCmd(t *testing.T) { ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "cursor",` + "\n" + + ` "client": "codex",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "windsurf",` + "\n" + + ` "client": "cursor",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "codex",` + "\n" + + ` "client": "gemini",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "gemini",` + "\n" + + ` "client": "antigravity",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "vscode",` + "\n" + + ` "client": "kiro-cli",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "antigravity",` + "\n" + + ` "client": "vscode",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "kiro-cli",` + "\n" + + ` "client": "windsurf",` + "\n" + ` "status": "not configured"` + "\n" + " }\n" + "]\n" @@ -180,13 +180,13 @@ func TestMCPStatusCmd(t *testing.T) { runner: notFoundRunner, wantStdout: "CLIENT STATUS \n" + "Claude Code not configured \n" + - "Cursor configured \n" + - "Windsurf not configured \n" + "Codex not configured \n" + + "Cursor configured \n" + "Gemini CLI not configured \n" + - "VS Code not configured \n" + "Google Antigravity not configured \n" + - "Kiro CLI not configured \n", + "Kiro CLI not configured \n" + + "VS Code not configured \n" + + "Windsurf not configured \n", }, { name: "all clients no args all unconfigured exits two", @@ -223,32 +223,32 @@ func TestMCPStatusCmd(t *testing.T) { ` "status": "not configured"` + "\n" + " },\n" + " {\n" + + ` "client": "codex",` + "\n" + + ` "status": "error",` + "\n" + + ` "detail": "failed to parse codex mcp list output: invalid character 'o' in literal null (expecting 'u')"` + "\n" + + " },\n" + + " {\n" + ` "client": "cursor",` + "\n" + ` "status": "configured"` + "\n" + " },\n" + " {\n" + - ` "client": "windsurf",` + "\n" + + ` "client": "gemini",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "codex",` + "\n" + - ` "status": "error",` + "\n" + - ` "detail": "failed to parse codex mcp list output: invalid character 'o' in literal null (expecting 'u')"` + "\n" + - " },\n" + - " {\n" + - ` "client": "gemini",` + "\n" + + ` "client": "antigravity",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "vscode",` + "\n" + + ` "client": "kiro-cli",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "antigravity",` + "\n" + + ` "client": "vscode",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "kiro-cli",` + "\n" + + ` "client": "windsurf",` + "\n" + ` "status": "not configured"` + "\n" + " }\n" + "]\n", diff --git a/internal/cmd/mcp_test.go b/internal/cmd/mcp_test.go index 8178308..4daf740 100644 --- a/internal/cmd/mcp_test.go +++ b/internal/cmd/mcp_test.go @@ -25,7 +25,7 @@ type mcpCmdTest struct { // in values is replaced with the absolute HOME path. files map[string]string - // ghostPath stubs ghostExecutablePathFunc so the install command produces + // ghostPath stubs getGhostExecutablePath so the install command produces // deterministic output independent of the real binary location. ghostPath string @@ -33,9 +33,9 @@ type mcpCmdTest struct { // install/uninstall invocations route through this stub. runner mcpClientCommandRunner - // uninstallSelector stubs the interactive client selector used by - // `ghost mcp uninstall` when no client argument is provided. - uninstallSelector func(*cobra.Command) (string, error) + // clientSelector stubs the interactive client selector used + // when no client argument is provided. + clientSelector func(*cobra.Command, mcpClientSelectionOptions) ([]clientConfig, error) // stdin / isTerminal exercise the interactive prompt code paths. stdin string @@ -46,6 +46,12 @@ type mcpCmdTest struct { wantStdout string wantStderr string + // wantStdoutFunc, when set, takes precedence over wantStdout. Use this when + // the expected output depends on the per-test HOME directory in a way that + // can't be expressed by simple "{{HOME}}" substitution — e.g. tablewriter + // output whose column widths depend on the length of a variable file path. + wantStdoutFunc func(homeDir string) string + // wantErr asserts the result error's Error() string. When set and // wantStderr is empty, wantStderr is derived as "Error: \n". wantErr string @@ -81,10 +87,10 @@ func runMCPCmdTests(t *testing.T, tests []mcpCmdTest) { if tt.runner != nil { withMCPClientCommandRunner(t, tt.runner) } - if tt.uninstallSelector != nil { - original := uninstallTargetSelector - uninstallTargetSelector = tt.uninstallSelector - t.Cleanup(func() { uninstallTargetSelector = original }) + if tt.clientSelector != nil { + original := selectMCPClientsInteractively + selectMCPClientsInteractively = tt.clientSelector + t.Cleanup(func() { selectMCPClientsInteractively = original }) } opts := []runOption{withEnv("HOME", homeDir)} @@ -110,7 +116,13 @@ func runMCPCmdTests(t *testing.T, tests []mcpCmdTest) { } } - assertOutput(t, result.stdout, expand(tt.wantStdout)) + wantStdout := tt.wantStdout + if tt.wantStdoutFunc != nil { + wantStdout = tt.wantStdoutFunc(homeDir) + } else { + wantStdout = expand(wantStdout) + } + assertOutput(t, result.stdout, wantStdout) wantStderr := tt.wantStderr if wantStderr == "" && tt.wantErr != "" && tt.wantExitCode == 0 { @@ -130,9 +142,9 @@ func runMCPCmdTests(t *testing.T, tests []mcpCmdTest) { // real binary location. func withGhostExecutablePath(t *testing.T, path string) { t.Helper() - original := ghostExecutablePathFunc - ghostExecutablePathFunc = func() (string, error) { return path, nil } - t.Cleanup(func() { ghostExecutablePathFunc = original }) + original := getGhostExecutablePath + getGhostExecutablePath = func() (string, error) { return path, nil } + t.Cleanup(func() { getGhostExecutablePath = original }) } // withMCPClientCommandRunner overrides runMCPClientCommand for the duration diff --git a/internal/cmd/mcp_uninstall.go b/internal/cmd/mcp_uninstall.go index 784287b..7b10425 100644 --- a/internal/cmd/mcp_uninstall.go +++ b/internal/cmd/mcp_uninstall.go @@ -17,12 +17,6 @@ import ( "github.com/timescale/ghost/internal/util" ) -// uninstallTargetSelector is the function used to select an uninstall target -// interactively when no client argument is provided. It is a package-level -// variable so tests can override it without spinning up a real Bubble Tea -// program (which requires a TTY). -var uninstallTargetSelector = selectClientInteractively - func buildMCPUninstallCmd(_ *common.App) *cobra.Command { var noBackup bool var jsonOutput bool @@ -34,9 +28,9 @@ func buildMCPUninstallCmd(_ *common.App) *cobra.Command { Short: "Uninstall Ghost MCP server configuration from a client", Long: `Uninstall the Ghost MCP server configuration from a supported MCP client. -Pass "all" to uninstall from all supported clients. If no client is specified, you'll be prompted to select one interactively. +Pass "all" to uninstall from all supported clients. If no client is specified, you'll be prompted to select one or more interactively. Only the Ghost MCP server entry named "ghost" is removed; other MCP server entries are left untouched.`, - Example: ` # Interactive client selection + Example: ` # Interactive client selection (multi-select) ghost mcp uninstall # Uninstall from Cursor @@ -51,29 +45,44 @@ Only the Ghost MCP server entry named "ghost" is removed; other MCP server entri ValidArgs: getValidMCPClientTargetNames(), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - targetName, err := selectedMCPUninstallTarget(cmd, args) - if err != nil { - return err - } - - clients, err := mcpClientConfigsForTargetName(targetName) + clients, err := resolveMCPClients(cmd, args, mcpClientSelectionOptions{ + title: "Select MCP clients to uninstall:", + statusText: func(status MCPClientStatus, _ bool) string { + switch status { + case mcpStatusConfigured: + return "installed" + case mcpStatusNotConfigured: + return "not installed" + case mcpStatusError: + return "could not detect" + default: + return string(status) + } + }, + selectedByDefault: func(status MCPClientStatus, _ bool) bool { + return status == mcpStatusConfigured + }, + dimmedByDefault: func(status MCPClientStatus, _ bool) bool { + return status == mcpStatusNotConfigured + }, + }) if err != nil { + if errors.Is(err, common.ErrMultiSelectCanceled) || errors.Is(err, common.ErrMultiSelectAborted) { + cmd.PrintErrln("Canceled.") + return nil + } return err } results := uninstallGhostMCPFromClients(cmd.Context(), clients, !noBackup) - output := make([]MCPClientStatusOutput, len(results)) - for i, result := range results { - output[i] = MCPClientStatusOutput(result) - } switch { case jsonOutput: - err = util.SerializeToJSON(cmd.OutOrStdout(), output) + err = util.SerializeToJSON(cmd.OutOrStdout(), results) case yamlOutput: - err = util.SerializeToYAML(cmd.OutOrStdout(), output) + err = util.SerializeToYAML(cmd.OutOrStdout(), results) default: - err = outputMCPClientResultTable(cmd.OutOrStdout(), output) + err = outputMCPClientResultTable(cmd.OutOrStdout(), results) } if err != nil { return err @@ -96,24 +105,6 @@ Only the Ghost MCP server entry named "ghost" is removed; other MCP server entri return cmd } -func selectedMCPUninstallTarget(cmd *cobra.Command, args []string) (string, error) { - if len(args) > 0 { - return args[0], nil - } - if !util.IsTerminal(cmd.InOrStdin()) { - return "", errors.New("no client specified and stdin is not a terminal; pass the client name or 'all' as an argument") - } - - targetName, err := uninstallTargetSelector(cmd) - if err != nil { - return "", fmt.Errorf("failed to select client: %w", err) - } - if targetName == "" { - return "", errors.New("no client selected") - } - return targetName, nil -} - func uninstallGhostMCPFromClients(ctx context.Context, clients []clientConfig, createBackup bool) []MCPClientStatusOutput { results := make([]MCPClientStatusOutput, len(clients)) for i, clientCfg := range clients { diff --git a/internal/cmd/mcp_uninstall_test.go b/internal/cmd/mcp_uninstall_test.go index da42be5..6d04314 100644 --- a/internal/cmd/mcp_uninstall_test.go +++ b/internal/cmd/mcp_uninstall_test.go @@ -15,7 +15,6 @@ import ( func TestMCPUninstallCmd(t *testing.T) { falseVal := false - trueVal := true cursorConfiguredFile := `{ "mcpServers": { @@ -237,19 +236,23 @@ func TestMCPUninstallCmd(t *testing.T) { ` "status": "not configured"` + "\n" + " },\n" + " {\n" + + ` "client": "codex",` + "\n" + + ` "status": "not configured"` + "\n" + + " },\n" + + " {\n" + ` "client": "cursor",` + "\n" + ` "status": "uninstalled"` + "\n" + " },\n" + " {\n" + - ` "client": "windsurf",` + "\n" + + ` "client": "gemini",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "codex",` + "\n" + + ` "client": "antigravity",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "gemini",` + "\n" + + ` "client": "kiro-cli",` + "\n" + ` "status": "not configured"` + "\n" + " },\n" + " {\n" + @@ -257,11 +260,7 @@ func TestMCPUninstallCmd(t *testing.T) { ` "status": "not configured"` + "\n" + " },\n" + " {\n" + - ` "client": "antigravity",` + "\n" + - ` "status": "not configured"` + "\n" + - " },\n" + - " {\n" + - ` "client": "kiro-cli",` + "\n" + + ` "client": "windsurf",` + "\n" + ` "status": "not configured"` + "\n" + " }\n" + "]\n", @@ -281,10 +280,9 @@ func TestMCPUninstallCmd(t *testing.T) { files: map[string]string{ ".cursor/mcp.json": cursorConfiguredFile, }, - uninstallSelector: func(_ *cobra.Command) (string, error) { - return "cursor", nil + clientSelector: func(_ *cobra.Command, _ mcpClientSelectionOptions) ([]clientConfig, error) { + return []clientConfig{supportedClientsMap[Cursor]}, nil }, - isTerminal: &trueVal, wantStdout: "CLIENT STATUS \n" + "Cursor uninstalled \n", }, diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 62cd236..e8caec6 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -102,6 +102,7 @@ func buildRootCmd() (*cobra.Command, *common.App, error) { cmd.AddCommand(buildVersionCmd(app)) cmd.AddCommand(buildConfigCmd(app)) cmd.AddCommand(buildMCPCmd(app)) + cmd.AddCommand(buildInitCmd(app)) cmd.AddCommand(buildLoginCmd(app)) cmd.AddCommand(buildLogoutCmd(app)) cmd.AddCommand(buildCreateCmd(app)) diff --git a/internal/cmd/version.go b/internal/cmd/version.go index c35861d..02b07cb 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -21,7 +21,7 @@ type VersionOutput struct { Platform string `json:"platform"` } -func buildVersionCmd(app *common.App) *cobra.Command { +func buildVersionCmd(_ *common.App) *cobra.Command { var jsonOutput bool var yamlOutput bool var bareOutput bool diff --git a/internal/common/multiselect.go b/internal/common/multiselect.go new file mode 100644 index 0000000..92204ef --- /dev/null +++ b/internal/common/multiselect.go @@ -0,0 +1,226 @@ +package common + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" +) + +// MultiSelectItem describes a single row of a multi-select prompt. +type MultiSelectItem struct { + // Label is the primary text shown to the right of the checkbox. + Label string + // Status is dim explanatory text shown after the label (optional). + Status string + // Selected controls the initial checked state of the row. + Selected bool + // Dimmed renders the entire row (checkbox, label, status) in a dim + // style. Used to mark rows the caller has detected as already + // configured so the user understands why they weren't pre-selected. + Dimmed bool +} + +// MultiSelectReason describes how a multi-select prompt completed. +type MultiSelectReason int + +const ( + // MultiSelectConfirmed means the user pressed enter. + MultiSelectConfirmed MultiSelectReason = iota + // MultiSelectCanceled means the user pressed esc or q. Callers can + // treat this as a soft cancel (e.g. return to a parent menu). + MultiSelectCanceled + // MultiSelectAborted means the user pressed ctrl+c. Callers should + // bubble this up and stop the surrounding workflow. + MultiSelectAborted +) + +// MultiSelectResult is the outcome of running a multi-select prompt. +type MultiSelectResult struct { + Reason MultiSelectReason + Indices []int // populated only when Reason == MultiSelectConfirmed +} + +// Canned errors to correspond with the MultiSelectReason values +var ErrMultiSelectAborted = errors.New("multi-select aborted") +var ErrMultiSelectCanceled = errors.New("multi-select canceled") + +// RunMultiSelect renders an interactive multi-select prompt and returns the +// user's selection. ctrl+c is reported as MultiSelectAborted; esc/q as +// MultiSelectCanceled. +func RunMultiSelect(ctx context.Context, in io.Reader, out io.Writer, title string, items []MultiSelectItem) (*MultiSelectResult, error) { + if len(items) == 0 { + return &MultiSelectResult{Reason: MultiSelectConfirmed}, nil + } + + initial := newMultiSelectModel(title, items) + + program := tea.NewProgram(initial, + tea.WithInput(in), + tea.WithOutput(out), + tea.WithContext(ctx), + tea.WithoutSignalHandler(), + ) + + finalModel, err := program.Run() + if err != nil { + return nil, fmt.Errorf("multi-select failed: %w", err) + } + + m := finalModel.(multiSelectModel) + return &MultiSelectResult{Reason: m.reason, Indices: m.selectedIndices()}, nil +} + +// multiSelectModel is the BubbleTea model backing RunMultiSelect. +type multiSelectModel struct { + title string + items []MultiSelectItem + cursor int + reason MultiSelectReason + done bool +} + +func newMultiSelectModel(title string, items []MultiSelectItem) multiSelectModel { + copied := make([]MultiSelectItem, len(items)) + copy(copied, items) + return multiSelectModel{ + title: title, + items: copied, + reason: MultiSelectCanceled, + } +} + +func (m multiSelectModel) Init() tea.Cmd { return nil } + +func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + + switch keyMsg.String() { + case "ctrl+c": + m.reason = MultiSelectAborted + m.done = true + return m, tea.Quit + case "esc", "q": + m.reason = MultiSelectCanceled + m.done = true + return m, tea.Quit + case "enter": + m.reason = MultiSelectConfirmed + m.done = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case " ", "space": + m.items[m.cursor].Selected = !m.items[m.cursor].Selected + case "1", "2", "3", "4", "5", "6", "7", "8", "9": + idx := int(keyMsg.String()[0] - '1') + if idx < len(m.items) { + m.cursor = idx + m.items[idx].Selected = !m.items[idx].Selected + } + } + return m, nil +} + +// Style cache. Created lazily on the first View() call. +var ( + multiSelectTitleStyle = lipgloss.NewStyle().Bold(true) + multiSelectStatusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + multiSelectDimmedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + multiSelectCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + multiSelectCheckStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) + multiSelectHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) +) + +const ( + // Checkbox glyphs. The check is rendered in green; the empty box stays + // in the terminal's default color so the row reads "to do" not "absent". + multiSelectCheckedGlyph = "✓" + multiSelectUncheckedGlyph = " " +) + +func (m multiSelectModel) View() tea.View { + if m.done { + // Leave the terminal clean once the program quits — BubbleTea + // preserves the final view, but we don't want a stale prompt + // hanging around above the subsequent command output. + return tea.NewView("") + } + + var b strings.Builder + if m.title != "" { + b.WriteString(multiSelectTitleStyle.Render(m.title)) + b.WriteString("\n\n") + } + + // Compute the maximum width of the "[✓] N. Label" prefix so the + // status column lines up across rows. Use a constant checked glyph for + // width measurement so toggling selection doesn't shift the column. + labelWidths := make([]int, len(m.items)) + maxLabelWidth := 0 + for i, item := range m.items { + w := lipgloss.Width(fmt.Sprintf("[%s] %d. %s", multiSelectCheckedGlyph, i+1, item.Label)) + labelWidths[i] = w + if w > maxLabelWidth { + maxLabelWidth = w + } + } + + for i, item := range m.items { + cursor := " " + if m.cursor == i { + cursor = multiSelectCursorStyle.Render("> ") + } + var checkbox string + if item.Selected { + checkbox = "[" + multiSelectCheckStyle.Render(multiSelectCheckedGlyph) + "]" + } else { + checkbox = "[" + multiSelectUncheckedGlyph + "]" + } + label := fmt.Sprintf(" %d. %s", i+1, item.Label) + if item.Dimmed { + label = multiSelectDimmedStyle.Render(label) + } + padding := strings.Repeat(" ", maxLabelWidth-labelWidths[i]+2) + b.WriteString(cursor) + b.WriteString(checkbox) + b.WriteString(label) + if item.Status != "" { + b.WriteString(padding) + b.WriteString(multiSelectStatusStyle.Render(item.Status)) + } + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(multiSelectHelpStyle.Render(" ↑/↓ or j/k to navigate, space or 1-9 to toggle, enter to confirm, esc to cancel")) + b.WriteString("\n") + return tea.NewView(b.String()) +} + +// selectedIndices returns the indices of items currently checked. +func (m multiSelectModel) selectedIndices() []int { + if m.reason != MultiSelectConfirmed { + return nil + } + indices := make([]int, 0, len(m.items)) + for i, item := range m.items { + if item.Selected { + indices = append(indices, i) + } + } + return indices +} diff --git a/internal/common/multiselect_test.go b/internal/common/multiselect_test.go new file mode 100644 index 0000000..dd1d50a --- /dev/null +++ b/internal/common/multiselect_test.go @@ -0,0 +1,150 @@ +package common + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +// keyMsg builds a synthetic KeyPressMsg whose String() matches the given +// keystroke. Letters and digits go through Text; special keys go through +// Code. +func keyMsg(s string) tea.KeyPressMsg { + switch s { + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + case "esc": + return tea.KeyPressMsg{Code: tea.KeyEscape} + case "space": + return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} + case " ": + return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case "ctrl+c": + return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} + } + if len(s) == 1 { + return tea.KeyPressMsg{Code: rune(s[0]), Text: s} + } + panic("unsupported key: " + s) +} + +func runKeys(m multiSelectModel, keys ...string) multiSelectModel { + for _, k := range keys { + updated, _ := m.Update(keyMsg(k)) + m = updated.(multiSelectModel) + } + return m +} + +func sampleItems() []MultiSelectItem { + return []MultiSelectItem{ + {Label: "First", Selected: true}, + {Label: "Second", Selected: false}, + {Label: "Third", Selected: true, Dimmed: true}, + } +} + +func TestMultiSelectModel_CursorMovement(t *testing.T) { + m := newMultiSelectModel("title", sampleItems()) + + m = runKeys(m, "down", "down") + if m.cursor != 2 { + t.Fatalf("expected cursor at 2 after two downs, got %d", m.cursor) + } + m = runKeys(m, "down") + if m.cursor != 2 { + t.Fatalf("cursor should clamp at last index, got %d", m.cursor) + } + m = runKeys(m, "up", "up", "up") + if m.cursor != 0 { + t.Fatalf("cursor should clamp at first index, got %d", m.cursor) + } + + m = runKeys(m, "j", "j") + if m.cursor != 2 { + t.Fatalf("expected j to move down, got %d", m.cursor) + } + m = runKeys(m, "k") + if m.cursor != 1 { + t.Fatalf("expected k to move up, got %d", m.cursor) + } +} + +func TestMultiSelectModel_ToggleSpace(t *testing.T) { + m := newMultiSelectModel("title", sampleItems()) + m = runKeys(m, " ") + if m.items[0].Selected { + t.Fatalf("expected item 0 to be unselected after toggle") + } + m = runKeys(m, "down", " ") + if !m.items[1].Selected { + t.Fatalf("expected item 1 to be selected after toggle") + } +} + +func TestMultiSelectModel_NumericToggle(t *testing.T) { + m := newMultiSelectModel("title", sampleItems()) + // Press "2" — should jump to index 1 and toggle. + m = runKeys(m, "2") + if m.cursor != 1 { + t.Fatalf("expected cursor at index 1 after pressing 2, got %d", m.cursor) + } + if !m.items[1].Selected { + t.Fatalf("expected item 1 selected after pressing 2") + } + // Press "9" — out of range, no-op. + m = runKeys(m, "9") + if m.cursor != 1 { + t.Fatalf("cursor should not move on out-of-range digit, got %d", m.cursor) + } +} + +func TestMultiSelectModel_EnterConfirms(t *testing.T) { + m := newMultiSelectModel("title", sampleItems()) + updated, cmd := m.Update(keyMsg("enter")) + m = updated.(multiSelectModel) + if m.reason != MultiSelectConfirmed { + t.Fatalf("expected MultiSelectConfirmed, got %v", m.reason) + } + if cmd == nil { + t.Fatal("expected tea.Quit cmd from enter") + } + indices := m.selectedIndices() + if len(indices) != 2 || indices[0] != 0 || indices[1] != 2 { + t.Fatalf("expected indices [0 2], got %v", indices) + } +} + +func TestMultiSelectModel_EscapeCancels(t *testing.T) { + m := newMultiSelectModel("title", sampleItems()) + updated, _ := m.Update(keyMsg("esc")) + m = updated.(multiSelectModel) + if m.reason != MultiSelectCanceled { + t.Fatalf("expected MultiSelectCanceled, got %v", m.reason) + } + if m.selectedIndices() != nil { + t.Fatalf("expected nil indices on cancel") + } +} + +func TestMultiSelectModel_QCancels(t *testing.T) { + m := newMultiSelectModel("title", sampleItems()) + updated, _ := m.Update(keyMsg("q")) + m = updated.(multiSelectModel) + if m.reason != MultiSelectCanceled { + t.Fatalf("expected MultiSelectCanceled on q, got %v", m.reason) + } +} + +func TestMultiSelectModel_CtrlCAborts(t *testing.T) { + m := newMultiSelectModel("title", sampleItems()) + updated, _ := m.Update(keyMsg("ctrl+c")) + m = updated.(multiSelectModel) + if m.reason != MultiSelectAborted { + t.Fatalf("expected MultiSelectAborted, got %v", m.reason) + } +} diff --git a/internal/common/shell_setup.go b/internal/common/shell_setup.go new file mode 100644 index 0000000..fa97083 --- /dev/null +++ b/internal/common/shell_setup.go @@ -0,0 +1,217 @@ +package common + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" +) + +// DetectShellType returns "bash", "zsh", "fish", or "" based on the SHELL +// environment variable. +func DetectShellType() string { + shellName := filepath.Base(os.Getenv("SHELL")) + switch shellName { + case "bash", "zsh", "fish": + return shellName + } + return "" +} + +// DetectShellRC returns the path to the shell rc file that PATH and +// completion snippets should be appended to. Mirrors the heuristic in +// scripts/install.sh. +func DetectShellRC() string { + home, _ := os.UserHomeDir() + shellName := filepath.Base(os.Getenv("SHELL")) + + switch shellName { + case "zsh": + if zdotdir := os.Getenv("ZDOTDIR"); zdotdir != "" { + return filepath.Join(zdotdir, ".zshrc") + } + return filepath.Join(home, ".zshrc") + case "bash": + // On macOS, login shells read .bash_profile, not .bashrc. Prefer + // it when it already exists. + bashProfile := filepath.Join(home, ".bash_profile") + if runtime.GOOS == "darwin" { + if _, err := os.Stat(bashProfile); err == nil { + return bashProfile + } + } + return filepath.Join(home, ".bashrc") + case "fish": + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome == "" { + configHome = filepath.Join(home, ".config") + } + return filepath.Join(configHome, "fish", "config.fish") + } + + // Unknown shell — guess based on OS and existing files. + zshrc := filepath.Join(home, ".zshrc") + if runtime.GOOS == "darwin" { + return zshrc + } + if _, err := os.Stat(zshrc); err == nil { + return zshrc + } + return filepath.Join(home, ".bashrc") +} + +// IsInPath reports whether dir is an element of $PATH. +func IsInPath(dir string) bool { + if dir == "" { + return false + } + return slices.Contains(filepath.SplitList(os.Getenv("PATH")), dir) +} + +// CompletionSnippet returns the shellrc line(s) that source Ghost's +// completion output for the given shell. +func CompletionSnippet(shell, binary string) string { + quotedBinary := shellQuote(binary) + switch shell { + case "fish": + return fmt.Sprintf("%s completion fish | source", quotedBinary) + default: + return fmt.Sprintf("command -v %s >/dev/null 2>&1 && source <(%s completion %s)", quotedBinary, quotedBinary, shell) + } +} + +var shellBareWordRE = regexp.MustCompile(`^[A-Za-z0-9_@%+=:,./-]+$`) + +func shellQuote(value string) string { + if value != "" && shellBareWordRE.MatchString(value) { + return value + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +// compinitMarkerRE matches existing references to compinit or to common zsh +// frameworks that already initialize completions. When none of these markers +// are present, the completion snippet won't work without an explicit +// compinit call. +var compinitMarkerRE = regexp.MustCompile(`compinit|oh-my-zsh|prezto|zinit|antigen|zplug|zgenom`) + +// ShellRCNeedsCompinit reports whether a zsh rc file is missing a compinit +// call. Returns false for non-zsh shells. Comment lines are ignored so a +// commented-out marker (e.g. `# compinit`) doesn't suppress the snippet. +func ShellRCNeedsCompinit(shell, rcPath string) bool { + if shell != "zsh" { + return false + } + data, err := readShellRCFileIfExists(rcPath) + if err != nil { + return true + } + for line := range strings.SplitSeq(string(data), "\n") { + if strings.HasPrefix(strings.TrimLeft(line, " \t"), "#") { + continue + } + if compinitMarkerRE.MatchString(line) { + return false + } + } + return true +} + +// ShellRCMentions reports whether the file at rcPath contains needle. A +// missing file is treated as "not mentioned" with no error so callers can +// distinguish "doesn't reference" from "couldn't read". +func ShellRCMentions(rcPath, needle string) (bool, error) { + data, err := readShellRCFileIfExists(rcPath) + if err != nil { + return false, err + } + return strings.Contains(string(data), needle), nil +} + +var ghostCompletionMentionRE = regexp.MustCompile(`(?:^|[^[:alnum:]_-])ghost(?:\.exe)?['"]?[[:space:]]+completion(?:[[:space:]]|$)`) + +// ShellRCMentionsGhostCompletion reports whether the rc file appears to +// configure Ghost shell completions. It recognizes both the historical +// `ghost completion ...` form and the absolute-path form written by +// AppendCompletionsToShellRC. Comment lines are ignored so a commented-out +// snippet is not treated as configured. +func ShellRCMentionsGhostCompletion(rcPath string) (bool, error) { + data, err := readShellRCFileIfExists(rcPath) + if err != nil { + return false, err + } + for line := range strings.SplitSeq(string(data), "\n") { + if strings.HasPrefix(strings.TrimLeft(line, " \t"), "#") { + continue + } + if ghostCompletionMentionRE.MatchString(line) { + return true, nil + } + } + return false, nil +} + +func readShellRCFileIfExists(rcPath string) ([]byte, error) { + data, err := os.ReadFile(rcPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + return data, nil +} + +// AppendPathToShellRC adds a shell snippet to rcPath that prepends +// installDir to $PATH. For fish, emits an equivalent `set -gx PATH` snippet. +func AppendPathToShellRC(rcPath, installDir string) error { + var snippet string + if strings.HasSuffix(rcPath, "config.fish") { + // Fish performs word-splitting on unquoted arguments, so an install + // dir containing whitespace (e.g. "/Applications/Ghost CLI/bin") + // would otherwise be split into separate PATH entries. + snippet = fmt.Sprintf("\n# Added by ghost init\nset -gx PATH %s $PATH\n", shellQuote(installDir)) + } else { + snippet = fmt.Sprintf("\n# Added by ghost init\nexport PATH=\"%s:$PATH\"\n", installDir) + } + return appendToShellRC(rcPath, snippet) +} + +// AppendCompletionsToShellRC adds the completion snippet (plus a compinit +// block, when needed) to rcPath. +func AppendCompletionsToShellRC(rcPath, shell, binary string) error { + var b strings.Builder + if ShellRCNeedsCompinit(shell, rcPath) { + b.WriteString("\n# Initialize zsh completions\n") + b.WriteString("autoload -Uz compinit && compinit -i\n") + } + b.WriteString("\n# Ghost shell completions\n") + b.WriteString(CompletionSnippet(shell, binary)) + b.WriteString("\n") + return appendToShellRC(rcPath, b.String()) +} + +// appendToShellRC creates the parent directory if needed, ensures the file +// exists, and appends snippet to it. +func appendToShellRC(rcPath, snippet string) error { + if err := os.MkdirAll(filepath.Dir(rcPath), 0o755); err != nil { + return fmt.Errorf("failed to create rc directory: %w", err) + } + f, err := os.OpenFile(rcPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("failed to open %s: %w", rcPath, err) + } + defer f.Close() + if _, err := f.WriteString(snippet); err != nil { + return fmt.Errorf("failed to write to %s: %w", rcPath, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("failed to close %s: %w", rcPath, err) + } + return nil +} diff --git a/internal/common/shell_setup_test.go b/internal/common/shell_setup_test.go new file mode 100644 index 0000000..e3c1cf9 --- /dev/null +++ b/internal/common/shell_setup_test.go @@ -0,0 +1,302 @@ +package common + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectShellType(t *testing.T) { + tests := map[string]string{ + "/bin/bash": "bash", + "/usr/local/bin/zsh": "zsh", + "/opt/fish/fish": "fish", + "/bin/dash": "", + "": "", + } + for shell, want := range tests { + t.Setenv("SHELL", shell) + if got := DetectShellType(); got != want { + t.Errorf("DetectShellType(SHELL=%q) = %q, want %q", shell, got, want) + } + } +} + +func TestDetectShellRC(t *testing.T) { + tests := []struct { + name string + shell string + // setup configures the test-specific env vars and returns the + // expected rc path. Called after HOME and SHELL are already set. + setup func(t *testing.T, home string) string + }{ + { + name: "zsh", + shell: "/bin/zsh", + setup: func(t *testing.T, home string) string { + t.Setenv("ZDOTDIR", "") + return filepath.Join(home, ".zshrc") + }, + }, + { + name: "zsh with ZDOTDIR", + shell: "/bin/zsh", + setup: func(t *testing.T, home string) string { + custom := t.TempDir() + t.Setenv("ZDOTDIR", custom) + return filepath.Join(custom, ".zshrc") + }, + }, + { + name: "fish", + shell: "/usr/bin/fish", + setup: func(t *testing.T, home string) string { + t.Setenv("XDG_CONFIG_HOME", "") + return filepath.Join(home, ".config", "fish", "config.fish") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SHELL", tt.shell) + want := tt.setup(t, home) + if got := DetectShellRC(); got != want { + t.Errorf("got %q, want %q", got, want) + } + }) + } +} + +func TestIsInPath(t *testing.T) { + t.Setenv("PATH", "/foo:/bar:/baz") + if !IsInPath("/bar") { + t.Errorf("expected /bar to be in PATH") + } + if IsInPath("/quux") { + t.Errorf("expected /quux to be missing from PATH") + } + if IsInPath("") { + t.Errorf("empty dir should never be in PATH") + } +} + +func TestCompletionSnippet(t *testing.T) { + tests := []struct { + name string + shell string + binary string + want string + }{ + {name: "fish", shell: "fish", binary: "ghost", want: "ghost completion fish | source"}, + {name: "zsh", shell: "zsh", binary: "ghost", want: "command -v ghost >/dev/null 2>&1 && source <(ghost completion zsh)"}, + {name: "bash", shell: "bash", binary: "ghost", want: "command -v ghost >/dev/null 2>&1 && source <(ghost completion bash)"}, + {name: "absolute path", shell: "bash", binary: "/opt/ghost/bin/ghost", want: "command -v /opt/ghost/bin/ghost >/dev/null 2>&1 && source <(/opt/ghost/bin/ghost completion bash)"}, + {name: "quoted path", shell: "zsh", binary: "/Applications/Ghost CLI/ghost", want: "command -v '/Applications/Ghost CLI/ghost' >/dev/null 2>&1 && source <('/Applications/Ghost CLI/ghost' completion zsh)"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CompletionSnippet(tt.shell, tt.binary); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestShellRCNeedsCompinit(t *testing.T) { + dir := t.TempDir() + rc := filepath.Join(dir, ".zshrc") + + if !ShellRCNeedsCompinit("zsh", rc) { + t.Errorf("missing zshrc should report needing compinit") + } + + if err := os.WriteFile(rc, []byte("# bare\n"), 0o644); err != nil { + t.Fatal(err) + } + if !ShellRCNeedsCompinit("zsh", rc) { + t.Errorf("rc without compinit markers should need compinit") + } + + if err := os.WriteFile(rc, []byte("source $ZSH/oh-my-zsh.sh\n"), 0o644); err != nil { + t.Fatal(err) + } + if ShellRCNeedsCompinit("zsh", rc) { + t.Errorf("rc with oh-my-zsh should NOT need compinit") + } + + if err := os.WriteFile(rc, []byte("# compinit\n# oh-my-zsh\n"), 0o644); err != nil { + t.Fatal(err) + } + if !ShellRCNeedsCompinit("zsh", rc) { + t.Errorf("rc with only commented-out markers should still need compinit") + } + + if ShellRCNeedsCompinit("bash", rc) { + t.Errorf("non-zsh shells should never need compinit") + } +} + +func TestShellRCMentions(t *testing.T) { + dir := t.TempDir() + rc := filepath.Join(dir, ".zshrc") + + mentioned, err := ShellRCMentions(rc, "ghost completion") + if err != nil { + t.Fatalf("missing file should not error: %v", err) + } + if mentioned { + t.Errorf("missing file should not be reported as mentioning needle") + } + + if err := os.WriteFile(rc, []byte("source <(ghost completion zsh)\n"), 0o644); err != nil { + t.Fatal(err) + } + mentioned, err = ShellRCMentions(rc, "ghost completion") + if err != nil { + t.Fatal(err) + } + if !mentioned { + t.Errorf("file containing needle should be reported as mentioning it") + } +} + +func TestShellRCMentionsGhostCompletion(t *testing.T) { + tests := []struct { + name string + content string + want bool + }{ + {name: "plain historical snippet", content: "source <(ghost completion zsh)\n", want: true}, + {name: "absolute path snippet", content: "source <(/opt/ghost/bin/ghost completion zsh)\n", want: true}, + {name: "quoted absolute path snippet", content: "source <('/Applications/Ghost CLI/ghost' completion zsh)\n", want: true}, + {name: "not completions", content: "echo ghost\n", want: false}, + {name: "commented out snippet is ignored", content: "# source <(ghost completion zsh)\n", want: false}, + {name: "indented commented out snippet is ignored", content: " \t# source <(ghost completion zsh)\n", want: false}, + {name: "snippet present alongside an unrelated comment", content: "# nothing here\nsource <(ghost completion zsh)\n", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + rc := filepath.Join(dir, ".zshrc") + if err := os.WriteFile(rc, []byte(tt.content), 0o644); err != nil { + t.Fatal(err) + } + got, err := ShellRCMentionsGhostCompletion(rc) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } + + missingRC := filepath.Join(t.TempDir(), ".zshrc") + got, err := ShellRCMentionsGhostCompletion(missingRC) + if err != nil { + t.Fatalf("missing file should not error: %v", err) + } + if got { + t.Errorf("missing file should not be reported as configuring completions") + } +} + +func TestAppendPathToShellRC(t *testing.T) { + tests := []struct { + name string + rcFilename string + installDir string + want string + }{ + { + name: "bash", + rcFilename: ".bashrc", + installDir: "/opt/ghost/bin", + want: "\n# Added by ghost init\nexport PATH=\"/opt/ghost/bin:$PATH\"\n", + }, + { + name: "fish", + rcFilename: "fish/config.fish", + installDir: "/opt/ghost/bin", + want: "\n# Added by ghost init\nset -gx PATH /opt/ghost/bin $PATH\n", + }, + { + name: "fish quotes install dir with spaces", + rcFilename: "fish/config.fish", + installDir: "/Applications/Ghost CLI/bin", + want: "\n# Added by ghost init\nset -gx PATH '/Applications/Ghost CLI/bin' $PATH\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + rc := filepath.Join(dir, tt.rcFilename) + if err := AppendPathToShellRC(rc, tt.installDir); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(rc) + if err != nil { + t.Fatal(err) + } + if string(got) != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestAppendCompletionsToShellRC(t *testing.T) { + const zshSnippet = "command -v ghost >/dev/null 2>&1 && source <(ghost completion zsh)" + const bashSnippet = "command -v ghost >/dev/null 2>&1 && source <(ghost completion bash)" + + tests := []struct { + name string + shell string + rcFilename string + prefilledRC string + want string + }{ + { + name: "zsh adds compinit block when missing", + shell: "zsh", + rcFilename: ".zshrc", + want: "\n# Initialize zsh completions\nautoload -Uz compinit && compinit -i\n\n# Ghost shell completions\n" + zshSnippet + "\n", + }, + { + name: "zsh skips compinit block when already present", + shell: "zsh", + rcFilename: ".zshrc", + prefilledRC: "autoload -Uz compinit\ncompinit\n", + want: "autoload -Uz compinit\ncompinit\n\n# Ghost shell completions\n" + zshSnippet + "\n", + }, + { + name: "bash never adds compinit", + shell: "bash", + rcFilename: ".bashrc", + want: "\n# Ghost shell completions\n" + bashSnippet + "\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + rc := filepath.Join(dir, tt.rcFilename) + if tt.prefilledRC != "" { + if err := os.WriteFile(rc, []byte(tt.prefilledRC), 0o644); err != nil { + t.Fatal(err) + } + } + if err := AppendCompletionsToShellRC(rc, tt.shell, "ghost"); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(rc) + if err != nil { + t.Fatal(err) + } + if string(got) != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/common/windows_path_other.go b/internal/common/windows_path_other.go new file mode 100644 index 0000000..247d2d3 --- /dev/null +++ b/internal/common/windows_path_other.go @@ -0,0 +1,19 @@ +//go:build !windows + +package common + +import "errors" + +// IsInWindowsUserPath is a Windows-only check against the persistent user +// Path in the registry. On other platforms it always errors — callers +// should branch on runtime.GOOS before calling. +func IsInWindowsUserPath(installDir string) (bool, error) { + return false, errors.New("IsInWindowsUserPath is only supported on Windows") +} + +// AddToWindowsUserPath is a Windows-only writer for the persistent user +// Path in the registry. On other platforms it always errors — callers +// should branch on runtime.GOOS before calling. +func AddToWindowsUserPath(installDir string) error { + return errors.New("AddToWindowsUserPath is only supported on Windows") +} diff --git a/internal/common/windows_path_windows.go b/internal/common/windows_path_windows.go new file mode 100644 index 0000000..fd5e5b7 --- /dev/null +++ b/internal/common/windows_path_windows.go @@ -0,0 +1,143 @@ +//go:build windows + +package common + +import ( + "errors" + "fmt" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows/registry" +) + +const windowsUserPathRegistryPath = "Environment" + +// windowsUserPathContainsDir reports whether dir is already an element of +// the semicolon-separated userPath, comparing case-insensitively and +// ignoring trailing path separators. +func windowsUserPathContainsDir(userPath, dir string) bool { + want := strings.ToLower(strings.TrimRight(dir, `\/`)) + for entry := range strings.SplitSeq(userPath, ";") { + got := strings.ToLower(strings.TrimRight(entry, `\/`)) + if got == want { + return true + } + } + return false +} + +// IsInWindowsUserPath reports whether installDir is present in either the +// current session PATH or the persistent user Path stored in the registry. +// The session check matters when a previous run already updated the +// registry but the broadcast hasn't reached this process. +func IsInWindowsUserPath(installDir string) (bool, error) { + if installDir == "" { + return false, nil + } + if IsInPath(installDir) { + return true, nil + } + + key, err := registry.OpenKey(registry.CURRENT_USER, windowsUserPathRegistryPath, registry.QUERY_VALUE) + if err != nil { + return false, fmt.Errorf("failed to open user Environment registry key: %w", err) + } + defer key.Close() + + userPath, _, err := key.GetStringValue("Path") + if errors.Is(err, registry.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to read user Path: %w", err) + } + return windowsUserPathContainsDir(userPath, installDir), nil +} + +// AddToWindowsUserPath appends installDir to the user's Path environment +// variable in the registry, preserving the existing value's type (REG_SZ +// vs REG_EXPAND_SZ so that entries like %USERPROFILE%\bin remain dynamic). +// Broadcasts WM_SETTINGCHANGE so Explorer and shells launched after this +// call see the new value; the current process's environment is not updated. +func AddToWindowsUserPath(installDir string) error { + if installDir == "" { + return errors.New("installDir is empty") + } + + key, err := registry.OpenKey(registry.CURRENT_USER, windowsUserPathRegistryPath, registry.QUERY_VALUE|registry.SET_VALUE) + if err != nil { + return fmt.Errorf("failed to open user Environment registry key: %w", err) + } + defer key.Close() + + userPath, valueType, err := key.GetStringValue("Path") + if errors.Is(err, registry.ErrNotExist) { + userPath = "" + valueType = registry.SZ + } else if err != nil { + return fmt.Errorf("failed to read user Path: %w", err) + } + + if windowsUserPathContainsDir(userPath, installDir) { + return nil + } + + var newPath string + switch { + case userPath == "": + newPath = installDir + case strings.HasSuffix(userPath, ";"): + newPath = userPath + installDir + default: + newPath = userPath + ";" + installDir + } + + if valueType == registry.EXPAND_SZ { + if err := key.SetExpandStringValue("Path", newPath); err != nil { + return fmt.Errorf("failed to write user Path: %w", err) + } + } else { + if err := key.SetStringValue("Path", newPath); err != nil { + return fmt.Errorf("failed to write user Path: %w", err) + } + } + + broadcastEnvironmentChange() + return nil +} + +// broadcastEnvironmentChange sends WM_SETTINGCHANGE with lParam pointing to +// the string "Environment". This is the same notification PowerShell's +// [Environment]::SetEnvironmentVariable emits so Explorer and listening +// processes refresh their environment. Errors are intentionally ignored — +// the registry write has already succeeded and new processes will pick up +// the change regardless. +func broadcastEnvironmentChange() { + user32 := syscall.NewLazyDLL("user32.dll") + sendMessageTimeoutW := user32.NewProc("SendMessageTimeoutW") + + const ( + HWND_BROADCAST = uintptr(0xFFFF) + WM_SETTINGCHANGE = uintptr(0x001A) + SMTO_ABORTIFHUNG = uintptr(0x0002) + timeoutMs = uintptr(5000) + ) + + envStr, err := syscall.UTF16PtrFromString("Environment") + if err != nil { + return + } + + var result uintptr + _, _, _ = sendMessageTimeoutW.Call( + HWND_BROADCAST, + WM_SETTINGCHANGE, + 0, + uintptr(unsafe.Pointer(envStr)), + SMTO_ABORTIFHUNG, + timeoutMs, + uintptr(unsafe.Pointer(&result)), + ) +} diff --git a/internal/util/path.go b/internal/util/path.go index ffadad2..832b7ef 100644 --- a/internal/util/path.go +++ b/internal/util/path.go @@ -6,6 +6,21 @@ import ( "strings" ) +// DisplayPath replaces $HOME with ~ in path for compact display. +func DisplayPath(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return path + } + if path == home { + return "~" + } + if strings.HasPrefix(path, home+string(os.PathSeparator)) { + return "~" + path[len(home):] + } + return path +} + // ExpandPath expands environment variables and tilde in file paths. // It handles: // - Empty paths (returns empty string) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a69865f..58ca80d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -355,6 +355,33 @@ function Test-Installation { } } +# Run `ghost init` to drive the post-install configuration flow (login, +# MCP server installation, shell completions). PATH setup is already handled +# inline by Test-Installation so this run's current PowerShell session can +# immediately pick up ghost — something a Go subprocess can't do for its +# parent. We pass --skip-if-configured so re-runs of the installer don't +# re-prompt the user unnecessarily. +# +# `ghost init` needs an interactive terminal for its multi-select prompts. +# When running under `irm | iex` with redirected stdin (CI, scripted runs, +# etc.), fall back to printing a hint instead so the install still succeeds. +function Invoke-GhostInit { + param([string]$BinaryPath) + + if ([Console]::IsInputRedirected -or -not [Environment]::UserInteractive) { + Write-Info "Run '$BinaryPath init' to finish configuring Ghost." + return + } + + try { + & $BinaryPath --version-check=false init --skip-if-configured + } + catch { + Write-Warn "ghost init failed: $_" + Write-Warn "Run '$BinaryPath init' manually to finish configuring Ghost." + } +} + # Main installation process function Install-Ghost { Write-Info "Ghost CLI Installation Script" @@ -429,12 +456,8 @@ function Install-Ghost { # Verify installation Test-Installation -InstallDir $installDir - # Show usage information - Write-Success "Get started with:" - Write-Success " ghost login" - Write-Success " ghost mcp install" - Write-Success "For help:" - Write-Success " ghost help" + # Run the post-install configuration flow + Invoke-GhostInit -BinaryPath $targetPath } finally { # Clean up temporary directory diff --git a/scripts/install.sh b/scripts/install.sh index 1da4fde..2b9183a 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -475,221 +475,26 @@ verify_installation() { fi } -# Check whether a prompt can be shown to the user. Prefers /dev/tty so this -# works correctly when the script is piped through `curl | sh`. -is_interactive() { - [ -t 0 ] || [ -r /dev/tty ] -} - -# Prompt the user with a yes/no question. Returns 0 on yes, 1 on no or on any -# error reading input. Reads from /dev/tty when available so prompts still -# work under `curl | sh`. -prompt_yn() { - local prompt="$1" - local reply="" - - if [ -r /dev/tty ]; then - printf "%s [y/N]: " "${prompt}" > /dev/tty - IFS= read -r reply < /dev/tty || reply="" - else - printf "%s [y/N]: " "${prompt}" >&2 - IFS= read -r reply || reply="" - fi - - case "${reply}" in - [Yy]|[Yy][Ee][Ss]) return 0 ;; - *) return 1 ;; - esac -} - -# Determine the user's shell rc file based on the SHELL env var, falling -# back to an OS-appropriate default. Echoes the path on stdout. -detect_shell_rc() { - local shell_name - shell_name="$(basename "${SHELL:-}" 2>/dev/null || echo "")" - - case "${shell_name}" in - zsh) - echo "${ZDOTDIR:-$HOME}/.zshrc" - ;; - bash) - # On macOS, login shells read .bash_profile, not .bashrc, so - # prefer that file when it already exists. - if [ "$(uname -s)" = "Darwin" ] && [ -f "$HOME/.bash_profile" ]; then - echo "$HOME/.bash_profile" - else - echo "$HOME/.bashrc" - fi - ;; - fish) - echo "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" - ;; - *) - # Unknown shell — guess based on OS and existing files. - if [ "$(uname -s)" = "Darwin" ] || [ -f "$HOME/.zshrc" ]; then - echo "$HOME/.zshrc" - else - echo "$HOME/.bashrc" - fi - ;; - esac -} - -# Determine the shell type for completions (bash/zsh/fish). Echoes empty -# string when the user's shell is unknown or unsupported. -detect_shell_type() { - local shell_name - shell_name="$(basename "${SHELL:-}" 2>/dev/null || echo "")" - - case "${shell_name}" in - bash|zsh|fish) echo "${shell_name}" ;; - *) echo "" ;; - esac -} - -# Build the shell-specific snippet that sources Ghost's completions. -completion_snippet() { - local shell_type="$1" - - case "${shell_type}" in - fish) - echo "${BINARY_NAME} completion fish | source" - ;; - *) - echo "command -v ${BINARY_NAME} >/dev/null 2>&1 && source <(${BINARY_NAME} completion ${shell_type})" - ;; - esac -} - -# Offer to add the install directory to the PATH in the user's shellrc. -# No-op when the directory is already in PATH or already referenced in the -# shellrc file. Prints manual instructions when declined or non-interactive. -configure_path_in_shellrc() { - local install_dir="$1" - local shell_rc - shell_rc="$(detect_shell_rc)" - - # Already in PATH — nothing to do. - if is_in_path "${install_dir}"; then - return 0 - fi - - log_warn "${install_dir} is not in your PATH" - - # If the shellrc already references the install dir, assume the user has - # configured it and just needs to reload their shell. - if [ -f "${shell_rc}" ] && grep -qF "${install_dir}" "${shell_rc}" 2>/dev/null; then - log_info "${install_dir} is already referenced in ${shell_rc}" - log_info "Restart your shell or run: source ${shell_rc}" - return 0 - fi - - local manual_cmd - case "${shell_rc}" in - *config.fish) - manual_cmd="fish_add_path ${install_dir}" - ;; - *) - manual_cmd="echo 'export PATH=\"${install_dir}:\$PATH\"' >> ${shell_rc}" - ;; - esac - - if ! is_interactive || ! prompt_yn "Add to PATH in ${shell_rc}?"; then - log_info "To add it manually, run:" - log_info " ${manual_cmd}" - return 0 - fi - - # Ensure parent directory and shellrc file exist before appending. - mkdir -p "$(dirname "${shell_rc}")" - touch "${shell_rc}" - - case "${shell_rc}" in - *config.fish) - { - echo "" - echo "# Added by Ghost installer" - echo "set -gx PATH ${install_dir} \$PATH" - } >> "${shell_rc}" - ;; - *) - { - echo "" - echo "# Added by Ghost installer" - echo "export PATH=\"${install_dir}:\$PATH\"" - } >> "${shell_rc}" - ;; - esac - - log_success "Added ${install_dir} to PATH in ${shell_rc}" - log_info "Restart your shell or run: source ${shell_rc}" -} - -# Offer to configure shell completions in the user's shellrc. No-op when -# the shell is unknown, when completions are already configured, or when -# the user declines. -configure_shell_completions() { - local installed_binary="$1" - local shell_rc - shell_rc="$(detect_shell_rc)" - local shell_type - shell_type="$(detect_shell_type)" - - if [ -z "${shell_type}" ]; then - log_info "Could not detect shell type, skipping completion setup" - log_info "Run '${installed_binary} completion --help' for manual setup instructions" - return 0 - fi - - local snippet - snippet="$(completion_snippet "${shell_type}")" - - # Already configured? Look for any reference to `ghost completion` in rc. - if [ -f "${shell_rc}" ] && grep -qF "${BINARY_NAME} completion" "${shell_rc}" 2>/dev/null; then - log_debug "Shell completions already configured in ${shell_rc}" - return 0 - fi - - # For zsh, `source <(...)` requires compinit. If the user doesn't already - # load it (directly or via a framework), we offer to add it too. - local needs_compinit="false" - if [ "${shell_type}" = "zsh" ] && [ -f "${shell_rc}" ]; then - if ! grep -qE '(compinit|oh-my-zsh|prezto|zinit|antigen|zplug|zgenom)' "${shell_rc}" 2>/dev/null; then - needs_compinit="true" - fi - elif [ "${shell_type}" = "zsh" ]; then - needs_compinit="true" - fi - - if ! is_interactive || ! prompt_yn "Add ${shell_type} shell completions for ${BINARY_NAME} to ${shell_rc}?"; then - log_info "To enable shell completions, add the following to ${shell_rc}:" - if [ "${needs_compinit}" = "true" ]; then - log_info " autoload -Uz compinit && compinit -i" - fi - log_info " ${snippet}" +# Run `ghost init` to drive the post-install configuration flow (PATH setup, +# login, MCP server installation, shell completions). We pass +# --skip-if-configured so re-runs of the installer don't re-prompt the user +# unnecessarily. +# +# `ghost init` needs an interactive TTY for its multi-select prompts. We +# redirect stdin/stdout/stderr through /dev/tty so the flow works under +# `curl | sh`, where the script's stdin is the pipe from curl, and so prompts +# remain visible even if the installer itself is redirected. If /dev/tty isn't +# readable and writable (e.g. in a container with no tty), we run the +# non-interactive PATH setup and tell the user to run the full interactive init +# flow manually. +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 return 0 fi - - # Ensure parent directory and shellrc file exist before appending. - mkdir -p "$(dirname "${shell_rc}")" - touch "${shell_rc}" - - { - if [ "${needs_compinit}" = "true" ]; then - echo "" - echo "# Initialize zsh completions" - echo "autoload -Uz compinit && compinit -i" - fi - echo "" - echo "# Ghost shell completions" - echo "${snippet}" - } >> "${shell_rc}" - - log_success "Added ${shell_type} shell completions to ${shell_rc}" - if [ "${needs_compinit}" = "true" ]; then - log_success "Added compinit initialization to ${shell_rc}" - fi - log_info "Restart your shell or run: source ${shell_rc}" + "${binary_path}" --version-check=false init --skip-if-configured /dev/tty 2>/dev/tty || true } # ============================================================================ @@ -854,14 +659,9 @@ main() { # usage messages can speak normally. QUIET=false - configure_path_in_shellrc "${install_dir}" - configure_shell_completions "${installed_binary}" - verify_installation "${install_dir}" "${installed_binary}" - # Final usage messages (plain printf for a clean unprefixed look). - printf "\nGet started with:\n %s login\n %s mcp install\n" \ - "${installed_binary}" "${installed_binary}" >&2 + run_ghost_init "${install_dir}/${installed_binary}" } # ============================================================================