diff --git a/README.md b/README.md index e8d0a20..5f438b0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Supermodel maps every file, function, and call relationship in your repo and wri ## Linux / Mac ```bash -curl -fsSL https://supermodeltools.com/install.sh | sh +curl -fsSL https://supermodeltools.com/install | sh ``` ## npm (cross-platform) @@ -78,10 +78,10 @@ brew install supermodeltools/tap/supermodel ### Linux / macOS (curl) ```bash -curl -fsSL https://supermodeltools.com/install.sh | sh +curl -fsSL https://supermodeltools.com/install | sh ``` -Runs the setup wizard automatically on first install when attached to a terminal. +On first run, `supermodel` launches the setup wizard automatically when attached to a terminal. ### From source @@ -96,9 +96,8 @@ go build -o supermodel . ## Quick start ```bash -supermodel setup # authenticate + configure (runs automatically after install) cd your/repo -supermodel # generate graph files and keep them updated +supermodel # first run: launches setup wizard, then watches for changes ``` --- diff --git a/cmd/root.go b/cmd/root.go index 0414980..374d1c6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,14 +3,74 @@ package cmd import ( "fmt" "os" + "syscall" "time" "github.com/spf13/cobra" + "golang.org/x/term" "github.com/supermodeltools/cli/internal/config" + "github.com/supermodeltools/cli/internal/setup" "github.com/supermodeltools/cli/internal/shards" ) +// stdinIsTerminal reports whether stdin is connected to an interactive +// terminal. Pulled into a var so tests can stub it. +var stdinIsTerminal = func() bool { + return term.IsTerminal(int(syscall.Stdin)) //nolint:unconvert // syscall.Stdin is uintptr on Windows +} + +// rootAction enumerates the three branches the bare `supermodel` command +// can take based on auth state and whether stdin is interactive. +type rootAction int + +const ( + // runWatch: an API key is configured; start the watch daemon. + runWatch rootAction = iota + // runSetup: no key, but stdin is a TTY — drop into the setup wizard. + runSetup + // errNotAuthenticated: no key and we can't prompt; surface a clean error. + errNotAuthenticated +) + +// pickRootAction is the decision logic for bare `supermodel`. Pulled into +// a pure function so the dispatch matrix can be unit-tested without +// invoking the wizard or the watch daemon (issue #151). +func pickRootAction(hasAPIKey, interactive bool) rootAction { + switch { + case hasAPIKey: + return runWatch + case interactive: + return runSetup + default: + return errNotAuthenticated + } +} + +// persistentPreRunE gates every subcommand on having an API key, except +// for the bare root command (which handles its own dispatch — see issue +// #151) and noConfigCommands. +func persistentPreRunE(cmd *cobra.Command, args []string) error { + for c := cmd; c != nil; c = c.Parent() { + if noConfigCommands[c.Name()] { + return nil + } + } + // The root command (bare `supermodel`) has no parent. Skip the + // pre-run check and let RunE handle interactive vs CI dispatch. + if cmd.Parent() == nil { + return nil + } + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("error loading config: %w", err) + } + if cfg.APIKey == "" { + return fmt.Errorf("run 'supermodel setup' to get started") + } + return nil +} + // noConfigCommands are subcommands that work without a config file or API key. // Includes Cobra's internal shell-completion helpers to avoid crashing them. var noConfigCommands = map[string]bool{ @@ -40,33 +100,23 @@ enters daemon mode. Listens for file-change notifications from the Press Ctrl+C to stop and remove graph files. See https://supermodeltools.com for documentation.`, - Args: cobra.NoArgs, - SilenceUsage: true, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Walk up to the root command name to get the subcommand. - name := cmd.Name() - if noConfigCommands[name] { - return nil - } - - cfg, err := config.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) - os.Exit(1) - } - if cfg.APIKey == "" { - fmt.Fprintln(os.Stderr, "Run 'supermodel setup' to get started.") - os.Exit(1) - } - return nil - }, + Args: cobra.NoArgs, + SilenceUsage: true, + PersistentPreRunE: persistentPreRunE, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } - if err := cfg.RequireAPIKey(); err != nil { - return err + // First-run dispatch (issue #151): pick a branch based on auth + // state and whether stdin is interactive. Non-interactive callers + // (CI, scripts, piped stdin) get a clean error so they can't hang + // on a browser-auth prompt. + switch pickRootAction(cfg.APIKey != "", stdinIsTerminal()) { + case runSetup: + return setup.Run(cmd.Context(), cfg) + case errNotAuthenticated: + return fmt.Errorf("not authenticated — run `supermodel setup` or set SUPERMODEL_API_KEY") } dir := watchDir opts := shards.WatchOptions{ diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..bceac9a --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,26 @@ +package cmd + +import "testing" + +func TestPickRootAction(t *testing.T) { + cases := []struct { + name string + hasAPIKey bool + interactive bool + want rootAction + }{ + {"key + tty starts watch", true, true, runWatch}, + {"key + non-tty starts watch (CI happy path)", true, false, runWatch}, + {"no key + tty drops into setup wizard", false, true, runSetup}, + {"no key + non-tty errors instead of hanging", false, false, errNotAuthenticated}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := pickRootAction(tc.hasAPIKey, tc.interactive) + if got != tc.want { + t.Errorf("pickRootAction(hasAPIKey=%v, interactive=%v) = %v, want %v", + tc.hasAPIKey, tc.interactive, got, tc.want) + } + }) + } +}