setup

package
v0.3.0-alpha.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 10, 2026 License: Apache-2.0 Imports: 27 Imported by: 0

Documentation

Overview

Package setup implements the interactive first-run wizard for Gramaton and the non-interactive bootstrap fallback that both entry points (`gramaton init` in a terminal, `gramaton init --non-interactive` in scripts/CI) share.

Why a wizard at all

The pre-OSS target audience is "tech-capable users" — developers who can run `go install`. Even for them, a complete Gramaton install wires together five moving parts: config file, data directory, embedding model, LLM provider + API key, MCP client registration, automatic- capture hooks. A bare `gramaton init` that drops out after creating the data dir leaves four of those five steps undocumented in the quickstart and invisible in `--help`. New users end up with a half-wired install and assume Gramaton "doesn't really work."

The wizard walks the user through each of those steps once, with a plain-English explanation at every decision point, auto-detection where it's possible (MCP clients, installed tools), and a validation pass at the end. It's the single highest-leverage UX investment we can make before the first public push.

Decision captured in Memory record "Gramaton pre-OSS target audience and wave-prioritization decision" (2026-04-22). The wizard is an explicit Wave-1 requirement; no-code/low-code polish (pre-built binaries, install script, GUI) is Wave-2+ and explicitly deferred.

Why `internal/setup/` (not `cli/wizard/`)

The wizard is a sequence of pure operations on config, filesystem, and the MCP-client's config files. None of it is CLI-flag-specific. Putting it in an internal package (a) keeps cli/ thin, (b) makes every step unit-testable with mock stdin/stdout, (c) leaves the door open for a future non-CLI driver (gramaton doctor --fix could reuse the MCP injection logic; a future desktop app, if one ever ships, could drive the same setup package).

The trade-off: an extra package boundary. Worth it for the testability alone. No shared state with cli/; imports go one way (cli -> setup).

Wizard shape

The public entry point is Run. It expects a Prompter and a Writer injected from the caller; cli/ passes terminal-backed implementations, tests pass scripted mocks.

wiz := setup.New(prompter, writer, cfg, cfgPath, configDir)
if err := wiz.Run(ctx); err != nil { ... }

Steps (see wizard.go Run):

  1. Welcome + branch fresh-vs-import
  2. Knowledge-store bootstrap (config, data dir, embedding provider, model download)
  3. LLM provider (optional but strongly recommended) + API key + test call + cost caps
  4. MCP client auto-detect (Claude Code + kiro-cli) + config injection
  5. Hooks installer (auto-capture) for detected clients
  6. Verification + concrete next-steps block

Each step is idempotent and safe to re-run: re-running the wizard against an existing install offers a menu to reconfigure individual parts rather than clobbering state.

Non-interactive mode

When the caller detects no TTY (or the user passes --non-interactive), the wizard runs in a defaults-only path that completes Step 1 only (bootstrap + BERT model download) and prints instructions for completing Steps 2-4 manually. This preserves backward compatibility with existing `gramaton init` invocations in scripts/CI and keeps the --non-interactive exit behavior predictable.

Index

Constants

This section is empty.

Variables

View Source
var ErrAborted = errors.New("setup aborted by user")

ErrAborted is returned when the user explicitly aborts the wizard (currently: ctrl+C mid-prompt, which surfaces as io.EOF from stdin and we translate here for callers to handle gracefully).

View Source
var ErrInputTooLong = errors.New("input too long")

ErrInputTooLong is returned when a single prompt receives more than maxLineBytes bytes of input. Defensive cap against pathological paste: a user who accidentally paste-bombs the terminal (very long file dropped into the prompt by mistake, escaped ANSI sequences, etc.) would otherwise force us to buffer the whole blob. With the cap we fail fast and the caller can re-prompt.

View Source
var ErrNoMCPBackend = errors.New("no MCP backend configured")

ErrNoMCPBackend is returned by a Wizard whose MCPBackend field was explicitly cleared (by a test, for example) and who then tried to run Step 3. Should never fire in production.

Functions

This section is empty.

Types

type DefaultHookBackend

type DefaultHookBackend struct{}

DefaultHookBackend is the production implementation.

func (DefaultHookBackend) Materialize

func (DefaultHookBackend) Materialize(client string, configDir string) ([]string, error)

Materialize writes the proxy scripts for `client` into <configDir>/hooks/<client>/. configDir is typically ~/.gramaton. Creates directories with 0o700 and scripts with 0o755. The exec bit is ignored on Windows but preserved on Unix.

Pre-Phase-2 this function extracted real hook logic from embedded .sh files; as of Phase 2 the scripts are one-line proxies generated from Go templates. User customizations of proxy files are overwritten on re-run — documented in the wizard output.

func (DefaultHookBackend) RegisterClaudeHooks

func (DefaultHookBackend) RegisterClaudeHooks(_ context.Context, scriptPaths []string) (bool, error)

RegisterClaudeHooks patches the user-scope ~/.claude/settings.json to route hook events at our materialized proxy scripts. The merge is additive and surgical: we preserve every top-level key, every unrelated hook-event entry, and every hook command that isn't pointing at ~/.gramaton/hooks/. Our own entries are replaced in place so re-running the wizard is idempotent.

Why direct JSON editing (vs shelling out to some hypothetical `claude hooks add` command): Claude Code does NOT currently have a `hooks` CLI subcommand (verified 2026-04-22: `claude hooks` treats the input as a prompt for the agent, not a subcommand). Hand- editing settings.json is the only programmatic path.

Schema source: verified against the user's actual ~/.claude/ settings.json at the time of writing. Each event maps to an array of "matcher blocks", each with a `hooks` array of {"type": "command", "command": "..."} entries. We write a single block per event with one entry, pointing at our materialized script.

type DefaultMCPBackend

type DefaultMCPBackend struct{}

DefaultMCPBackend is the production implementation. Uses exec to detect binaries and shell out to each client's CLI for registration.

func (DefaultMCPBackend) Detect

func (DefaultMCPBackend) Detect() []DetectedClient

Detect looks for known MCP client binaries on PATH. Currently recognizes Claude Code (`claude`) and kiro-cli (`kiro`). Order of the returned slice matches the order binaries were searched -- it's stable for a given machine, which matters for the wizard's display.

func (DefaultMCPBackend) Register

func (DefaultMCPBackend) Register(ctx context.Context, client DetectedClient) (bool, error)

Register dispatches to a client-specific registration helper. Unknown clients are a programming error (Detect returned something Register doesn't handle) so we surface that loudly.

type DetectedClient

type DetectedClient struct {
	Name   string
	Binary string
}

DetectedClient describes one MCP client installed on this system that Step 3 can register Gramaton against. Name is a human- readable label ("Claude Code", "kiro-cli") used in wizard output; Binary is the absolute path returned by exec.LookPath, kept so we run the same binary we detected (not whatever PATH resolves to at registration time).

type HookBackend

type HookBackend interface {
	// Materialize generates the proxy scripts for `client` into a
	// canonical on-disk location (typically
	// ~/.gramaton/hooks/<client>/) and returns the absolute paths of
	// the installed scripts. Idempotent: re-running overwrites with
	// the current proxy template so upgrades propagate when users
	// re-run the wizard.
	Materialize(client string, configDir string) (scriptPaths []string, err error)

	// RegisterClaudeHooks patches ~/.claude/settings.json to point
	// the user-scope hooks at the given script paths. Existing
	// gramaton-owned hook entries (commands under
	// ~/.gramaton/hooks/) are replaced in place; other hook entries
	// (user's own, other tools') are left untouched. Returns (true,
	// nil) when our entries were already present and unchanged,
	// (false, nil) on a successful update, (false, err) on failure.
	RegisterClaudeHooks(ctx context.Context, scriptPaths []string) (unchanged bool, err error)
}

HookBackend is the test seam for Step 4. Production uses DefaultHookBackend; tests inject a fake to exercise the wizard orchestration without touching the real filesystem or the user's Claude Code settings.json.

type MCPBackend

type MCPBackend interface {
	// Detect returns the clients found on this system. Empty slice if
	// nothing was detected; never nil.
	Detect() []DetectedClient

	// Register adds Gramaton to the named client's config. Returns
	// (true, nil) if Gramaton was already registered (a soft success
	// the wizard can report differently), (false, nil) on a new
	// registration, or (false, err) on failure.
	Register(ctx context.Context, client DetectedClient) (alreadyRegistered bool, err error)
}

MCPBackend is the test seam for Step 3. Production uses DefaultMCPBackend; tests inject a fake to exercise the wizard's orchestration without running real clients.

type Prompter

type Prompter interface {
	// Text reads a line, trims whitespace, and returns it. If the user
	// presses Enter without input and def is non-empty, def is
	// returned. The prompt is whatever the caller wants; Prompter does
	// not print it (the caller prints, the prompter reads). This
	// separation keeps output formatting in Writer and input reading
	// here.
	Text(def string) (string, error)

	// Secret reads a line with echo disabled (if the input is a TTY).
	// No default -- secrets are explicitly typed or skipped by pressing
	// Enter. If the stream is not a TTY (piped input), Secret falls
	// back to plain ReadString; this is acceptable because piped
	// secrets are already flowing through something that could log
	// them, and refusing to read them would break scripted --non-
	// interactive setups.
	Secret() (string, error)

	// Choice presents the options as "  [n] label" lines on the Writer
	// out-of-band (caller prints; Prompter just reads the digit). It
	// parses a 1-indexed digit, validates it's in [1, maxChoice], and
	// returns the 0-indexed result. If the user presses Enter without
	// input and def != -1, def is returned. Any other input is an
	// error the caller should surface and re-prompt.
	Choice(maxChoice, def int) (int, error)

	// YesNo reads a single [y/n] answer. defaultYes controls which
	// letter is returned on a blank Enter. Accepts y, Y, yes, YES, n,
	// N, no, NO.
	YesNo(defaultYes bool) (bool, error)
}

Prompter is the test seam for reading user input during the wizard. The terminal implementation (NewTerminalPrompter) reads from stdin and honours TTY conventions (hidden input for Secret, line-buffered input otherwise). Tests pass ScriptedPrompter to drive the wizard with a canned sequence of answers.

Why an interface (not a concrete terminal reader everywhere):

  • Unit-testing the wizard without driving a real terminal.
  • Keeps the wizard logic independent of *os.File assumptions; the wizard never calls term.IsTerminal directly, it just receives a Prompter that already committed to a mode.

type ScriptedPrompter

type ScriptedPrompter struct {
	Answers []string
	// contains filtered or unexported fields
}

ScriptedPrompter is the test implementation. It yields pre-loaded answers in order; an out-of-answers read returns ErrAborted so tests fail loudly rather than hanging.

func NewScriptedPrompter

func NewScriptedPrompter(answers ...string) *ScriptedPrompter

NewScriptedPrompter accepts the canned answer sequence.

func (*ScriptedPrompter) Choice

func (s *ScriptedPrompter) Choice(maxChoice, def int) (int, error)

func (*ScriptedPrompter) Secret

func (s *ScriptedPrompter) Secret() (string, error)

func (*ScriptedPrompter) Text

func (s *ScriptedPrompter) Text(def string) (string, error)

func (*ScriptedPrompter) YesNo

func (s *ScriptedPrompter) YesNo(defaultYes bool) (bool, error)

type TerminalPrompter

type TerminalPrompter struct {
	// contains filtered or unexported fields
}

TerminalPrompter is the production Prompter backed by os.Stdin and golang.org/x/term for secret input. It is NOT safe for concurrent use (the wizard is single-threaded by design).

func NewTerminalPrompter

func NewTerminalPrompter() *TerminalPrompter

NewTerminalPrompter returns a Prompter that reads from os.Stdin. Secret uses term.ReadPassword when stdin is a TTY; otherwise it falls back to ReadString (piped input case).

func (*TerminalPrompter) Choice

func (p *TerminalPrompter) Choice(maxChoice, def int) (int, error)

func (*TerminalPrompter) Secret

func (p *TerminalPrompter) Secret() (string, error)

func (*TerminalPrompter) Text

func (p *TerminalPrompter) Text(def string) (string, error)

func (*TerminalPrompter) YesNo

func (p *TerminalPrompter) YesNo(defaultYes bool) (bool, error)

type TerminalWriter

type TerminalWriter struct {
	// contains filtered or unexported fields
}

TerminalWriter is the production Writer that renders to an io.Writer (usually os.Stdout). ANSI styling is deliberately minimal: the checkmark/warning/error prefixes are plain-ASCII-plus-UTF8 (not ANSI colour codes) so output is readable when piped to a log file or redirected by an IDE. If richer styling is desired later, the prefixes can be upgraded in one place here.

func NewTerminalWriter

func NewTerminalWriter() *TerminalWriter

NewTerminalWriter returns a TerminalWriter that writes to os.Stdout. Tests and alternate drivers can construct one directly with a different writer via the NewWriter helper.

func NewWriter

func NewWriter(out io.Writer) *TerminalWriter

NewWriter is for tests and alternate frontends that need to inject a specific io.Writer (e.g., a bytes.Buffer to capture output).

func (*TerminalWriter) Blank

func (w *TerminalWriter) Blank()

func (*TerminalWriter) Check

func (w *TerminalWriter) Check(msg string)

func (*TerminalWriter) ErrorLine

func (w *TerminalWriter) ErrorLine(msg string)

func (*TerminalWriter) Paragraph

func (w *TerminalWriter) Paragraph(lines ...string)

func (*TerminalWriter) ProgressEnd

func (w *TerminalWriter) ProgressEnd()

func (*TerminalWriter) ProgressStart

func (w *TerminalWriter) ProgressStart(label string)

func (*TerminalWriter) ProgressUpdate

func (w *TerminalWriter) ProgressUpdate(done, total int64)

func (*TerminalWriter) Prompt

func (w *TerminalWriter) Prompt(text string)

func (*TerminalWriter) Raw

func (w *TerminalWriter) Raw(line string)

func (*TerminalWriter) Section

func (w *TerminalWriter) Section(title string)

func (*TerminalWriter) StepHeader

func (w *TerminalWriter) StepHeader(n, of int, title string)

func (*TerminalWriter) Warn

func (w *TerminalWriter) Warn(msg string)

type Wizard

type Wizard struct {
	// contains filtered or unexported fields
}

Wizard orchestrates the interactive first-run setup. A single Wizard instance runs once per `gramaton init` invocation; the type is not concurrency-safe and is not intended to be.

Construction injects three things:

  • Prompter: how the wizard reads input (terminal in production, scripted in tests).
  • Writer: how the wizard prints output (stdout in production, buffer in tests).
  • cfg / cfgPath / configDir: the target state the wizard mutates. The wizard writes to cfg in memory and persists via config.Save at well-defined checkpoints; callers passing a partially- populated cfg (e.g., pre-loaded from defaults + a partial user-provided override) is supported.

Why inject the Prompter/Writer instead of reading os.Stdin / writing os.Stdout directly in the wizard code:

  • Every step becomes unit-testable.
  • Steps stay unaware of what "a terminal" is; they just ask the Prompter for a Yes/No or print to the Writer.
  • A future non-CLI driver (`gramaton doctor --fix`, for example, which might want to re-run Step 3 MCP injection headlessly) can swap the Prompter for a non-interactive stub.

func New

func New(prompter Prompter, writer Writer, cfg *config.Config, cfgPath, configDir string) *Wizard

New constructs a Wizard. cfg may be any config.Config (typically config.Defaults() with the DataDir already resolved by the caller). cfgPath is the path we'll eventually write the final config to. configDir is the parent directory of cfgPath and is where the wizard drops ancillary files (API key files, model cache, etc.).

func (*Wizard) Run

func (w *Wizard) Run(ctx context.Context) error

type Writer

type Writer interface {
	// StepHeader prints the "Step N of M: Title" banner that frames
	// each section. The implementation should add surrounding
	// whitespace so sections are visually separated.
	StepHeader(n, of int, title string)

	// Section prints a free-standing header used outside the
	// numbered steps (Welcome, Verification, Next steps).
	Section(title string)

	// Paragraph prints one or more lines of prose with indentation
	// matching the checkmark/warning prefix width. Used for the
	// plain-English explanations above each prompt.
	Paragraph(lines ...string)

	// Prompt prints the final inline prompt that immediately
	// precedes input. No trailing newline -- the user types after.
	Prompt(text string)

	// Check prints "  ✓ <msg>" for a completed sub-step.
	Check(msg string)

	// Warn prints "  ⚠ <msg>" for a non-fatal issue. Use when we
	// continued past a minor hiccup; use Error for fatal stops.
	Warn(msg string)

	// ErrorLine prints "  ✗ <msg>" for a fatal failure within a
	// step. Named ErrorLine (not Error) to avoid shadowing the
	// builtin error type in method receivers.
	ErrorLine(msg string)

	// Blank prints a blank line. Used to separate prose from
	// prompts within a step.
	Blank()

	// Raw prints a line verbatim, no indentation or prefix. Used for
	// feature-map tables, example commands, and the end-of-wizard
	// next-steps block where explicit layout is needed.
	Raw(line string)

	// ProgressStart signals the beginning of a long operation (like
	// an embedding-model download). The implementation can choose to
	// show a spinner, a byte counter, or nothing; the wizard only
	// cares that it's told when the op starts and ends.
	ProgressStart(label string)

	// ProgressUpdate reports incremental progress (bytes or count).
	// Total may be zero if the total isn't known up front.
	ProgressUpdate(done, total int64)

	// ProgressEnd closes out the progress display.
	ProgressEnd()
}

Writer is the test seam for wizard output. Everything the wizard prints to the user (banners, step headers, prompts, checkmarks, warnings, errors, progress) goes through a Writer. The terminal implementation writes to stdout with ANSI-safe formatting; tests capture output into a buffer.

Why not just use fmt.Fprintln everywhere:

  • We want consistent prefix conventions (✓ for success, ⚠ for warnings, ✗ for errors) with no stray emoji elsewhere. A central Writer enforces this.
  • Tests benefit from a structured API (Check, Warn, Error) that lets them assert "did the wizard report success on step N" without substring-matching raw output.
  • A future non-terminal driver (imagine a plain-HTML wizard output for a desktop app) would swap the Writer impl without touching step logic.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL