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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
```

---
Expand Down
94 changes: 72 additions & 22 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down
26 changes: 26 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading