Files
zzet--gortex/cmd/gortex/root.go
T
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

232 lines
8.4 KiB
Go

package main
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/zzet/gortex/internal/persistence"
"github.com/zzet/gortex/internal/platform"
"github.com/zzet/gortex/internal/progress"
"github.com/zzet/gortex/internal/telemetry"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// exitCodeError carries a specific process exit code out of a command's RunE
// without printing the usual "Error:" banner. A command sets it (with
// SilenceErrors) to communicate a machine-readable outcome — e.g. `affected`
// uses it so a CI script can branch on whether any test was affected.
type exitCodeError struct {
code int
msg string
}
func (e *exitCodeError) Error() string { return e.msg }
func (e *exitCodeError) ExitCode() int { return e.code }
var (
cfgFile string
logLevel string
noProgress bool
)
var rootCmd = &cobra.Command{
Use: "gortex",
Short: "Code intelligence engine — indexes repos into a queryable knowledge graph",
// Runs before every subcommand (cobra walks to the nearest
// PersistentPreRun; no subcommand defines its own). Fold any state
// left by older versions in the split ~/.config / ~/.cache / flat
// ~/.gortex layout into the unified ~/.gortex tree before a command
// opens the store or reads config. Best-effort + idempotent, so it's
// cheap on every run and silent after the first.
PersistentPreRun: func(cmd *cobra.Command, args []string) {
platform.MigrateToUnifiedHome(func(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
})
},
// Runs after every subcommand (no subcommand defines its own
// PersistentPostRun, so cobra falls through to the root's). Records the
// command that just ran for opt-in anonymous usage telemetry. Fail-silent
// and consent-gated, so a default run touches no disk and never affects
// the command's exit.
PersistentPostRun: func(cmd *cobra.Command, args []string) {
recordCLIUsage(cmd, telemetry.NewStore(platform.TelemetryDir()), os.Getenv)
recordCLIEvent(cmd, os.Getenv)
},
}
// cliEventSidecarPath resolves the sidecar database the consent-free CLI-verb
// ledger writes. A package var so tests can redirect it away from the real
// ~/.gortex without touching the home layout.
var cliEventSidecarPath = func() string {
return persistence.DefaultSidecarPath(platform.DataDir())
}
// recordCLIEvent records one cli_events row for the verb that just ran,
// CONSENT-FREE, scoped by the GORTEX_SESSION_ID correlation id. It is the
// grounded source for a context-budget receipt's cli_verbs_used /
// safety_steps_run: telemetry is opt-in-OFF and keeps only daily counts with
// no session dimension, so it cannot answer "which verbs did THIS session
// run". This ledger can.
//
// Gating, in order, so the default interactive path pays zero cost:
// - No GORTEX_SESSION_ID → return early (no sidecar opened). Only an agent
// session that stamps the id pays the per-invocation sqlite write.
// - Skip the same cases recordCLIUsage skips: a re-spawned daemon child, an
// empty verb, and the self-referential telemetry subcommand.
//
// Best-effort and fail-silent: any error opening or writing the sidecar is
// swallowed so accounting never affects the command's exit.
func recordCLIEvent(cmd *cobra.Command, getenv func(string) string) {
sessionID := getenv("GORTEX_SESSION_ID")
if sessionID == "" {
return
}
if getenv("GORTEX_DAEMON_CHILD") == "1" {
return
}
verb := cliCommandDim(cmd)
if verb == "" || verb == "telemetry" || strings.HasPrefix(verb, "telemetry.") {
return
}
sc, err := persistence.OpenSidecar(cliEventSidecarPath())
if err != nil || sc == nil {
return
}
defer func() { _ = sc.Close() }()
_ = sc.AddCLIEvent(time.Now(), sessionID, verb)
}
// cliCommandDim renders a cobra command's path below the root as a dim-safe
// token — "gortex daemon start" → "daemon.start", "gortex review" → "review".
// Returns "" for the bare root (nothing meaningful ran).
func cliCommandDim(cmd *cobra.Command) string {
path := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()))
return strings.ReplaceAll(path, " ", ".")
}
// recordCLIUsage counts the CLI command that just ran under the cli_command
// metric, gated on consent. It is fail-silent and builds the recorder only when
// consent is enabled, so a default (telemetry-off) invocation never opens the
// telemetry directory. getenv is injected for tests.
func recordCLIUsage(cmd *cobra.Command, store *telemetry.Store, getenv func(string) string) {
consent := telemetry.ResolveConsent(telemetry.LoadConsentConfig(platform.TelemetryDir()), getenv)
if !consent.Enabled {
return
}
// A detached daemon re-spawns this binary with GORTEX_DAEMON_CHILD=1 to run
// `daemon start`; counting that re-spawn would double-record the user's one
// `gortex daemon start`. Skip it.
if getenv("GORTEX_DAEMON_CHILD") == "1" {
return
}
dim := cliCommandDim(cmd)
// Exclude the telemetry subcommand itself — recording usage of the
// telemetry on/off/status controls is self-referential noise.
if dim == "" || dim == "telemetry" || strings.HasPrefix(dim, "telemetry.") {
return
}
rec := telemetry.NewRecorder(consent, store)
rec.Record("cli_command", dim)
rec.Flush()
}
func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default .gortex.yaml)")
rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "log level: debug|info|warn|error")
rootCmd.PersistentFlags().BoolVar(&noProgress, "no-progress", false, "disable animated progress output (also honored: NO_COLOR, TERM=dumb, CI, non-TTY stderr)")
}
func newLogger() *zap.Logger {
level := zapcore.InfoLevel
switch logLevel {
case "debug":
level = zapcore.DebugLevel
case "warn":
level = zapcore.WarnLevel
case "error":
level = zapcore.ErrorLevel
}
cfg := zap.NewProductionConfig()
cfg.Level = zap.NewAtomicLevelAt(level)
cfg.OutputPaths = []string{"stderr"}
cfg.ErrorOutputPaths = []string{"stderr"}
logger, err := cfg.Build()
if err != nil {
// Fallback.
logger = zap.NewNop()
}
return logger
}
func execute() {
assignCommandGroups()
// Recover the `gortex <tool>` misuse (issue #259) before cobra prints a
// bare "unknown command": point the user at `gortex call <tool> --arg …`.
if maybeToolInvocationHint(os.Stderr, os.Args[1:]) {
os.Exit(1)
}
err := rootCmd.Execute()
// A command that errored out (or was interrupted) mid-animation leaves
// the cursor hidden; re-show it before any exit path. No-op otherwise.
progress.RestoreTerminal()
if err != nil {
var ec *exitCodeError
if errors.As(err, &ec) {
if ec.msg != "" {
fmt.Fprintln(os.Stderr, ec.msg)
}
os.Exit(ec.code)
}
os.Exit(1)
}
}
// assignCommandGroups organizes `gortex --help` into intent groups instead
// of one flat command list, with the MCP server (how editors and agents
// connect) called out first and the CLI commands grouped by what they do.
// Called once before Execute, after every command's init() has registered
// it. Commands left unmapped (internal/utility verbs) fall under cobra's
// "Additional Commands" heading.
func assignCommandGroups() {
if rootCmd.ContainsGroup("serve") {
return // idempotent — already grouped
}
rootCmd.AddGroup(
&cobra.Group{ID: "serve", Title: "MCP server — connect editors & agents:"},
&cobra.Group{ID: "engine", Title: "Daemon & repositories:"},
&cobra.Group{ID: "query", Title: "Query & explore the graph:"},
&cobra.Group{ID: "edit", Title: "Edit safely & verify:"},
&cobra.Group{ID: "memory", Title: "Session & durable memory:"},
&cobra.Group{ID: "index", Title: "Index & enrich:"},
&cobra.Group{ID: "setup", Title: "Setup & configuration:"},
)
groupOf := map[string]string{
"mcp": "serve",
"daemon": "engine", "track": "engine", "untrack": "engine",
"repos": "engine", "status": "engine", "proxy": "engine", "workspace": "engine",
"query": "query", "context": "query", "audit": "query", "wiki": "query",
"docs": "query", "export": "query", "wakeup": "query", "prs": "query",
"review": "query", "call": "query", "tools": "query",
"analyze": "query", "flow": "query", "taint": "query",
"clones": "query", "feedback": "query",
"edit": "edit",
"memory": "memory",
"enrich": "index", "db": "index",
"init": "setup", "install": "setup", "uninstall": "setup", "agents": "setup",
"hook": "setup", "githook": "setup", "config": "setup",
"provider": "setup", "plugin": "setup", "cloud": "setup",
}
for _, c := range rootCmd.Commands() {
if id, ok := groupOf[c.Name()]; ok {
c.GroupID = id
}
}
}