Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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
}