a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
588 lines
17 KiB
Go
588 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/zzet/gortex/internal/agents"
|
|
"github.com/zzet/gortex/internal/progress"
|
|
"github.com/zzet/gortex/internal/tui"
|
|
)
|
|
|
|
// agentLabels maps adapter Name() → human-friendly label shown in the wizard
|
|
// checklist. Anything not listed falls through to title-casing the slug, so a
|
|
// new adapter that ships before this table is updated still renders sanely.
|
|
var agentLabels = map[string]string{
|
|
"claude-code": "Claude Code",
|
|
"cursor": "Cursor",
|
|
"aider": "Aider",
|
|
"antigravity": "Antigravity",
|
|
"cline": "Cline",
|
|
"codex": "Codex CLI",
|
|
"continue": "Continue.dev",
|
|
"gemini": "Gemini CLI",
|
|
"hermes": "Hermes",
|
|
"kimi": "Kimi Code CLI",
|
|
"kilocode": "Kilo Code",
|
|
"kiro": "Kiro",
|
|
"oh-my-pi": "Oh My Pi",
|
|
"opencode": "OpenCode",
|
|
"openclaw": "OpenClaw",
|
|
"pi": "Pi",
|
|
"vscode": "VS Code (Copilot)",
|
|
"windsurf": "Windsurf",
|
|
"zed": "Zed",
|
|
}
|
|
|
|
// agentDetails is the one-line "what does Gortex write here" hint that
|
|
// appears in dim text right of each agent's label.
|
|
var agentDetails = map[string]string{
|
|
"claude-code": ".mcp.json · .claude/ hooks · CLAUDE.md · skills",
|
|
"cursor": ".cursor/rules + .cursor/mcp.json",
|
|
"aider": ".aider.conf.yml",
|
|
"antigravity": ".antigravity/mcp.json",
|
|
"cline": ".clinerules + cline_mcp_settings.json",
|
|
"codex": "AGENTS.md + config.toml hooks",
|
|
"continue": ".continue/config.json",
|
|
"gemini": ".gemini/settings.json",
|
|
"hermes": "~/.hermes/config.yaml + profiles + skills",
|
|
"kimi": ".kimi-code/mcp.json · user hook via install",
|
|
"kilocode": ".kilocode/mcp.json",
|
|
"kiro": ".kiro/settings/mcp.json",
|
|
"oh-my-pi": ".omp/mcp.json",
|
|
"opencode": "opencode.json",
|
|
"openclaw": ".openclaw/mcp.json",
|
|
"pi": ".pi/extensions/gortex",
|
|
"vscode": ".vscode/mcp.json",
|
|
"windsurf": ".windsurf/mcp.json",
|
|
"zed": ".zed/settings.json",
|
|
}
|
|
|
|
// agentLabel returns a friendly display name for the given adapter slug.
|
|
func agentLabel(name string) string {
|
|
if l, ok := agentLabels[name]; ok {
|
|
return l
|
|
}
|
|
parts := strings.Split(name, "-")
|
|
for i, p := range parts {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// agentDetail returns the dim "writes …" hint for the given adapter slug.
|
|
func agentDetail(name string) string {
|
|
if d, ok := agentDetails[name]; ok {
|
|
return d
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// wizardStep is the current screen the user is looking at.
|
|
type wizardStep int
|
|
|
|
const (
|
|
stepAgents wizardStep = iota
|
|
stepOptions
|
|
stepConfirm
|
|
stepCancelled
|
|
)
|
|
|
|
// initWizardModel is the bubbletea Model for `gortex init -i` (and reused by
|
|
// `gortex install -i` with a different title). It drives the pre-flight
|
|
// selection screens; once the user confirms, runInit / runInstall takes over
|
|
// and the dashboard is rendered by a separate Dashboard model. Keeping the
|
|
// two models separate avoids the wizard having to know about indexer state.
|
|
type initWizardModel struct {
|
|
step wizardStep
|
|
checklist tui.Checklist
|
|
options tui.OptionsPanel
|
|
title string // banner title, e.g. "gortex init" or "gortex install"
|
|
rootPath string // banner subtitle target ("this repo" / "your machine")
|
|
|
|
tick int
|
|
width int
|
|
confirmed bool
|
|
cancelled bool
|
|
|
|
// Resolved choices — read by the caller after Run() returns.
|
|
pickedAgents []string
|
|
hooks bool
|
|
hookMode string
|
|
analyze bool
|
|
skills bool
|
|
telemetry bool
|
|
showTelemetry bool
|
|
}
|
|
|
|
// initialDetection runs adapter.Detect for every registered adapter so the
|
|
// wizard can pre-check the ones already present on disk. Detect is contractually
|
|
// side-effect-free, so this is cheap and safe to run during model setup.
|
|
func detectAdapters(adapters []agents.Adapter, env agents.Env) map[string]bool {
|
|
out := make(map[string]bool, len(adapters))
|
|
for _, a := range adapters {
|
|
ok, _ := a.Detect(env)
|
|
out[a.Name()] = ok
|
|
}
|
|
return out
|
|
}
|
|
|
|
// newInitWizardModel builds the wizard, seeded with the registered agents.
|
|
// Detected adapters start checked; Claude Code is always checked (it's the
|
|
// load-bearing adapter for hooks + skills, and unchecking it almost always
|
|
// reflects a mis-click, not intent).
|
|
func newInitWizardModel(rootPath string, registered []agents.Adapter, detected map[string]bool, defaults initDefaults) *initWizardModel {
|
|
items := make([]tui.CheckItem, 0, len(registered))
|
|
for _, a := range registered {
|
|
name := a.Name()
|
|
picked := detected[name]
|
|
// Claude Code always defaults to picked — it owns the hook surface
|
|
// and the CLAUDE.md global guidance; opting it out makes most other
|
|
// agents pointless. The user can still uncheck it in the wizard.
|
|
if name == "claude-code" {
|
|
picked = true
|
|
}
|
|
items = append(items, tui.CheckItem{
|
|
Label: agentLabel(name),
|
|
Detail: agentDetail(name),
|
|
Picked: picked,
|
|
})
|
|
}
|
|
cl := tui.NewChecklist(items)
|
|
|
|
opts := tui.OptionsPanel{
|
|
Toggles: []tui.Toggle{
|
|
{
|
|
Label: "Agent hooks",
|
|
Hint: "Claude Code + Codex",
|
|
Detail: "inject graph orientation and redirect indexed source reads where supported",
|
|
On: defaults.hooks,
|
|
},
|
|
{
|
|
Label: "Codebase analysis",
|
|
Hint: "richer CLAUDE.md preamble (--analyze)",
|
|
Detail: "index the repo once to embed a codebase overview into CLAUDE.md",
|
|
On: defaults.analyze,
|
|
},
|
|
{
|
|
Label: "Community skills",
|
|
Hint: "per-cluster routing under .claude/skills (--skills)",
|
|
Detail: "auto-generate one SKILL.md per detected community",
|
|
On: defaults.skills,
|
|
},
|
|
},
|
|
Selects: []tui.Select{
|
|
{
|
|
Label: "Hook posture",
|
|
Options: []tui.SelectOption{
|
|
{Value: "deny", Label: "deny", Detail: "PreToolUse refuses Read/Grep/Glob against indexed source"},
|
|
{Value: "enrich", Label: "enrich", Detail: "PostToolUse appends graph context after the tool runs"},
|
|
{Value: "consult-unlock", Label: "consult-unlock", Detail: "deny fallback reads until Gortex is queried once this session"},
|
|
{Value: "nudge", Label: "nudge", Detail: "soft-deny once per burst, then let the next call proceed"},
|
|
},
|
|
Picked: hookModeIndex(defaults.hookMode),
|
|
},
|
|
},
|
|
}
|
|
|
|
// Install-only: append the anonymous-telemetry toggle as the last toggle
|
|
// (kept last so the hooks/analyze/skills positional read-back is stable).
|
|
if defaults.showTelemetry {
|
|
opts.Toggles = append(opts.Toggles, tui.Toggle{
|
|
Label: "Anonymous telemetry",
|
|
Hint: "tool/command counts only — off by default",
|
|
Detail: "no code, paths, or names; toggle later with `gortex telemetry on|off`",
|
|
On: defaults.telemetry,
|
|
})
|
|
}
|
|
|
|
return &initWizardModel{
|
|
step: stepAgents,
|
|
checklist: cl,
|
|
options: opts,
|
|
title: "gortex init",
|
|
rootPath: rootPath,
|
|
hooks: defaults.hooks,
|
|
hookMode: defaults.hookMode,
|
|
analyze: defaults.analyze,
|
|
skills: defaults.skills,
|
|
telemetry: defaults.telemetry,
|
|
showTelemetry: defaults.showTelemetry,
|
|
}
|
|
}
|
|
|
|
// initDefaults bundles the boolean defaults the wizard inherits from CLI
|
|
// flags / global config. Resets are cheaper than threading a half-dozen
|
|
// scalars through newInitWizardModel.
|
|
type initDefaults struct {
|
|
hooks bool
|
|
hookMode string
|
|
analyze bool
|
|
skills bool
|
|
// telemetry seeds the (install-only) anonymous-telemetry toggle;
|
|
// showTelemetry adds that toggle to the options panel. `gortex init`
|
|
// leaves both zero so the toggle never appears there.
|
|
telemetry bool
|
|
showTelemetry bool
|
|
}
|
|
|
|
// hookModeIndex maps a hook-mode value to its Select index, defaulting to
|
|
// "deny" (0) on an empty / unknown value.
|
|
func hookModeIndex(mode string) int {
|
|
switch mode {
|
|
case "enrich":
|
|
return 1
|
|
case "consult-unlock":
|
|
return 2
|
|
case "nudge":
|
|
return 3
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// wizardTickMsg paces the mesh animation at the same 80ms cadence as the
|
|
// dashboard so the brand mark feels consistent across screens.
|
|
type wizardTickMsg time.Time
|
|
|
|
func wizardTick() tea.Cmd {
|
|
return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg { return wizardTickMsg(t) })
|
|
}
|
|
|
|
// Init starts the mesh animation ticker.
|
|
func (m *initWizardModel) Init() tea.Cmd { return wizardTick() }
|
|
|
|
// Update handles keyboard input + ticks. Quit on q / esc / ctrl-c. Enter on
|
|
// stepConfirm sets m.confirmed and quits — the caller checks that flag to
|
|
// decide whether to proceed with runInit.
|
|
func (m *initWizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case wizardTickMsg:
|
|
_ = msg
|
|
m.tick++
|
|
return m, wizardTick()
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
return m.handleKey(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *initWizardModel) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
// Global escape hatches first.
|
|
switch k.String() {
|
|
case "ctrl+c", "q", "esc":
|
|
m.cancelled = true
|
|
m.step = stepCancelled
|
|
return m, tea.Quit
|
|
}
|
|
switch m.step {
|
|
case stepAgents:
|
|
return m.handleAgentsKey(k)
|
|
case stepOptions:
|
|
return m.handleOptionsKey(k)
|
|
case stepConfirm:
|
|
return m.handleConfirmKey(k)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *initWizardModel) handleAgentsKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch k.String() {
|
|
case "up", "k":
|
|
m.checklist.Move(-1)
|
|
case "down", "j":
|
|
m.checklist.Move(1)
|
|
case " ":
|
|
m.checklist.ToggleCursor()
|
|
case "a":
|
|
m.checklist.PickAll()
|
|
case "n":
|
|
m.checklist.PickNone()
|
|
case "i":
|
|
m.checklist.PickInvert()
|
|
case "enter":
|
|
if m.checklist.PickedCount() == 0 {
|
|
// Nothing picked — nudge by snapping to claude-code as the
|
|
// load-bearing default rather than refusing to advance.
|
|
for i := range m.checklist.Items {
|
|
if m.checklist.Items[i].Label == agentLabel("claude-code") {
|
|
m.checklist.Items[i].Picked = true
|
|
}
|
|
}
|
|
}
|
|
m.step = stepOptions
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *initWizardModel) handleOptionsKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch k.String() {
|
|
case "up", "k":
|
|
m.options.Move(-1)
|
|
case "down", "j":
|
|
m.options.Move(1)
|
|
case " ", "enter":
|
|
// Space toggles toggles, advances selects, and on the last row falls
|
|
// through to "next step" — but only when the cursor is on a Toggle.
|
|
// Selects use ←/→.
|
|
if !m.options.ToggleCursor() {
|
|
// Select row: ←/→ behaviour; enter still advances when nothing
|
|
// is highlighted to flip.
|
|
if k.String() == "enter" {
|
|
m.step = stepConfirm
|
|
}
|
|
}
|
|
case "left", "h":
|
|
m.options.CycleCursor(-1)
|
|
case "right", "l":
|
|
m.options.CycleCursor(1)
|
|
case "tab", "N":
|
|
m.step = stepConfirm
|
|
case "backspace":
|
|
m.step = stepAgents
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *initWizardModel) handleConfirmKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch k.String() {
|
|
case "enter", "y", "Y":
|
|
m.collectChoices()
|
|
m.confirmed = true
|
|
return m, tea.Quit
|
|
case "backspace", "b":
|
|
m.step = stepOptions
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// collectChoices pulls the wizard's pick state out of the widgets and into
|
|
// the flat fields runInit reads after the model exits. Called once on
|
|
// confirmation so the wizard's internal state can change without leaking.
|
|
func (m *initWizardModel) collectChoices() {
|
|
m.pickedAgents = nil
|
|
for _, it := range m.checklist.Items {
|
|
if !it.Picked {
|
|
continue
|
|
}
|
|
// Round-trip label → slug; preserve registration order.
|
|
for slug, label := range agentLabels {
|
|
if label == it.Label {
|
|
m.pickedAgents = append(m.pickedAgents, slug)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(m.pickedAgents)
|
|
|
|
if len(m.options.Toggles) >= 3 {
|
|
m.hooks = m.options.Toggles[0].On
|
|
m.analyze = m.options.Toggles[1].On
|
|
m.skills = m.options.Toggles[2].On
|
|
}
|
|
if m.showTelemetry && len(m.options.Toggles) >= 4 {
|
|
m.telemetry = m.options.Toggles[3].On
|
|
}
|
|
if len(m.options.Selects) >= 1 {
|
|
m.hookMode = m.options.Selects[0].Value()
|
|
}
|
|
}
|
|
|
|
// View renders the current step. Layout: banner (always), step-line, body,
|
|
// hint footer. Each step's body is rendered by its own helper so the View
|
|
// stays a thin switch.
|
|
func (m *initWizardModel) View() string {
|
|
if m.step == stepCancelled {
|
|
return progress.StyleHint.Render(" cancelled — no changes made.\n")
|
|
}
|
|
|
|
title := m.title
|
|
if title == "" {
|
|
title = "gortex init"
|
|
}
|
|
banner := tui.Banner{
|
|
Title: title,
|
|
Subtitle: subtitleForStep(m.step, m.rootPath),
|
|
Tick: m.tick,
|
|
}.Render()
|
|
|
|
body := ""
|
|
hint := ""
|
|
step := 0
|
|
heading := ""
|
|
switch m.step {
|
|
case stepAgents:
|
|
step, heading = 1, "Pick coding assistants"
|
|
body = m.checklist.Render(10, 18)
|
|
hint = tui.KeyHint("↑/↓ move", "space toggle", "a all · n none · i invert", "enter next", "q quit")
|
|
case stepOptions:
|
|
step, heading = 2, "Configure options"
|
|
body = m.options.Render()
|
|
hint = tui.KeyHint("↑/↓ row", "space/enter toggle", "←/→ cycle select", "tab next", "backspace prev")
|
|
case stepConfirm:
|
|
step, heading = 3, "Confirm"
|
|
body = m.renderConfirmSummary()
|
|
hint = tui.KeyHint("enter run", "backspace prev", "q quit")
|
|
}
|
|
|
|
stepLine := ""
|
|
if heading != "" {
|
|
stepLine = tui.StepLine(step, 3, heading)
|
|
}
|
|
|
|
parts := []string{banner, "", stepLine, "", body, "", hint}
|
|
return lipgloss.JoinVertical(lipgloss.Left, parts...) + "\n"
|
|
}
|
|
|
|
func subtitleForStep(s wizardStep, root string) string {
|
|
switch s {
|
|
case stepAgents:
|
|
// Use "this repo" when the path is "." or empty — the literal dot
|
|
// reads awkwardly in the sentence ("wire ..").
|
|
target := root
|
|
if target == "" || target == "." {
|
|
target = "this repo"
|
|
}
|
|
return "Welcome — let's wire " + target + "."
|
|
case stepOptions:
|
|
return "Tune the integration."
|
|
case stepConfirm:
|
|
return "Review and run."
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// renderConfirmSummary draws a card-style block listing every choice so the
|
|
// user can verify before any disk write happens.
|
|
func (m *initWizardModel) renderConfirmSummary() string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(progress.Heading("agents", fmt.Sprintf("%d", m.checklist.PickedCount())))
|
|
sb.WriteString("\n")
|
|
picked := m.collectPickedLabels()
|
|
if len(picked) == 0 {
|
|
sb.WriteString(progress.StyleHint.Render(" (none — wizard will fall back to Claude Code)"))
|
|
} else {
|
|
sb.WriteString(" ")
|
|
sb.WriteString(progress.Chips(picked, 64))
|
|
}
|
|
sb.WriteString("\n\n")
|
|
|
|
sb.WriteString(progress.Heading("options"))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(progress.Row("Hooks", chipBool(m.options.Toggles[0].On), 18))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(progress.Row("Hook posture", m.options.Selects[0].Value(), 18))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(progress.Row("Analyze repo", chipBool(m.options.Toggles[1].On), 18))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(progress.Row("Generate skills", chipBool(m.options.Toggles[2].On), 18))
|
|
sb.WriteString("\n\n")
|
|
|
|
sb.WriteString(progress.StyleHint.Render(" press enter to apply these changes — files are written atomically and dry-run is available via --dry-run."))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m *initWizardModel) collectPickedLabels() []string {
|
|
out := make([]string, 0, len(m.checklist.Items))
|
|
for _, it := range m.checklist.Items {
|
|
if it.Picked {
|
|
out = append(out, it.Label)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func chipBool(on bool) string {
|
|
if on {
|
|
return progress.StyleOK.Render("on")
|
|
}
|
|
return progress.StyleHint.Render("off")
|
|
}
|
|
|
|
// cobraInitState carries the per-run inputs the wizard needs that aren't
|
|
// already on the package-level init* globals. Keeping it as a struct rather
|
|
// than passing scalars leaves room to grow without touching every callsite.
|
|
type cobraInitState struct {
|
|
rootPath string
|
|
}
|
|
|
|
// runInitWizard launches the bubbletea wizard, blocks until the user either
|
|
// confirms or cancels, and (on confirmation) mutates the init* globals to
|
|
// reflect the wizard's picks. Returns cancelled=true when the user aborted
|
|
// (Ctrl-C / q / Esc) so the caller can short-circuit before any disk write.
|
|
//
|
|
// The wizard writes to cmd's stderr — alt-screen mode keeps the wizard's
|
|
// frame on its own surface, so when the user confirms the terminal scrollback
|
|
// is clean before the dashboard takes over.
|
|
func runInitWizard(cmd interface {
|
|
ErrOrStderr() io.Writer
|
|
}, st *cobraInitState) (bool, error) {
|
|
registry := buildRegistry()
|
|
registered := registry.All()
|
|
|
|
home, _ := osUserHomeDir()
|
|
env := agents.Env{
|
|
Root: st.rootPath,
|
|
Home: home,
|
|
Mode: agents.ModeProject,
|
|
InstallHooks: initInstallHooks,
|
|
HookMode: initHookMode,
|
|
}
|
|
detected := detectAdapters(registered, env)
|
|
|
|
defaults := initDefaults{
|
|
hooks: initInstallHooks,
|
|
hookMode: firstNonEmpty(initHookMode, "deny"),
|
|
analyze: initAnalyze,
|
|
skills: initSkills,
|
|
}
|
|
model := newInitWizardModel(st.rootPath, registered, detected, defaults)
|
|
|
|
prog := tea.NewProgram(model,
|
|
tea.WithOutput(cmd.ErrOrStderr()),
|
|
tea.WithAltScreen(),
|
|
tea.WithoutSignalHandler(),
|
|
)
|
|
finalModel, err := prog.Run()
|
|
if err != nil {
|
|
return false, fmt.Errorf("wizard: %w", err)
|
|
}
|
|
m, ok := finalModel.(*initWizardModel)
|
|
if !ok || m.cancelled {
|
|
return true, nil
|
|
}
|
|
if !m.confirmed {
|
|
return true, nil
|
|
}
|
|
|
|
// Pour wizard picks into the init* globals so the orchestration code
|
|
// downstream sees them as if they came from CLI flags.
|
|
initInstallHooks = m.hooks
|
|
initHookMode = m.hookMode
|
|
initAnalyze = m.analyze
|
|
initSkills = m.skills
|
|
if len(m.pickedAgents) > 0 {
|
|
initAgents = strings.Join(m.pickedAgents, ",")
|
|
}
|
|
wizardSelectedDashboard = true
|
|
return false, nil
|
|
}
|
|
|
|
// osUserHomeDir and firstNonEmpty already live in daemon_status_tui.go so we
|
|
// reuse them. Kept here as a comment marker because grepping for the seam
|
|
// from this file is the more natural reading order.
|