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
359 lines
14 KiB
Go
359 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/zzet/gortex/internal/agents"
|
|
"github.com/zzet/gortex/internal/agents/claudecode"
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
"github.com/zzet/gortex/internal/pathkey"
|
|
"github.com/zzet/gortex/internal/platform"
|
|
"github.com/zzet/gortex/internal/progress"
|
|
"github.com/zzet/gortex/internal/telemetry"
|
|
)
|
|
|
|
// Install-only flags. Repo-local `gortex init` has its own set —
|
|
// kept separate to avoid cross-pollination when wiring from the
|
|
// interactive wizard or the --json reporters.
|
|
var (
|
|
installYes bool
|
|
installInteractive bool
|
|
installAgents string
|
|
installPrintConfig string
|
|
installAgentsSkip string
|
|
installJSON bool
|
|
installDryRun bool
|
|
installForce bool
|
|
installHooks = true
|
|
installNoHooks bool
|
|
installHookMode string
|
|
installClaudeMd = true
|
|
installNoClaudeMd bool
|
|
installClaudeConfigDir string
|
|
installStartDaemon bool
|
|
installTrackRepo bool
|
|
installTrackPath string
|
|
installTelemetry bool
|
|
installNoTelemetry bool
|
|
)
|
|
|
|
var installCmd = &cobra.Command{
|
|
Use: "install",
|
|
Short: "Install machine-wide Gortex integration for every detected AI assistant",
|
|
Long: `Install Gortex at the user level: user-wide MCP config, user-level
|
|
skills / Knowledge Items, and (optionally) user-level hooks.
|
|
|
|
Run once per machine. Per-repo setup is done by ` + "`gortex init`" + ` inside
|
|
each repository.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: runInstall,
|
|
}
|
|
|
|
func init() {
|
|
installCmd.Flags().BoolVarP(&installYes, "yes", "y", false, "skip any interactive prompts (implied when stdin is not a TTY)")
|
|
installCmd.Flags().BoolVarP(&installInteractive, "interactive", "i", false, "force the full-screen install wizard (banner + agent checklist + options panel + live dashboard)")
|
|
installCmd.Flags().StringVar(&installAgents, "agents", "", "comma-separated list of agents to configure ('auto' means every registered adapter)")
|
|
installCmd.Flags().StringVar(&installAgentsSkip, "agents-skip", "", "comma-separated list of agents to skip (composable with --agents)")
|
|
installCmd.Flags().BoolVar(&installJSON, "json", false, "emit a structured JSON report on stdout (banner still goes to stderr)")
|
|
installCmd.Flags().BoolVar(&installDryRun, "dry-run", false, "plan writes without modifying disk")
|
|
installCmd.Flags().BoolVar(&installForce, "force", false, "overwrite keys we would otherwise preserve during a merge")
|
|
installCmd.Flags().BoolVar(&installHooks, "hooks", true, "install user-level hooks for supported agents; use --no-hooks to skip")
|
|
installCmd.Flags().BoolVar(&installNoHooks, "no-hooks", false, "skip user-level hooks for supported agents (inverse of --hooks)")
|
|
installCmd.Flags().StringVar(&installHookMode, "hook-mode", "deny",
|
|
"hook posture: 'deny' (PreToolUse redirects Grep/Glob/Read of indexed source), 'enrich' "+
|
|
"(PreToolUse never denies; PostToolUse appends graph context after the tool runs — easier onboarding), "+
|
|
"'consult-unlock' (deny fallback reads until the Gortex graph is queried once this session, then downgrade to soft context), "+
|
|
"or 'nudge' (soft-deny once per burst of consecutive non-symbolic calls, then let the next call proceed)")
|
|
installCmd.Flags().BoolVar(&installClaudeMd, "claude-md", true, "merge Gortex rule block into ~/.claude/CLAUDE.md; use --no-claude-md to skip")
|
|
installCmd.Flags().BoolVar(&installNoClaudeMd, "no-claude-md", false, "skip the ~/.claude/CLAUDE.md rule block (inverse of --claude-md)")
|
|
installCmd.Flags().StringVar(&installClaudeConfigDir, "claude-config-dir", "", "Claude Code config root to write into (skills/commands/agents/settings/CLAUDE.md/.claude.json); overrides $CLAUDE_CONFIG_DIR, defaults to ~/.claude. Useful for installing into a non-active profile or CI sandbox")
|
|
installCmd.Flags().StringVar(&installClaudeConfigDir, "config-root", "", "alias for --claude-config-dir")
|
|
installCmd.Flags().BoolVar(&installStartDaemon, "start", false, "start the daemon immediately after setup (detached)")
|
|
installCmd.Flags().BoolVar(&installTrackRepo, "track", false, "track a repository with the daemon after setup")
|
|
installCmd.Flags().StringVar(&installTrackPath, "track-path", ".", "repository to track when --track is set (default: current directory)")
|
|
installCmd.Flags().BoolVar(&installTelemetry, "telemetry", false, "opt in to anonymous usage telemetry (tool/command counts only; off by default)")
|
|
installCmd.Flags().BoolVar(&installNoTelemetry, "no-telemetry", false, "explicitly opt out of anonymous usage telemetry")
|
|
installCmd.Flags().StringVar(&installPrintConfig, "print-config", "", "print the config a single agent would install (JSON, zero writes) and exit")
|
|
|
|
rootCmd.AddCommand(installCmd)
|
|
}
|
|
|
|
func runInstall(cmd *cobra.Command, _ []string) (err error) {
|
|
if installNoHooks {
|
|
installHooks = false
|
|
}
|
|
if installNoClaudeMd {
|
|
installClaudeMd = false
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve home: %w", err)
|
|
}
|
|
if home == "" {
|
|
return fmt.Errorf("gortex install needs a home directory; $HOME is empty")
|
|
}
|
|
|
|
// An explicit --claude-config-dir / --config-root pins the Claude
|
|
// Code config root for every adapter write below (and the banner),
|
|
// overriding $CLAUDE_CONFIG_DIR. Resolve to an absolute path so a
|
|
// relative flag doesn't depend on the daemon's later cwd.
|
|
if installClaudeConfigDir != "" {
|
|
abs, err := filepath.Abs(installClaudeConfigDir)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve --claude-config-dir: %w", err)
|
|
}
|
|
claudecode.SetConfigDirOverride(abs)
|
|
}
|
|
|
|
// --print-config <agent>: a zero-write dry-run of one adapter's planned
|
|
// config, as JSON, then exit. Useful for "what would `gortex install`
|
|
// write for cursor?" without touching disk.
|
|
if installPrintConfig != "" {
|
|
root, _ := filepath.Abs(".")
|
|
return agents.PrintConfig(cmd.OutOrStdout(), buildRegistry(), installPrintConfig,
|
|
agents.Env{Root: root, Home: home, Mode: agents.ModeGlobal})
|
|
}
|
|
|
|
// Machine-global telemetry choice. An explicit --telemetry / --no-telemetry
|
|
// flag wins; otherwise seed the wizard toggle from the PERSISTED choice
|
|
// only (never the env-folded resolution), so a transient GORTEX_TELEMETRY /
|
|
// DO_NOT_TRACK in this shell can't be laundered into a durable opt-in or
|
|
// silently flip a saved one. Unset persisted choice → default off.
|
|
telemetryFlagSet := cmd.Flags().Changed("telemetry") || cmd.Flags().Changed("no-telemetry")
|
|
if installNoTelemetry {
|
|
installTelemetry = false
|
|
}
|
|
if !telemetryFlagSet {
|
|
if persisted := telemetry.LoadConsentConfig(platform.TelemetryDir()); persisted.Enabled != nil {
|
|
installTelemetry = *persisted.Enabled
|
|
}
|
|
}
|
|
|
|
// Interactive wizard — same shape as `gortex init -i`, just sourced from
|
|
// the install* globals and writing under Home instead of Root. The
|
|
// wizard mutates the install* globals (agents list, hooks, hook-mode) so
|
|
// the orchestration below sees them as if they came from the flag set.
|
|
wantWizard := installInteractive || (!installYes && progress.IsTTY(cmd.ErrOrStderr()) && isInteractive())
|
|
if wantWizard {
|
|
cancelled, werr := runInstallWizard(cmd, home)
|
|
if werr != nil {
|
|
return werr
|
|
}
|
|
if cancelled {
|
|
fmt.Fprintln(cmd.ErrOrStderr(), " cancelled — no changes made.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Persist the telemetry choice when the user expressed one — an explicit
|
|
// flag, or the interactive wizard (whose toggle wrote back into
|
|
// installTelemetry). A non-interactive run with no flag leaves the opt-in
|
|
// default (off) and the first-run notice untouched.
|
|
if (telemetryFlagSet || wantWizard) && !installDryRun {
|
|
if serr := telemetry.SaveConsent(platform.TelemetryDir(), installTelemetry, "installer", time.Now); serr != nil {
|
|
fmt.Fprintf(cmd.ErrOrStderr(), "[gortex install] warning: could not save telemetry choice: %v\n", serr)
|
|
}
|
|
}
|
|
|
|
realStderr := cmd.ErrOrStderr()
|
|
prog := selectInstallProgress(realStderr)
|
|
prog.Start("Installing gortex")
|
|
if installClaudeMd && !installDryRun {
|
|
prog.Sub(fmt.Sprintf("merging %s (use --no-claude-md to skip)",
|
|
claudecode.UserClaudeMdPath(home)))
|
|
}
|
|
|
|
// Buffer chatty adapter logs while the animation is running. On success
|
|
// we drop them entirely — the summary already conveys outcome. On
|
|
// failure we replay them so the user can debug.
|
|
var (
|
|
chatter bytes.Buffer
|
|
results []*agents.Result
|
|
opts agents.ApplyOpts
|
|
)
|
|
captured := prog.Enabled()
|
|
if captured {
|
|
cmd.SetErr(&chatter)
|
|
}
|
|
defer func() {
|
|
if err != nil {
|
|
prog.Fail(err)
|
|
} else {
|
|
prog.Done()
|
|
}
|
|
if captured {
|
|
cmd.SetErr(realStderr)
|
|
if err != nil {
|
|
_, _ = io.Copy(realStderr, &chatter)
|
|
}
|
|
}
|
|
if err == nil {
|
|
emitInstallHuman(realStderr, results, opts)
|
|
}
|
|
}()
|
|
|
|
env := agents.Env{
|
|
// Root is still set so adapters that write to the daemon
|
|
// config use the right cwd for "this is where I was run
|
|
// from", but no per-repo files get written in ModeGlobal.
|
|
Root: mustAbs(installTrackPath),
|
|
Home: home,
|
|
HookCommand: claudecode.ResolveHookCommand(cmd.ErrOrStderr()),
|
|
Mode: agents.ModeGlobal,
|
|
InstallHooks: installHooks,
|
|
HookMode: installHookMode,
|
|
InstallGlobalInstructions: installClaudeMd,
|
|
Stderr: cmd.ErrOrStderr(),
|
|
}
|
|
|
|
registry := buildRegistry()
|
|
selected, err := registry.Filter(installAgents, installAgentsSkip)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts = agents.ApplyOpts{DryRun: installDryRun, Force: installForce}
|
|
results = make([]*agents.Result, 0, len(selected))
|
|
prog.Stage(stageAdapters, fmt.Sprintf("%d adapter(s)", len(selected)))
|
|
for _, a := range selected {
|
|
prog.Sub(a.Name())
|
|
r, applyErr := a.Apply(env, opts)
|
|
if applyErr != nil {
|
|
// Claude Code is load-bearing — propagate. Other
|
|
// adapters warn so one broken editor doesn't abort.
|
|
if a.Name() == claudecode.Name {
|
|
return fmt.Errorf("%s: %w", a.Name(), applyErr)
|
|
}
|
|
fmt.Fprintf(cmd.ErrOrStderr(), "[gortex install] warning: %s setup failed: %v\n", a.Name(), applyErr)
|
|
}
|
|
if r != nil {
|
|
results = append(results, r)
|
|
}
|
|
}
|
|
prog.StageDone(stageAdapters, fmt.Sprintf("%d adapter(s) configured", len(results)))
|
|
|
|
// Ensure the daemon config file exists so --track doesn't
|
|
// fail with "no config" on first use.
|
|
if !installDryRun {
|
|
if err := ensureGlobalConfigExists(); err != nil {
|
|
return err
|
|
}
|
|
if err := runInstallFollowUps(cmd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Opt-in install telemetry: one bounded event per configured adapter. The
|
|
// user's just-saved telemetry choice is re-resolved, so an install that
|
|
// opted in is counted and an opt-out records nothing.
|
|
if !installDryRun {
|
|
recordInstallTelemetry(results)
|
|
}
|
|
|
|
if installJSON {
|
|
if err := emitInstallJSON(cmd.OutOrStdout(), results, opts); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recordInstallTelemetry records one install event per configured adapter,
|
|
// dimensioned by the adapter name. Consent-gated and fail-silent: a disabled
|
|
// recorder opens nothing.
|
|
func recordInstallTelemetry(results []*agents.Result) {
|
|
consent := telemetry.ResolveConsent(telemetry.LoadConsentConfig(platform.TelemetryDir()), os.Getenv)
|
|
if !consent.Enabled {
|
|
return
|
|
}
|
|
rec := telemetry.NewRecorder(consent, telemetry.NewStore(platform.TelemetryDir()))
|
|
for _, r := range results {
|
|
if r == nil || !r.Configured {
|
|
continue
|
|
}
|
|
telemetry.RecordInstall(rec, "install", r.Name)
|
|
}
|
|
rec.Flush()
|
|
}
|
|
|
|
// runInstallFollowUps runs the daemon-side post-setup operations
|
|
// that don't fit the Adapter interface: `--start` spawns the daemon
|
|
// detached, `--track` registers installTrackPath with the running
|
|
// daemon.
|
|
func runInstallFollowUps(cmd *cobra.Command) error {
|
|
w := cmd.ErrOrStderr()
|
|
|
|
if installStartDaemon {
|
|
if daemon.IsRunning() {
|
|
fmt.Fprintln(w, "[gortex install] daemon already running (skipped --start)")
|
|
} else {
|
|
if err := spawnDetachedDaemon(); err != nil {
|
|
return fmt.Errorf("start daemon: %w", err)
|
|
}
|
|
fmt.Fprintln(w, "[gortex install] daemon started (detached)")
|
|
}
|
|
}
|
|
|
|
if installTrackRepo {
|
|
// Normalise the volume for this NEW tracked path so a Windows drive
|
|
// letter converges with os.Getwd's upper-case convention before the
|
|
// daemon stores it. No-op on POSIX; never touches the basename.
|
|
abs := pathkey.NormalizeVolume(mustAbs(installTrackPath))
|
|
if !daemon.IsRunning() {
|
|
fmt.Fprintln(w, "[gortex install] ⚠ skipping --track: daemon is not running (try `gortex daemon start`)")
|
|
} else {
|
|
resp, err := trackViaDaemon(abs)
|
|
if err != nil {
|
|
return fmt.Errorf("track %s: %w", abs, err)
|
|
}
|
|
fmt.Fprintf(w, "[gortex install] tracked %s (%s)\n", abs, resp)
|
|
}
|
|
}
|
|
|
|
if !installStartDaemon {
|
|
fmt.Fprintln(w, "\nNext: start the daemon → `gortex daemon start --detach`")
|
|
}
|
|
if !installTrackRepo && installStartDaemon {
|
|
fmt.Fprintln(w, "Then: track a repo → `cd <repo> && gortex init` (or `gortex track <path>`)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func emitInstallJSON(w interface{ Write([]byte) (int, error) }, results []*agents.Result, opts agents.ApplyOpts) error {
|
|
payload := map[string]any{
|
|
"dry_run": opts.DryRun,
|
|
"force": opts.Force,
|
|
"mode": "global",
|
|
"agents": results,
|
|
}
|
|
enc := json.NewEncoder(w)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(payload)
|
|
}
|
|
|
|
func emitInstallHuman(w io.Writer, results []*agents.Result, opts agents.ApplyOpts) {
|
|
emitAgentSummary(w, results, opts, []string{
|
|
"cd into any repo and run `gortex init` to wire repo-local config for every detected assistant (MCP UIs differ by editor)",
|
|
})
|
|
}
|
|
|
|
func mustAbs(p string) string {
|
|
if p == "" {
|
|
p = "."
|
|
}
|
|
abs, err := filepath.Abs(p)
|
|
if err != nil {
|
|
return p
|
|
}
|
|
return abs
|
|
}
|