diff --git a/.claude/COMMON-COMMANDS.md b/.claude/COMMON-COMMANDS.md new file mode 100644 index 0000000..3e4a1ac --- /dev/null +++ b/.claude/COMMON-COMMANDS.md @@ -0,0 +1,7 @@ +# Commands + +- `go run ./cmd/jay/main.go` - Run the CLI application +- `go build` - Build the binary +- `go test ./...` - Run all tests +- `go vet ./...` - Run static analysis +- `golangci-lint run` - Run linter diff --git a/.vscode/settings.json b/.vscode/settings.json index c815ecf..53bdac9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -73,6 +73,7 @@ "Flaphead", "fortytw", "Frandisco", + "fset", "fsnotify", "fsys", "Fugazi", @@ -87,6 +88,7 @@ "gomega", "gomnd", "gomod", + "GOMODCACHE", "goreleaser", "gosec", "gosimple", @@ -100,6 +102,7 @@ "hanno", "hiber", "icase", + "iface", "incase", "inconshreveable", "ineffassign", @@ -127,6 +130,7 @@ "logr", "lorax", "Lovin", + "macfg", "mapstructure", "Marillion", "Marthas", @@ -134,6 +138,7 @@ "MDNA", "Megadeth", "mitchellh", + "mname", "Moonchild", "MΓΆtley", "msys", @@ -163,6 +168,7 @@ "Persistables", "Phloam", "pixa", + "pkgs", "Plastikman", "prealloc", "Ptrs", @@ -194,6 +200,8 @@ "Sprintf", "Spys", "staticcheck", + "stdlib", + "stringifiers", "struct", "structcheck", "stylecheck", @@ -210,6 +218,7 @@ "toplevel", "Tourbook", "tparallel", + "tparams", "tpers", "tpref", "Trac", diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2dd4054 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,179 @@ +# CLAUDE.md - agenor + +## Project Overview + +`agenor` is a file system traversal library that navigates directory trees and notifies +callers of events at each node. It extends the standard `filepath.Walk` with: regex/glob +filtering, hibernation (deferred activation of callbacks until a condition is met), +resume from a previously interrupted session, concurrent navigation via pants worker pool, +and hook-able traversal behaviour. + +- **Module**: `github.com/snivilised/agenor` +- **Package alias**: `age` (import as `age "github.com/snivilised/agenor"`) +- **Docs**: + +## Build & Test Commands + +- **Test all**: `go test ./...` +- **Dependencies**: `go mod tidy` + +## Package Architecture + +The dependency rule is: packages may only depend on packages in layers below them. +This rule does not apply to unit tests. + +```txt +πŸ”† user interface layer + age (root package) - public API; may use everything + +πŸ”† feature layer + internal/feat/resume - depends on pref, opts, kernel + internal/feat/sampling - depends on filter + internal/feat/hiber - depends on filter, services + internal/feat/filter - no internal deps + +πŸ”† central layer + internal/kernel - no internal deps + internal/enclave - depends on pref, override + internal/opts - depends on pref + internal/override - depends on tapable; must not use enclave + +πŸ”† support layer + pref - depends on life, services, persist + internal/persist - no internal deps + internal/services - no internal deps + +πŸ”† intermediary layer + life - no internal deps; must not use pref + +πŸ”† platform layer + tapable - depends on core + core - no internal deps + enums - no deps + tfs - no internal deps +``` + +## Core API + +### Traversal modes + +There are two traversal modes and two extents, giving four possible scenarios: + +| Mode | Extent | Description | +| --- | --- | --- | +| Walk | Prime | Sequential traversal from root | +| Walk | Resume | Sequential traversal resuming from a saved session | +| Run | Prime | Concurrent traversal from root | +| Run | Resume | Concurrent traversal resuming from a saved session | + +The low-level API composes these explicitly: + +```go +// Walk/Prime +age.Walk().Configure().Extent(age.Prime(facade, opts...)).Navigate(ctx) + +// Run/Resume +age.Run(wg).Configure().Extent(age.Resume(facade, opts...)).Navigate(ctx) +``` + +### Scenario composites + +To avoid conditional duplication at the call site, use the scenario composites: + +| Composite | Fixes | Selects by | +| --- | --- | --- | +| `Tortoise(isPrime)` | Walk | `isPrime bool` β†’ Prime or Resume | +| `Hare(isPrime, wg)` | Run | `isPrime bool` β†’ Prime or Resume | +| `Goldfish(isWalk, wg)` | Prime | `isWalk bool` β†’ Walk or Run | +| `Hydra(isWalk, isPrime, wg)` | neither | both `isWalk` and `isPrime` | + +Usage pattern - always pass `isPrime`/`isWalk` as named `const bool` values to avoid +lint warnings from bare literals: + +```go +const isPrime = true +age.Tortoise(isPrime)(facade, opts...).Navigate(ctx) + +var wg sync.WaitGroup +age.Hare(isPrime, &wg)(facade, opts...).Navigate(ctx) +wg.Wait() +``` + +### Facades + +Construct facades as named variables, never inline: + +```go +using := &pref.Using{...} +relic := &pref.Relic{...} // resume sessions only +``` + +- `pref.Using` - dependencies for a Prime session +- `pref.Relic` - saved state for a Resume session + +### Enums + +All enum values are in the `enums` package. Do not use `age.` prefixed aliases +for enum values - use `enums.` directly: + +```go +enums.SubscribeFiles // not age.SubscribeFiles +enums.MetricNoFilesInvoked // not age.MetricNoFilesInvoked +enums.ResumeStrategyFastward +``` + +### Options (With* functions) + +Options are passed as variadic `...pref.Option` to `Prime`/`Resume` or to a composite. +All `With*` option constructors are re-exported from the root `age` package: + +```go +age.WithFilter(...) +age.WithDepth(5) +age.WithOnBegin(handler) +age.WithCPU // use all available CPUs for Run +age.WithNoW(n) // use n workers for Run +``` + +Use `age.IfOption` / `age.IfOptionF` / `age.IfElseOptionF` for conditional options. + +## Key Types + +| Type | Package | Purpose | +| --- | --- | --- | +| `age.Node` | `core` | A file system node passed to the client callback | +| `age.Servant` | `core` | Provides the client with traversal properties | +| `age.Client` | `core` | The callback signature: `func(node *age.Node) error` | +| `age.Navigator` | `core` | Returned by `Extent()`; call `.Navigate(ctx)` on it | +| `age.Options` | `pref` | Full options struct available inside `With*` constructors | +| `age.Using` | `pref` | Alias for `pref.Using` (Prime facade) | +| `age.Relic` | `pref` | Alias for `pref.Relic` (Resume facade) | +| `age.TraversalFS` | `tfs` | File system interface required for traversal | + +## Internal Packages (do not import directly) + +- `internal/kernel` - core traversal engine +- `internal/feat/*` - feature plugins (filter, hiber, resume, sampling, nanny) +- `internal/enclave` - supervisor and kernel result types +- `internal/opts` - options loading and binding +- `internal/persist` - session state marshalling for resume +- `internal/services` - cross-cutting concerns (message bus) +- `internal/filtering` - shared filter implementations used by multiple plugins +- `internal/laboratory` - internal test helpers (not for external use) + +## Test Helpers + +- **`test/hanno`** (`github.com/snivilised/agenor/test/hanno`) - utilities for building + virtual file system trees; see `GO-USER-CONFIG.md` for `Nuxx` usage +- **`test/data/musico-index.xml`** - standard XML fixture representing a sample music + directory tree, used by `Nuxx` to populate an in-memory file system +- **`internal/laboratory`** - internal-only test utilities; do not use from outside the module + +## i18n + +- Translation structs are defined in `github.com/snivilised/agenor/locale` +- Follow the i18n conventions in `GO-USER-CONFIG.md` + +## File References + +@~/.claude/GO-USER-CONFIG.md diff --git a/Taskfile.yml b/Taskfile.yml index 79125d3..1b4b91c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -251,6 +251,15 @@ tasks: cmds: - govulncheck ./... + # === inspect GOMODCACHE =================================== + + # run this to install inspec then invoke directly, eg: + # inspect github.com/spf13/cobra + # inspect --interfaces github.com/spf13/cobra + install-inspect: + cmds: + - go install ./tools/inspect + # === i18n ================================================= clear: diff --git a/cmd/command/boostrap.go b/cmd/command/boostrap.go deleted file mode 100644 index f8b6b75..0000000 --- a/cmd/command/boostrap.go +++ /dev/null @@ -1,191 +0,0 @@ -package command - -import ( - "fmt" - "os" - - "github.com/cubiest/jibberjabber" - "github.com/snivilised/agenor/internal/third/lo" - "github.com/snivilised/agenor/locale" - "github.com/snivilised/li18ngo" - "github.com/snivilised/mamba/assist" - "github.com/snivilised/mamba/assist/cfg" - si18n "github.com/snivilised/mamba/locale" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "golang.org/x/text/language" -) - -// LocaleDetector abstracts the detection of the user's preferred -// language as a BCP 47 language tag. -type LocaleDetector interface { - Scan() language.Tag -} - -// Jabber is a LocaleDetector implemented using jibberjabber. -type Jabber struct { -} - -// Scan returns the detected language tag. -func (j *Jabber) Scan() language.Tag { - lang, _ := jibberjabber.DetectIETF() - return language.MustParse(lang) -} - -// ConfigInfo describes the configuration file that should be loaded, -// including its name, type, path and the viper instance to use. -type ConfigInfo struct { - // Name of the configuration name. - Name string - // ConfigType specifies the configuration type. - ConfigType string - // ConfigPath is the path to the configuration. - ConfigPath string - // Viper instance to load the configuration. - Viper cfg.ViperConfig -} - -// ConfigureOptions groups configuration options that influence how -// Bootstrap initialises localisation and configuration. -type ConfigureOptions struct { - // Detector for locale identification. - Detector LocaleDetector - // Config describing the configuration. - Config ConfigInfo -} - -// ConfigureOptionFn is a functional option used to modify -// ConfigureOptions before Bootstrap performs its setup. -type ConfigureOptionFn func(*ConfigureOptions) - -// Bootstrap represents construct that performs start up of the cli -// without resorting to the use of Go's init() mechanism and minimal -// use of package global variables. -type Bootstrap struct { - container *assist.CobraContainer - options ConfigureOptions -} - -func (b *Bootstrap) prepare() { - home, err := os.UserHomeDir() - cobra.CheckErr(err) - - b.options = ConfigureOptions{ - Detector: &Jabber{}, - Config: ConfigInfo{ - Name: ApplicationName, - ConfigType: "yaml", - ConfigPath: home, - Viper: &cfg.GlobalViperConfig{}, - }, - } -} - -// Root builds the command tree and returns the root command, ready -// to be executed. -func (b *Bootstrap) Root(options ...ConfigureOptionFn) *cobra.Command { - b.prepare() - - for _, fo := range options { - fo(&b.options) - } - - b.configure() - - // all these string literals here should be made translate-able - // - - b.container = assist.NewCobraContainer( - &cobra.Command{ - Use: ApplicationName, - Short: li18ngo.Text(locale.RootCmdShortDescTemplData{}), - Long: li18ngo.Text(locale.RootCmdLongDescTemplData{}), - Version: fmt.Sprintf("'%v'", Version), - Run: func(_ *cobra.Command, _ []string) { - fmt.Println("=== jay ===") - }, - }, - ) - - b.buildRootCommand(b.container) - - return b.container.Root() -} - -func (b *Bootstrap) configure() { - vc := b.options.Config.Viper - ci := b.options.Config - - vc.SetConfigName(ci.Name) - vc.SetConfigType(ci.ConfigType) - vc.AddConfigPath(ci.ConfigPath) - vc.AutomaticEnv() - - err := vc.ReadInConfig() - - handleLangSetting() - - if err != nil { - msg := li18ngo.Text(locale.UsingConfigFileTemplData{ - ConfigFileName: viper.ConfigFileUsed(), - }) - fmt.Fprintln(os.Stderr, msg) - } -} - -func handleLangSetting() { - tag := lo.TernaryF(viper.InConfig("lang"), - func() language.Tag { - lang := viper.GetString("lang") - parsedTag, err := language.Parse(lang) - - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - return parsedTag - }, - func() language.Tag { - return li18ngo.DefaultLanguage - }, - ) - - err := li18ngo.Use(func(uo *li18ngo.UseOptions) { - uo.Tag = tag - uo.From = li18ngo.LoadFrom{ - Sources: li18ngo.TranslationFiles{ - SourceID: li18ngo.TranslationSource{Name: ApplicationName}, - - // By adding in the source for mamba, we relieve the client from having - // to do this. After-all, it should be taken as read that since any - // instantiation of jay (ie a project using this template) is by - // necessity dependent on mamba, it's source should be loaded so that a - // localizer can be created for it. - // - // The client app has to make sure that when their app is deployed, - // the translations file(s) for mamba are named as 'mamba', as you - // can see below, that is the name assigned to the app name of the - // source. - // - si18n.MambaSourceID: li18ngo.TranslationSource{Name: "mamba"}, - }, - } - }) - - if err != nil { - fmt.Println(err) - os.Exit(1) - } -} - -func (b *Bootstrap) buildRootCommand(container *assist.CobraContainer) { - // Here you will define your flags and configuration settings. - // Cobra supports persistent flags, which, if defined here, - // will be global for your application. - // - root := container.Root() - paramSet := assist.NewParamSet[RootParameterSet](root) - - container.MustRegisterParamSet(RootPsName, paramSet) -} diff --git a/cmd/command/bootstrap.go b/cmd/command/bootstrap.go new file mode 100644 index 0000000..ad79b0d --- /dev/null +++ b/cmd/command/bootstrap.go @@ -0,0 +1,280 @@ +package command + +import ( + "fmt" + "os" + + "github.com/cubiest/jibberjabber" + "github.com/snivilised/agenor/internal/third/lo" + "github.com/snivilised/agenor/locale" + "github.com/snivilised/li18ngo" + "github.com/snivilised/mamba/assist" + macfg "github.com/snivilised/mamba/assist/cfg" + si18n "github.com/snivilised/mamba/locale" + "github.com/snivilised/mamba/store" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/text/language" + + "github.com/snivilised/agenor/cmd/internal/cfg" + "github.com/snivilised/agenor/cmd/ui" +) + +// --------------------------------------------------------------------------- +// Locale detection +// --------------------------------------------------------------------------- + +// LocaleDetector abstracts the detection of the user's preferred +// language as a BCP 47 language tag. +type LocaleDetector interface { + Scan() language.Tag +} + +// Jabber is a LocaleDetector implemented using jibberjabber. +type Jabber struct{} + +// Scan returns the detected language tag. +func (j *Jabber) Scan() language.Tag { + lang, _ := jibberjabber.DetectIETF() + return language.MustParse(lang) +} + +// --------------------------------------------------------------------------- +// ConfigureOptions +// --------------------------------------------------------------------------- + +// ConfigInfo describes the configuration file that should be loaded, +// including its name, type, path and the viper instance to use. +type ConfigInfo struct { + Name string + ConfigType string + ConfigPath string + Viper macfg.ViperConfig +} + +// ConfigureOptions groups configuration options that influence how +// Bootstrap initialises localisation and configuration. +type ConfigureOptions struct { + Detector LocaleDetector + Config ConfigInfo +} + +// ConfigureOptionFn is a functional option used to modify +// ConfigureOptions before Bootstrap performs its setup. +type ConfigureOptionFn func(*ConfigureOptions) + +// --------------------------------------------------------------------------- +// Bootstrap +// --------------------------------------------------------------------------- + +// Bootstrap constructs the full cobra command tree and owns all +// mamba param-set registrations. It is the single entry point for +// application startup wiring. +type Bootstrap struct { + container *assist.CobraContainer + options ConfigureOptions + + // Cfg is populated after configure() reads viper. + Cfg *cfg.Config + + // UI is constructed from the --tui flag value in PersistentPreRunE + // and injected into every command's Inputs struct. + UI ui.Manager + + // root param-set - stashed so PersistentPreRunE and RunE can read it. + rootPs *assist.ParamSet[RootParameterSet] + + // shared family param-sets (persistent, inherited by all sub-commands) + previewFam *assist.ParamSet[store.PreviewParameterSet] + cascadeFam *assist.ParamSet[store.CascadeParameterSet] + samplingFam *assist.ParamSet[store.SamplingParameterSet] + + // walk command + walkPs *assist.ParamSet[WalkParameterSet] + walkPolyFam *assist.ParamSet[store.PolyFilterParameterSet] + + // run command + runPs *assist.ParamSet[RunParameterSet] + runPolyFam *assist.ParamSet[store.PolyFilterParameterSet] + workerPoolFam *assist.ParamSet[store.WorkerPoolParameterSet] +} + +// --------------------------------------------------------------------------- +// Root +// --------------------------------------------------------------------------- + +func (b *Bootstrap) prepare() { + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + b.options = ConfigureOptions{ + Detector: &Jabber{}, + Config: ConfigInfo{ + Name: ApplicationName, + ConfigType: "yaml", + ConfigPath: home, + Viper: &macfg.GlobalViperConfig{}, + }, + } +} + +// Root builds the command tree and returns the root command, ready +// to be executed. +func (b *Bootstrap) Root(options ...ConfigureOptionFn) *cobra.Command { + b.prepare() + + for _, fo := range options { + fo(&b.options) + } + + b.configure() + + b.container = assist.NewCobraContainer( + &cobra.Command{ + Use: ApplicationName, + Short: li18ngo.Text(locale.RootCmdShortDescTemplData{}), + Long: li18ngo.Text(locale.RootCmdLongDescTemplData{}), + Version: fmt.Sprintf("'%v'", Version), + + // PersistentPreRunE runs after flag parsing but before any + // RunE handler. It resolves --tui into a UI manager so all + // sub-commands receive a fully constructed b.UI. + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + mgr, err := ui.New(b.rootPs.Native.TUI) + if err != nil { + return err + } + b.UI = mgr + return nil + }, + + Run: func(_ *cobra.Command, _ []string) { + fmt.Println("=== jay ===") + }, + }, + ) + + b.buildRootCommand(b.container) + b.buildWalkCommand(b.container) + b.buildRunCommand(b.container) + + return b.container.Root() +} + +// --------------------------------------------------------------------------- +// configure - viper + i18n (your existing logic, unchanged) +// --------------------------------------------------------------------------- + +func (b *Bootstrap) configure() { + vc := b.options.Config.Viper + ci := b.options.Config + + vc.SetConfigName(ci.Name) + vc.SetConfigType(ci.ConfigType) + vc.AddConfigPath(ci.ConfigPath) + vc.AutomaticEnv() + + err := vc.ReadInConfig() + + handleLangSetting() + + if err != nil { + msg := li18ngo.Text(locale.UsingConfigFileTemplData{ + ConfigFileName: viper.ConfigFileUsed(), + }) + fmt.Fprintln(os.Stderr, msg) + } + + // Load jay's typed config on top of viper now that the file is read. + // GlobalViperConfig delegates to viper's global instance, so we use + // viper.GetViper() to obtain the underlying *viper.Viper directly, + // which allows cfg.Load to skip ReadInConfig on an already-read instance. + b.Cfg, err = cfg.Load(cfg.LoadOptions{ + ViperInstance: viper.GetViper(), + }) + if err != nil { + fmt.Fprintf(os.Stderr, "jay: config error: %v\n", err) + os.Exit(1) + } +} + +func handleLangSetting() { + tag := lo.TernaryF(viper.InConfig("lang"), + func() language.Tag { + lang := viper.GetString("lang") + parsedTag, err := language.Parse(lang) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + return parsedTag + }, + func() language.Tag { + return li18ngo.DefaultLanguage + }, + ) + + err := li18ngo.Use(func(uo *li18ngo.UseOptions) { + uo.Tag = tag + uo.From = li18ngo.LoadFrom{ + Sources: li18ngo.TranslationFiles{ + SourceID: li18ngo.TranslationSource{Name: ApplicationName}, + si18n.MambaSourceID: li18ngo.TranslationSource{Name: "mamba"}, + }, + } + }) + + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +// --------------------------------------------------------------------------- +// buildRootCommand - root param-set and shared persistent families +// --------------------------------------------------------------------------- + +func (b *Bootstrap) buildRootCommand(container *assist.CobraContainer) { + root := container.Root() + + // root param-set: --tui (and any future root-level flags) + b.rootPs = assist.NewParamSet[RootParameterSet](root) + + // --tui(-t) : selects the display renderer; defaults to "linear". + // Validated eagerly so a bad value is rejected before traversal starts. + b.rootPs.BindString( + assist.NewFlagInfoOnFlagSet( + `tui display mode: "linear" (default) or a named Charm-based renderer`, + "t", + ui.ModeDefault, + root.PersistentFlags(), + ), + &b.rootPs.Native.TUI, + ) + + container.MustRegisterParamSet(RootPsName, b.rootPs) + + // family: preview [--dry-run(D)] + b.previewFam = assist.NewParamSet[store.PreviewParameterSet](root) + b.previewFam.Native.BindAll(b.previewFam, root.PersistentFlags()) + container.MustRegisterParamSet(PreviewFamName, b.previewFam) + + // family: cascade [--depth, --no-recurse] + b.cascadeFam = assist.NewParamSet[store.CascadeParameterSet](root) + b.cascadeFam.Native.BindAll(b.cascadeFam, root.PersistentFlags()) + container.MustRegisterParamSet(CascadeFamName, b.cascadeFam) + + // family: sampling [--sample, --no-files, --no-folders, --last] + b.samplingFam = assist.NewParamSet[store.SamplingParameterSet](root) + b.samplingFam.Native.BindAll(b.samplingFam, root.PersistentFlags()) + container.MustRegisterParamSet(SamplingFamName, b.samplingFam) +} + +// sharedFamilies is a convenience accessor for RunE closures. +func (b *Bootstrap) sharedFamilies() SharedFamilies { + return SharedFamilies{ + Preview: b.previewFam, + Cascade: b.cascadeFam, + Sampling: b.samplingFam, + } +} diff --git a/cmd/command/const.go b/cmd/command/const.go new file mode 100644 index 0000000..b9ab04d --- /dev/null +++ b/cmd/command/const.go @@ -0,0 +1,45 @@ +package command + +// --------------------------------------------------------------------------- +// Application identity +// --------------------------------------------------------------------------- + +const ( + AppEmoji = "πŸ’" + ApplicationName = "jay" + SourceID = "github.com/snivilised/agenor" +) + +// --------------------------------------------------------------------------- +// Param-set registration names +// --------------------------------------------------------------------------- + +const ( + // root + RootPsName = "root-ps" + + // shared families (registered on root, inherited by sub-commands) + PreviewFamName = "preview-fam" + CascadeFamName = "cascade-fam" + InteractionFamName = "interaction-fam" + SamplingFamName = "sampling-fam" + + // run-only family + WorkerPoolFamName = "worker-pool-fam" + + // filter family (registered per-command, not inherited) + PolyFamName = "poly-fam" + + // jay-specific param sets + WalkPsName = "walk-ps" + RunPsName = "run-ps" +) + +// --------------------------------------------------------------------------- +// Resume strategy values +// --------------------------------------------------------------------------- + +const ( + ResumeStrategySpawn = "spawn" + ResumeStrategyFastward = "fastward" +) diff --git a/cmd/command/inputs.go b/cmd/command/inputs.go new file mode 100644 index 0000000..cb6b29e --- /dev/null +++ b/cmd/command/inputs.go @@ -0,0 +1,96 @@ +package command + +import ( + "fmt" + + "github.com/snivilised/agenor/enums" + "github.com/snivilised/mamba/assist" + "github.com/snivilised/mamba/store" + + "github.com/snivilised/agenor/cmd/ui" +) + +// --------------------------------------------------------------------------- +// Subscription resolution +// --------------------------------------------------------------------------- + +// ResolveSubscription maps the user-supplied --subscribe string to the +// corresponding agenor enums.Subscription value. Returns an error if the +// value is not one of the three legal strings. +func ResolveSubscription(flag string) (enums.Subscription, error) { + switch flag { + case SubscribeFlagFiles, "": + return enums.SubscribeFiles, nil + case SubscribeFlagDirs: + return enums.SubscribeDirectories, nil + case SubscribeFlagAll: + return enums.SubscribeUniversal, nil + default: + return 0, fmt.Errorf( + "invalid --subscribe value %q: must be %q, %q or %q", + flag, SubscribeFlagFiles, SubscribeFlagDirs, SubscribeFlagAll, + ) + } +} + +// --------------------------------------------------------------------------- +// Shared families bundle - embedded in both WalkInputs and RunInputs +// --------------------------------------------------------------------------- + +// SharedFamilies groups the mamba param-set pointers registered on the +// root command as persistent flags, inherited by all sub-commands. +// Note: CliInteractionParameterSet is NOT included here because --tui is +// a string flag on RootParameterSet, allowing the user to select a named +// display mode rather than a simple on/off boolean. +type SharedFamilies struct { + Preview *assist.ParamSet[store.PreviewParameterSet] + Cascade *assist.ParamSet[store.CascadeParameterSet] + Sampling *assist.ParamSet[store.SamplingParameterSet] +} + +// --------------------------------------------------------------------------- +// WalkInputs - consumed by the walk RunE handler +// --------------------------------------------------------------------------- + +// WalkInputs collects all flag values needed to build a Walk invocation. +type WalkInputs struct { + SharedFamilies + + // Tree is the positional directory argument. + Tree string + + // UI is the display manager selected by --tui. All output to the + // terminal is routed through this interface. + UI ui.Manager + + // Jay-specific flags + ParamSet *assist.ParamSet[WalkParameterSet] + + // Per-command filter family (not inherited from root) + PolyFam *assist.ParamSet[store.PolyFilterParameterSet] +} + +// --------------------------------------------------------------------------- +// RunInputs - consumed by the run RunE handler +// --------------------------------------------------------------------------- + +// RunInputs collects all flag values needed to build a Run invocation. +type RunInputs struct { + SharedFamilies + + // Tree is the positional directory argument. + Tree string + + // UI is the display manager selected by --tui. All output to the + // terminal is routed through this interface. + UI ui.Manager + + // Jay-specific flags + ParamSet *assist.ParamSet[RunParameterSet] + + // Per-command filter family (not inherited from root) + PolyFam *assist.ParamSet[store.PolyFilterParameterSet] + + // Run-only family + WorkerPool *assist.ParamSet[store.WorkerPoolParameterSet] +} diff --git a/cmd/command/param-sets.go b/cmd/command/param-sets.go new file mode 100644 index 0000000..9896c0f --- /dev/null +++ b/cmd/command/param-sets.go @@ -0,0 +1,95 @@ +package command + +import "github.com/snivilised/mamba/store" + +// --------------------------------------------------------------------------- +// Subscription flag values - what the user types on the command line +// --------------------------------------------------------------------------- + +const ( + // SubscribeFlagFiles subscribes to file nodes only. + SubscribeFlagFiles = "files" + + // SubscribeFlagDirs subscribes to directory nodes only. + SubscribeFlagDirs = "dirs" + + // SubscribeFlagAll subscribes to all nodes (files and directories). + SubscribeFlagAll = "all" + + // SubscribeFlagDefault is the default subscription if not specified. + SubscribeFlagDefault = SubscribeFlagAll +) + +// --------------------------------------------------------------------------- +// Root parameter set +// --------------------------------------------------------------------------- + +// RootParameterSet holds flags defined on the root command that are +// inherited by all sub-commands. +type RootParameterSet struct { + store.ParameterSetWithOverrides + + // Language sets the IETF BCP 47 language tag for i18n output. + Language string + + // TUI selects the display mode. Corresponds to --tui . + // Defaults to "linear" (plain fmt.Println output). + // Future values: "flashy", "retro", etc (Charm-based implementations). + TUI string +} + +// --------------------------------------------------------------------------- +// Walk parameter set +// --------------------------------------------------------------------------- + +// WalkParameterSet holds the jay-specific flags for the walk command. +// Shared families (preview, cascade, sampling, poly-filter) are registered +// separately and are not embedded here. +type WalkParameterSet struct { + store.ParameterSetWithOverrides + + // Subscribe controls which node types are visited. + // Valid values: "files", "dirs", "all". Maps to --subscribe(-s). + Subscribe string + + // Action names the config-defined action to invoke for each node. + // Maps to --action(-a). + Action string + + // Pipeline names the config-defined pipeline to execute. + // Maps to --pipeline(-p). + Pipeline string + + // Resume defines the resume strategy to use when re-entering a + // previously interrupted traversal. Maps to --resume(-r). + // Valid values: "spawn", "fastward". Empty means prime (no resume). + Resume string +} + +// --------------------------------------------------------------------------- +// Run parameter set +// --------------------------------------------------------------------------- + +// RunParameterSet holds the jay-specific flags for the run command. +// It mirrors WalkParameterSet; run additionally gets WorkerPoolParameterSet +// via a separate family registration. +type RunParameterSet struct { + store.ParameterSetWithOverrides + + // Subscribe controls which node types are visited. + // Valid values: "files", "dirs", "all". Maps to --subscribe(-s). + Subscribe string + + // Action names the config-defined action to invoke for each node. + // Maps to --action(-a). + Action string + + // Pipeline names the config-defined pipeline to execute. + // Maps to --pipeline(-p). + Pipeline string + + // Resume defines the resume strategy to use when re-entering a + // previously interrupted traversal. Maps to --resume(-r). + // Valid values: "spawn", "fastward". Empty means prime (no resume). + Resume string +} diff --git a/cmd/command/root-cmd.go b/cmd/command/root-cmd.go index ad4089b..9903cc2 100644 --- a/cmd/command/root-cmd.go +++ b/cmd/command/root-cmd.go @@ -1,20 +1,6 @@ // Package command provides CLI commands for the jay application. package command -const ( - AppEmoji = "πŸ’" - ApplicationName = "jay" - RootPsName = "root-ps" - SourceID = "github.com/snivilised/agenor" -) - func Execute() error { return (&Bootstrap{}).Root().Execute() } - -// RootParameterSet defines the configuration options exposed on the -// root command's parameter set (CLIENT-TODO: refine these properties). -type RootParameterSet struct { - // Language defines the IETF BCP 47 language tag. - Language string -} diff --git a/cmd/command/run-cmd.go b/cmd/command/run-cmd.go new file mode 100644 index 0000000..277fc7d --- /dev/null +++ b/cmd/command/run-cmd.go @@ -0,0 +1,178 @@ +package command + +import ( + "context" + "fmt" + "sync" + + "github.com/snivilised/mamba/assist" + "github.com/snivilised/mamba/store" + "github.com/spf13/cobra" + + age "github.com/snivilised/agenor" + "github.com/snivilised/agenor/enums" + "github.com/snivilised/agenor/pref" +) + +const ( + defaultRunSubscribe = SubscribeFlagDefault + defaultRunAction = "" + defaultRunPipeline = "" + defaultRunResume = "" +) + +func (b *Bootstrap) buildRunCommand(container *assist.CobraContainer) { + runCmd := &cobra.Command{ + Use: "run ", + Short: "run a concurrent directory tree traversal using a worker pool", + Long: `Run traverses a directory tree concurrently via an agenor worker pool. +Flags are identical to walk, plus --cpu and --now for worker pool control. +Use --action or --pipeline to name a config-defined operation.`, + Args: cobra.ExactArgs(1), + RunE: b.runRun, + } + + runPs := assist.NewParamSet[RunParameterSet](runCmd) + + // --subscribe(-s): which node types to visit + // + runPs.BindString( + assist.NewFlagInfo( + "subscribe node types to visit: \"files\", \"dirs\" or \"all\" (default)", + "s", + defaultRunSubscribe, + ), + &runPs.Native.Subscribe, + ) + + // --action(-a) + // + runPs.BindString( + assist.NewFlagInfo( + "action name of the config-defined action to invoke for each matched node", + "a", + defaultRunAction, + ), + &runPs.Native.Action, + ) + + // --pipeline(-p) + // + runPs.BindString( + assist.NewFlagInfo( + "pipeline name of the config-defined pipeline to execute", + "p", + defaultRunPipeline, + ), + &runPs.Native.Pipeline, + ) + + // --resume(-r) + // + runPs.BindString( + assist.NewFlagInfo( + `resume strategy for an interrupted traversal: "spawn" or "fastward"`, + "r", + defaultRunResume, + ), + &runPs.Native.Resume, + ) + + // family: worker-pool [--cpu(C), --now] + // run-only, registered on the run command's local flags. + // + workerPoolFam := assist.NewParamSet[store.WorkerPoolParameterSet](runCmd) + workerPoolFam.Native.BindAll(workerPoolFam, runCmd.Flags()) + container.MustRegisterParamSet(WorkerPoolFamName, workerPoolFam) + + // poly-filter family - local to run, not inherited. + // + polyFam := assist.NewParamSet[store.PolyFilterParameterSet](runCmd) + polyFam.Native.BindAll(polyFam) + + container.MustRegisterRootedCommand(runCmd) + container.MustRegisterParamSet(RunPsName, runPs) + container.MustRegisterParamSet(PolyFamName+"-run", polyFam) + + b.runPs = runPs + b.runPolyFam = polyFam + b.workerPoolFam = workerPoolFam +} + +// runRun is the RunE handler for the run command. +func (b *Bootstrap) runRun(cmd *cobra.Command, args []string) error { + inputs := &RunInputs{ + Tree: args[0], + UI: b.UI, + ParamSet: b.runPs, + PolyFam: b.runPolyFam, + SharedFamilies: b.sharedFamilies(), + WorkerPool: b.workerPoolFam, + } + + return executeRun(cmd.Context(), inputs) +} + +// executeRun builds and runs an agenor concurrent traversal using age.Hare, +// which supports both prime and resume modes with a worker pool. +func executeRun(ctx context.Context, inputs *RunInputs) error { + subscription, err := ResolveSubscription(inputs.ParamSet.Native.Subscribe) + if err != nil { + return err + } + + isPrime := inputs.ParamSet.Native.Resume == "" + opts := buildOptions(inputs.SharedFamilies) + + if inputs.WorkerPool.Native.CPU { + opts = append(opts, age.WithCPU()) + } else if n := inputs.WorkerPool.Native.NoWorkers; n > 0 { + opts = append(opts, age.WithNoW(uint(n))) + } + + var facade pref.Facade + + if isPrime { + facade = &pref.Using{ + Subscription: subscription, + Head: pref.Head{ + Handler: func(servant age.Servant) error { + return inputs.UI.OnNode(servant.Node()) + }, + }, + Tree: inputs.Tree, + } + } else { + strategy, e := resolveResumeStrategy(inputs.ParamSet.Native.Resume) + if e != nil { + return e + } + + facade = &pref.Relic{ + Head: pref.Head{ + Handler: func(servant age.Servant) error { + return inputs.UI.OnNode(servant.Node()) + }, + }, + Strategy: strategy, + } + } + + wg := sync.WaitGroup{} + + result, err := age.Hare(isPrime, &wg)(facade, opts...).Navigate(ctx) + wg.Wait() + + if err != nil { + inputs.UI.Error(fmt.Sprintf("run failed: %v", err)) + return err + } + + inputs.UI.Info(fmt.Sprintf( + "run complete: %d files, %d dirs visited", + result.Metrics().Count(enums.MetricNoFilesInvoked), + result.Metrics().Count(enums.MetricNoDirectoriesInvoked), + )) + + return nil +} diff --git a/cmd/command/walk-cmd.go b/cmd/command/walk-cmd.go new file mode 100644 index 0000000..ad6fbc5 --- /dev/null +++ b/cmd/command/walk-cmd.go @@ -0,0 +1,205 @@ +package command + +import ( + "context" + "fmt" + + "github.com/snivilised/mamba/assist" + "github.com/snivilised/mamba/store" + "github.com/spf13/cobra" + + age "github.com/snivilised/agenor" + "github.com/snivilised/agenor/enums" + "github.com/snivilised/agenor/pref" +) + +const ( + defaultWalkSubscribe = SubscribeFlagDefault + defaultWalkAction = "" + defaultWalkPipeline = "" + defaultWalkResume = "" +) + +func (b *Bootstrap) buildWalkCommand(container *assist.CobraContainer) { + walkCmd := &cobra.Command{ + Use: "walk ", + Short: "walk a directory tree, invoking an action or pipeline per node", + Long: `Walk traverses a directory tree synchronously using the agenor library. +For each node that matches the active filter, the handler prints the node path. +Use --action or --pipeline to name a config-defined operation. +Use --resume to re-enter a previously interrupted traversal.`, + Args: cobra.ExactArgs(1), + RunE: b.runWalk, + } + + walkPs := assist.NewParamSet[WalkParameterSet](walkCmd) + + // --subscribe(-s): which node types to visit + // + walkPs.BindString( + assist.NewFlagInfo( + "subscribe node types to visit: \"files\", \"dirs\" or \"all\" (default)", + "s", + defaultWalkSubscribe, + ), + &walkPs.Native.Subscribe, + ) + + // --action(-a): name of a config-defined action to run per node + // + walkPs.BindString( + assist.NewFlagInfo( + "action name of the config-defined action to invoke for each matched node", + "a", + defaultWalkAction, + ), + &walkPs.Native.Action, + ) + + // --pipeline(-p): name of a config-defined pipeline to execute + // + walkPs.BindString( + assist.NewFlagInfo( + "pipeline name of the config-defined pipeline to execute", + "p", + defaultWalkPipeline, + ), + &walkPs.Native.Pipeline, + ) + + // --resume(-r): resume strategy for interrupted traversals + // + walkPs.BindString( + assist.NewFlagInfo( + `resume strategy for an interrupted traversal: "spawn" or "fastward"`, + "r", + defaultWalkResume, + ), + &walkPs.Native.Resume, + ) + + // poly-filter family [--files-glob(b), --files-rx, --folders-glob, --folders-rx] + // Not passed to PersistentFlags so it is local to walk only. + // + polyFam := assist.NewParamSet[store.PolyFilterParameterSet](walkCmd) + polyFam.Native.BindAll(polyFam) + + container.MustRegisterRootedCommand(walkCmd) + container.MustRegisterParamSet(WalkPsName, walkPs) + container.MustRegisterParamSet(PolyFamName+"-walk", polyFam) + + b.walkPs = walkPs + b.walkPolyFam = polyFam +} + +// runWalk is the RunE handler for the walk command. +func (b *Bootstrap) runWalk(cmd *cobra.Command, args []string) error { + inputs := &WalkInputs{ + Tree: args[0], + UI: b.UI, + ParamSet: b.walkPs, + PolyFam: b.walkPolyFam, + SharedFamilies: b.sharedFamilies(), + } + + return executeWalk(cmd.Context(), inputs) +} + +// executeWalk builds and runs an agenor walk traversal using age.Tortoise, +// which supports both prime and resume modes for synchronous walking. +func executeWalk(ctx context.Context, inputs *WalkInputs) error { + subscription, err := ResolveSubscription(inputs.ParamSet.Native.Subscribe) + if err != nil { + return err + } + + isPrime := inputs.ParamSet.Native.Resume == "" + opts := buildOptions(inputs.SharedFamilies) + + var facade pref.Facade + + if isPrime { + facade = &pref.Using{ + Subscription: subscription, + Head: pref.Head{ + Handler: func(servant age.Servant) error { + return inputs.UI.OnNode(servant.Node()) + }, + }, + Tree: inputs.Tree, + } + } else { + strategy, e := resolveResumeStrategy(inputs.ParamSet.Native.Resume) + if e != nil { + return e + } + + facade = &pref.Relic{ + Head: pref.Head{ + Handler: func(servant age.Servant) error { + return inputs.UI.OnNode(servant.Node()) + }, + }, + Strategy: strategy, + } + } + + result, err := age.Tortoise(isPrime)(facade, opts...).Navigate(ctx) + if err != nil { + inputs.UI.Error(fmt.Sprintf("walk failed: %v", err)) + return err + } + + inputs.UI.Info(fmt.Sprintf( + "walk complete: %d files, %d dirs visited", + result.Metrics().Count(enums.MetricNoFilesInvoked), + result.Metrics().Count(enums.MetricNoDirectoriesInvoked), + )) + + return nil +} + +// buildOptions translates shared flag values into agenor option functions. +// Shared between walk and run to avoid duplication. +func buildOptions(families SharedFamilies) []pref.Option { + var opts []pref.Option + + if families.Cascade.Native.NoRecurse { + opts = append(opts, age.WithNoRecurse()) + } + + if d := families.Cascade.Native.Depth; d > 0 { + opts = append(opts, age.WithDepth(d)) + } + + // TODO: implement DryRun on age + // if families.Preview.Native.DryRun { + // opts = append(opts, age.WithDryRun()) + // } + + if families.Sampling.Native.IsSampling { + opts = append(opts, age.WithSamplingOptions(&pref.SamplingOptions{ + NoOf: pref.EntryQuantities{ + Files: families.Sampling.Native.NoFiles, + Directories: families.Sampling.Native.NoFolders, + }, + })) + } + + return opts +} + +// resolveResumeStrategy maps the --resume string to the agenor constant. +func resolveResumeStrategy(resume string) (age.ResumeStrategy, error) { + switch resume { + case ResumeStrategySpawn: + return age.ResumeStrategySpawn, nil + case ResumeStrategyFastward: + return age.ResumeStrategyFastward, nil + default: + return 0, fmt.Errorf( + "invalid --resume value %q: must be %q or %q", + resume, ResumeStrategySpawn, ResumeStrategyFastward, + ) + } +} diff --git a/cmd/internal/cfg/default-config.yml b/cmd/internal/cfg/example-config.yml similarity index 100% rename from cmd/internal/cfg/default-config.yml rename to cmd/internal/cfg/example-config.yml diff --git a/cmd/internal/cfg/loader.go b/cmd/internal/cfg/loader.go index e2ceb7a..5eceba1 100644 --- a/cmd/internal/cfg/loader.go +++ b/cmd/internal/cfg/loader.go @@ -231,15 +231,15 @@ func decode(v *viper.Viper) (*Config, error) { } // decodeSection extracts key from Viper and mapstructure-decodes it into dest. -func decodeSection(v *viper.Viper, key string, cfg *mapstructure.DecoderConfig, dest any) error { +func decodeSection(v *viper.Viper, key string, decoderCfg *mapstructure.DecoderConfig, dest any) error { raw := v.Get(key) if raw == nil { // Section absent - leave dest at zero value. return nil } - cfg.Result = dest - decoder, err := mapstructure.NewDecoder(cfg) + decoderCfg.Result = dest + decoder, err := mapstructure.NewDecoder(decoderCfg) if err != nil { return fmt.Errorf("creating decoder for %q: %w", key, err) } @@ -250,7 +250,7 @@ func decodeSection(v *viper.Viper, key string, cfg *mapstructure.DecoderConfig, } // decodeFlagsSection has custom logic because flags.short is nested deeply. -func decodeFlagsSection(v *viper.Viper, dcfg *mapstructure.DecoderConfig, dest *FlagsConfig) error { +func decodeFlagsSection(v *viper.Viper, decoderCfg *mapstructure.DecoderConfig, destinationCfg *FlagsConfig) error { raw := v.Get("flags") if raw == nil { return nil @@ -267,13 +267,13 @@ func decodeFlagsSection(v *viper.Viper, dcfg *mapstructure.DecoderConfig, dest * if overridesRaw, ok := shortMap["overrides"]; ok { if overridesMap, ok := toStringAnyMap(overridesRaw); ok { if cmdsRaw, ok := overridesMap["cmds"]; ok { - dest.Short = make(FlagShortOverride) + destinationCfg.Short = make(FlagShortOverride) if cmdsMap, ok := toStringAnyMap(cmdsRaw); ok { for cmd, flagsRaw := range cmdsMap { if flagsMap, ok := toStringAnyMap(flagsRaw); ok { - dest.Short[cmd] = make(map[string]string) + destinationCfg.Short[cmd] = make(map[string]string) for flag, val := range flagsMap { - dest.Short[cmd][flag] = fmt.Sprintf("%v", val) + destinationCfg.Short[cmd][flag] = fmt.Sprintf("%v", val) } } } @@ -288,11 +288,11 @@ func decodeFlagsSection(v *viper.Viper, dcfg *mapstructure.DecoderConfig, dest * if invokeRaw, ok := rawMap["invoke"]; ok { if invokeMap, ok := toStringAnyMap(invokeRaw); ok { if cmdsRaw, ok := invokeMap["cmds"]; ok { - dest.Invoke = make(FlagInvokeDefaults) + destinationCfg.Invoke = make(FlagInvokeDefaults) if cmdsMap, ok := toStringAnyMap(cmdsRaw); ok { for cmd, flagsRaw := range cmdsMap { if flagsMap, ok := toStringAnyMap(flagsRaw); ok { - dest.Invoke[cmd] = flagsMap + destinationCfg.Invoke[cmd] = flagsMap } } } @@ -302,17 +302,17 @@ func decodeFlagsSection(v *viper.Viper, dcfg *mapstructure.DecoderConfig, dest * // component ─► FlagComponentDefaults = map[component]map[flag]any if compRaw, ok := rawMap["component"]; ok { - dest.Component = make(FlagComponentDefaults) + destinationCfg.Component = make(FlagComponentDefaults) if compMap, ok := toStringAnyMap(compRaw); ok { for comp, flagsRaw := range compMap { if flagsMap, ok := toStringAnyMap(flagsRaw); ok { - dest.Component[comp] = flagsMap + destinationCfg.Component[comp] = flagsMap } } } } - _ = dcfg // reserved for future hook composition + _ = decoderCfg // reserved for future hook composition return nil } diff --git a/cmd/internal/cfg/types.go b/cmd/internal/cfg/types.go index d1da1da..31dd866 100644 --- a/cmd/internal/cfg/types.go +++ b/cmd/internal/cfg/types.go @@ -65,7 +65,7 @@ type RawPipeline struct { Steps []string `mapstructure:"steps"` } -// FlagShortOverride captures per-command short-flag remappings. +// FlagShortOverride captures per-command short-flag re-mappings. // // flags.short.overrides.cmds.. = type FlagShortOverride map[string]map[string]string diff --git a/cmd/jay/CLAUDE.md b/cmd/jay/CLAUDE.md new file mode 100644 index 0000000..d32a694 --- /dev/null +++ b/cmd/jay/CLAUDE.md @@ -0,0 +1,62 @@ +# CLAUDE.md - jay + +## Project Overview + +`jay` is a Go CLI application that acts as a companion tool to the `agenor` directory-walking library. It uses `cobra`/`mamba` for the CLI layer and `viper` for configuration management. + +- **Repo**: `github.com/snivilised/agenor` (`jay` lives at `cmd/jay` within it) +- **Module**: `github.com/snivilised/agenor` (jay is the CLI frontend, located at `cmd/jay` within the agenor module) +- **Entry point for jay**: `./cmd/jay/main.go` + +## Build & Test Commands + +- **Build**: `go build -o jay ./cmd/jay/main.go` +- **Test**: `go test ./...` +- **Dependencies**: `go mod tidy` + +## Architecture + +| Package | Responsibility | +| --- | --- | +| `cmd/internal/cfg` | Configuration loading; `ViperInstance` is the test-isolation seam for `Load` | +| `cmd/ui` | UI abstraction; `Manager` interface accepts `*core.Node`; `age.Servant` is unwrapped at the command layer before crossing into `ui` | +| `cmd/command` | cobra/mamba wiring; `Bootstrap` owns all param-set stashes | + +All flags are defined in `cmd/internal/cfg/flags.go`. + +## Viper & Configuration + +- Use `viper.GetViper()` to obtain the global viper instance for `cfg.Load` +- Use `viper.Get()` to access configuration values +- In tests, use the `viperFromYAML` helper (defined in `./cmd/internal/config/helpers_test.go`) for in-memory viper fixtures instead of reading from disk + +## agenor Integration + +`jay` uses `agenor` (`github.com/snivilised/agenor`) as its directory-walking backend. Follow these conventions: + +- Construct facades as named variables before passing to `Tortoise`/`Hare` - never inline: + +```go + using := &pref.Using{...} + relic := &pref.Relic{...} +``` + +- **Synchronous walk**: `age.Tortoise(isPrime)(facade, opts...).Navigate(ctx)` +- **Concurrent run**: `age.Hare(isPrime, &wg)(facade, opts...).Navigate(ctx)`, followed by `wg.Wait()` +- Enum values are defined in the `enums` package - use `enums.MetricNoFilesInvoked`, not `age.MetricNoFilesInvoked` + +## mamba/assist + +- Use `NewFlagInfo` for local flags; use `NewFlagInfoOnFlagSet` for persistent flags +- The first word of the `usage` string becomes the flag name +- Use `BindString`, not `BindValidatedString`, unless validation is explicitly required + +## i18n + +- Translation structs are defined in `github.com/snivilised/agenor/locale` +- Follow the i18n conventions in `GO-USER-CONFIG.md`; locale struct placement is per the package above + +## File References + +@./.claude/COMMON-COMMANDS.md +@~/.claude/GO-USER-CONFIG.md diff --git a/cmd/ui/doc.go b/cmd/ui/doc.go new file mode 100644 index 0000000..e2d9b1c --- /dev/null +++ b/cmd/ui/doc.go @@ -0,0 +1,9 @@ +// Package ui defines the user interface abstraction for jay. All output +// to the terminal is routed through a UI implementation, chosen at startup +// via the --tui flag. This makes it trivial to swap in richer Charm-based +// renderers later without touching any command or traversal logic. +// +// The only implementation shipped initially is Linear, which writes plain +// text to stdout via fmt.Println. Future implementations (e.g. a Bubble Tea +// TUI) satisfy the same Manager interface and are selected by name. +package ui diff --git a/cmd/ui/linear.go b/cmd/ui/linear.go new file mode 100644 index 0000000..77fc82b --- /dev/null +++ b/cmd/ui/linear.go @@ -0,0 +1,45 @@ +package ui + +import ( + "fmt" + "sync" + + "github.com/snivilised/agenor/core" +) + +// linear is the default UI implementation. It writes plain text to stdout +// using fmt.Println. It is safe for concurrent use - all writes are +// serialised through a mutex so interleaved output from the run command's +// worker pool is avoided. +type linear struct { + mu sync.Mutex +} + +// OnNode prints the visited node's path to stdout. +func (l *linear) OnNode(node *core.Node) error { + l.mu.Lock() + defer l.mu.Unlock() + fmt.Printf("-> %v\n", node.Path) + return nil +} + +// Info writes a plain informational line to stdout. +func (l *linear) Info(msg string) { + l.mu.Lock() + defer l.mu.Unlock() + fmt.Println("i", msg) +} + +// Warn writes a warning line to stdout. +func (l *linear) Warn(msg string) { + l.mu.Lock() + defer l.mu.Unlock() + fmt.Println("!", msg) +} + +// Error writes an error line to stdout. +func (l *linear) Error(msg string) { + l.mu.Lock() + defer l.mu.Unlock() + fmt.Println("x", msg) +} diff --git a/cmd/ui/manager.go b/cmd/ui/manager.go new file mode 100644 index 0000000..280eaaf --- /dev/null +++ b/cmd/ui/manager.go @@ -0,0 +1,96 @@ +package ui + +import ( + "fmt" + + "github.com/snivilised/agenor/core" +) + +// --------------------------------------------------------------------------- +// Named display modes - these are the legal values for --tui +// --------------------------------------------------------------------------- + +const ( + // ModeLinear is the default plain-text display. Writes one line per node + // using fmt.Println. No external dependencies. + ModeLinear = "linear" + + // ModeDefault is the display used when --tui is not specified. + ModeDefault = ModeLinear +) + +// --------------------------------------------------------------------------- +// Manager interface +// --------------------------------------------------------------------------- + +// Manager is the single interface all UI implementations satisfy. +// Command handlers and agenor node callbacks interact only with this +// interface, never with a concrete type. +type Manager interface { + // OnNode is called for every node the traversal visits. The node is + // obtained by the command layer via servant.Node() before being passed + // here. It is safe to call from multiple goroutines (implementations + // must ensure this). + OnNode(node *core.Node) error + + // Info writes a general informational message to the display. + Info(msg string) + + // Warn writes a warning message to the display. + Warn(msg string) + + // Error writes an error message to the display. + Error(msg string) +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +// ErrUnknownMode is returned by New when the requested mode is not registered. +type ErrUnknownMode struct { + Mode string +} + +func (e *ErrUnknownMode) Error() string { + return fmt.Sprintf("ui: unknown display mode %q (valid modes: %v)", e.Mode, registeredModes()) +} + +// factory maps a mode name to its constructor. +type factory func() Manager + +var registry = map[string]factory{ + ModeLinear: func() Manager { return &linear{} }, +} + +// RegisterMode adds a new display mode to the registry. Call this from +// an init() function in the package that provides the implementation. +// Panics if the name is already registered. +func RegisterMode(name string, f factory) { + if _, exists := registry[name]; exists { + panic(fmt.Sprintf("ui: display mode %q already registered", name)) + } + registry[name] = f +} + +// New returns the Manager for the requested mode. Returns an error if the +// mode is not registered. +func New(mode string) (Manager, error) { + if mode == "" { + mode = ModeDefault + } + f, ok := registry[mode] + if !ok { + return nil, &ErrUnknownMode{Mode: mode} + } + return f(), nil +} + +// registeredModes returns a slice of all known mode names, for error messages. +func registeredModes() []string { + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + return names +} diff --git a/cmd/ui/manager_test.go b/cmd/ui/manager_test.go new file mode 100644 index 0000000..eccf3ff --- /dev/null +++ b/cmd/ui/manager_test.go @@ -0,0 +1,109 @@ +package ui_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/snivilised/agenor/cmd/ui" + "github.com/snivilised/agenor/core" +) + +// --------------------------------------------------------------------------- +// Specs +// --------------------------------------------------------------------------- + +var _ = Describe("ui.New", func() { + + Context("given an empty mode string", func() { + It("returns the default linear manager", func() { + m, err := ui.New("") + Expect(err).To(BeNil()) + Expect(m).NotTo(BeNil()) + }) + }) + + Context("given mode 'linear'", func() { + It("returns a Manager without error", func() { + m, err := ui.New(ui.ModeLinear) + Expect(err).To(BeNil()) + Expect(m).NotTo(BeNil()) + }) + }) + + Context("given an unknown mode", func() { + It("returns an ErrUnknownMode error", func() { + m, err := ui.New("flashy") + Expect(m).To(BeNil()) + Expect(err).NotTo(BeNil()) + + var unknownErr *ui.ErrUnknownMode + Expect(err).To(BeAssignableToTypeOf(unknownErr)) + Expect(err.Error()).To(ContainSubstring("flashy")) + }) + }) +}) + +var _ = Describe("RegisterMode", func() { + Context("registering a new mode", func() { + It("makes the mode available via New", func() { + ui.RegisterMode("test-stub", func() ui.Manager { + return &stubManager{} + }) + m, err := ui.New("test-stub") + Expect(err).To(BeNil()) + Expect(m).NotTo(BeNil()) + }) + }) + + Context("registering a duplicate mode", func() { + It("panics", func() { + Expect(func() { + ui.RegisterMode("test-stub", func() ui.Manager { + return &stubManager{} + }) + }).To(Panic()) + }) + }) +}) + +var _ = Describe("linear Manager", func() { + var m ui.Manager + + BeforeEach(func() { + var err error + m, err = ui.New(ui.ModeLinear) + Expect(err).To(BeNil()) + }) + + Describe("OnNode", func() { + It("does not return an error for a valid node", func() { + node := &core.Node{Path: "/some/path/file.txt"} + Expect(m.OnNode(node)).To(BeNil()) + }) + }) + + Describe("Info / Warn / Error", func() { + It("Info does not panic", func() { + Expect(func() { m.Info("all good") }).NotTo(Panic()) + }) + + It("Warn does not panic", func() { + Expect(func() { m.Warn("something odd") }).NotTo(Panic()) + }) + + It("Error does not panic", func() { + Expect(func() { m.Error("something broke") }).NotTo(Panic()) + }) + }) +}) + +// --------------------------------------------------------------------------- +// Test double - satisfies ui.Manager for registration tests +// --------------------------------------------------------------------------- + +type stubManager struct{} + +func (s *stubManager) OnNode(_ *core.Node) error { return nil } +func (s *stubManager) Info(_ string) {} +func (s *stubManager) Warn(_ string) {} +func (s *stubManager) Error(_ string) {} diff --git a/cmd/ui/ui_suite_test.go b/cmd/ui/ui_suite_test.go new file mode 100644 index 0000000..eb7ba8b --- /dev/null +++ b/cmd/ui/ui_suite_test.go @@ -0,0 +1,13 @@ +package ui_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestUI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ui Suite") +} diff --git a/tools/inspect/main.go b/tools/inspect/main.go new file mode 100644 index 0000000..b314f74 --- /dev/null +++ b/tools/inspect/main.go @@ -0,0 +1,576 @@ +// inspect: Extract exported declarations from Go modules in the module cache. +// +// Usage: +// +// inspect [flags] [@version] +// +// Examples: +// +// inspect github.com/spf13/cobra@v1.8.0 +// inspect --funcs --types github.com/spf13/cobra +// inspect --interfaces golang.org/x/net/html +package main + +import ( + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// --- flags --- + +type config struct { + showVar bool + showInterface bool + showType bool + showConst bool + showFunc bool +} + +func (c config) showAll() bool { + return !c.showVar && !c.showInterface && !c.showType && !c.showConst && !c.showFunc +} +func (c config) wantVar() bool { return c.showAll() || c.showVar } +func (c config) wantInterface() bool { return c.showAll() || c.showInterface } +func (c config) wantType() bool { return c.showAll() || c.showType } +func (c config) wantConst() bool { return c.showAll() || c.showConst } +func (c config) wantFunc() bool { return c.showAll() || c.showFunc } + +// --- entry point --- + +func main() { + cfg := config{} + flag.BoolVar(&cfg.showVar, "vars", false, "show exported var declarations") + flag.BoolVar(&cfg.showInterface, "interfaces", false, "show exported interface declarations") + flag.BoolVar(&cfg.showType, "types", false, "show exported type declarations") + flag.BoolVar(&cfg.showConst, "consts", false, "show exported const declarations") + flag.BoolVar(&cfg.showFunc, "funcs", false, "show exported func/method signatures") + flag.Usage = usage + flag.Parse() + + if flag.NArg() != 1 { + usage() + os.Exit(1) + } + + moduleArg := flag.Arg(0) + modPath, version, hasVersion := strings.Cut(moduleArg, "@") + + // Resolve the cache directory for the module. + cacheDir, err := resolveModuleDir(modPath, version, hasVersion) + if err != nil { + fatalf("error resolving module: %v", err) + } + + // Walk all .go files (non-test) and collect declarations. + if err := walkAndPrint(cacheDir, modPath, cfg); err != nil { + fatalf("error processing module: %v", err) + } +} + +// --- module cache resolution --- + +// resolveModuleDir locates the module directory inside GOMODCACHE. +// If no version is supplied it picks the highest available version. +func resolveModuleDir(modPath, version string, hasVersion bool) (string, error) { + gomodcache, err := goModCache() + if err != nil { + return "", fmt.Errorf("cannot determine GOMODCACHE: %w", err) + } + if gomodcache == "" { + return "", fmt.Errorf("GOMODCACHE is empty") + } + + // Module paths inside the cache use a lower-cased, escaped encoding + // (e.g. capital letters β†’ '!'+lowercase). We replicate that here. + escapedMod := escapePath(modPath) + + if hasVersion && version != "" { + escapedVer := escapePath(version) + dir := filepath.Join(gomodcache, escapedMod+"@"+escapedVer) + info, statErr := os.Stat(dir) + if statErr == nil && info.IsDir() { + return dir, nil + } + return "", fmt.Errorf("module %s@%s not found in cache (%s)", modPath, version, dir) + } + + // No version: find all available versions in the cache. + prefix := filepath.Join(gomodcache, escapedMod+"@") + parent := filepath.Dir(prefix) + base := filepath.Base(escapedMod) + "@" + + entries, err := os.ReadDir(parent) + if err != nil { + return "", fmt.Errorf("cannot read cache dir %s: %w", parent, err) + } + + var matches []string + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), base) { + matches = append(matches, filepath.Join(parent, e.Name())) + } + } + if len(matches) == 0 { + return "", fmt.Errorf("no cached versions of %q found under %s", modPath, gomodcache) + } + sort.Strings(matches) + chosen := matches[len(matches)-1] + fmt.Fprintf(os.Stderr, "# using %s\n", filepath.Base(chosen)) + return chosen, nil +} + +// goModCache returns the GOMODCACHE path. The subcommand args are literals +// (not user-supplied) so the gosec G204 subprocess warning does not apply. +func goModCache() (string, error) { + out, err := exec.Command("go", "env", "GOMODCACHE").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// escapePath converts a module path to the cache's filesystem encoding. +// Capital letters become '!' followed by the lowercase letter. +func escapePath(s string) string { + var b strings.Builder + for _, r := range s { + if r >= 'A' && r <= 'Z' { + b.WriteByte('!') + b.WriteRune(r + 32) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +// --- AST walking --- + +func walkAndPrint(root, modPath string, cfg config) error { + fset := token.NewFileSet() + + // Group files by package directory so we parse each package once. + type pkgDir struct { + importPath string + dir string + } + var dirs []pkgDir + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // skip unreadable entries + } + if info.IsDir() { + // Derive the import path from the directory relative to the module root. + rel, _ := filepath.Rel(root, path) + if rel == "." { + dirs = append(dirs, pkgDir{modPath, path}) + } else { + dirs = append(dirs, pkgDir{modPath + "/" + filepath.ToSlash(rel), path}) + } + } + return nil + }) + if err != nil { + return err + } + + for _, pd := range dirs { + // ParseDir is deprecated in favour of golang.org/x/tools/go/packages, but + // that would add an external dependency. For read-only inspection of cached + // modules without build-tag filtering, ParseDir is sufficient. + //nolint:staticcheck + pkgs, err := parser.ParseDir(fset, pd.dir, func(fi os.FileInfo) bool { + // Skip test files and files that start with '_' or '.'. + name := fi.Name() + if strings.HasSuffix(name, "_test.go") { + return false + } + if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") { + return false + } + return true + }, parser.SkipObjectResolution) + if err != nil { + // Non-Go or broken directory; just skip. + continue + } + + for _, pkg := range pkgs { + printPackage(fset, pd.importPath, pkg, cfg) + } + } + return nil +} + +// printPackage formats and prints all matching exported declarations from pkg. +// +//nolint:staticcheck // ast.Package deprecated alongside parser.ParseDir; no external deps introduced +func printPackage(fset *token.FileSet, importPath string, pkg *ast.Package, cfg config) { + // Collect all declarations across files. + var out strings.Builder + + // Sort files for deterministic output. + filenames := make([]string, 0, len(pkg.Files)) + for name := range pkg.Files { + filenames = append(filenames, name) + } + sort.Strings(filenames) + + for _, filename := range filenames { + file := pkg.Files[filename] + for _, decl := range file.Decls { + switch d := decl.(type) { + case *ast.GenDecl: + handleGenDecl(fset, d, cfg, &out) + case *ast.FuncDecl: + if cfg.wantFunc() && ast.IsExported(d.Name.Name) { + out.WriteString(funcSignature(fset, d)) + out.WriteByte('\n') + } + } + } + } + + if out.Len() > 0 { + fmt.Printf("// package %s (%s)\n", pkg.Name, importPath) + fmt.Print(out.String()) + fmt.Println() + } +} + +// --- declaration formatters --- + +func handleGenDecl(fset *token.FileSet, d *ast.GenDecl, cfg config, out *strings.Builder) { + switch d.Tok { //nolint:exhaustive // only VAR, CONST, TYPE are relevant declaration tokens + case token.VAR: + if !cfg.wantVar() { + return + } + for _, spec := range d.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, name := range vs.Names { + if !ast.IsExported(name.Name) { + continue + } + var typeStr string + if vs.Type != nil { + typeStr = exprString(fset, vs.Type) + } else if vs.Values != nil && i < len(vs.Values) { + // Infer type from value via a best-effort stringification. + typeStr = inferredType(vs.Values[i]) + } + if typeStr != "" { + fmt.Fprintf(out, "var %s %s\n", name.Name, typeStr) + } else { + fmt.Fprintf(out, "var %s\n", name.Name) + } + } + } + + case token.CONST: + if !cfg.wantConst() { + return + } + for _, spec := range d.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, name := range vs.Names { + if !ast.IsExported(name.Name) { + continue + } + var parts []string + parts = append(parts, name.Name) + if vs.Type != nil { + parts = append(parts, exprString(fset, vs.Type)) + } + if i < len(vs.Values) { + parts = append(parts, "= "+exprString(fset, vs.Values[i])) + } + fmt.Fprintf(out, "const %s\n", strings.Join(parts, " ")) + } + } + + case token.TYPE: + for _, spec := range d.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || !ast.IsExported(ts.Name.Name) { + continue + } + switch t := ts.Type.(type) { + case *ast.InterfaceType: + if cfg.wantInterface() { + fmt.Fprintf(out, "%s\n", interfaceDecl(fset, ts.Name.Name, t)) + } + default: + if cfg.wantType() { + if ts.TypeParams != nil { + fmt.Fprintf(out, "type %s%s %s\n", + ts.Name.Name, + typeParamsString(fset, ts.TypeParams), + exprString(fset, ts.Type)) + } else { + fmt.Fprintf(out, "type %s %s\n", ts.Name.Name, exprString(fset, ts.Type)) + } + } + } + } + default: + // token.IMPORT, token.PACKAGE, and all non-decl tokens are irrelevant. + } +} + +func funcSignature(fset *token.FileSet, d *ast.FuncDecl) string { + var sb strings.Builder + sb.WriteString("func ") + if d.Recv != nil && len(d.Recv.List) > 0 { + sb.WriteString("(") + sb.WriteString(fieldListString(fset, d.Recv)) + sb.WriteString(") ") + } + sb.WriteString(d.Name.Name) + if d.Type.TypeParams != nil { + sb.WriteString(typeParamsString(fset, d.Type.TypeParams)) + } + sb.WriteString("(") + if d.Type.Params != nil { + sb.WriteString(fieldListString(fset, d.Type.Params)) + } + sb.WriteString(")") + if d.Type.Results != nil && len(d.Type.Results.List) > 0 { + results := fieldListString(fset, d.Type.Results) + if len(d.Type.Results.List) > 1 || (len(d.Type.Results.List) == 1 && len(d.Type.Results.List[0].Names) > 0) { + sb.WriteString(" (") + sb.WriteString(results) + sb.WriteString(")") + } else { + sb.WriteString(" ") + sb.WriteString(results) + } + } + return sb.String() +} + +func interfaceDecl(fset *token.FileSet, name string, iface *ast.InterfaceType) string { + var sb strings.Builder + fmt.Fprintf(&sb, "type %s interface {\n", name) + if iface.Methods != nil { + for _, method := range iface.Methods.List { + switch mt := method.Type.(type) { + case *ast.FuncType: + // Named method. + for _, mname := range method.Names { + sb.WriteString("\t") + sb.WriteString(mname.Name) + sb.WriteString("(") + if mt.Params != nil { + sb.WriteString(fieldListString(fset, mt.Params)) + } + sb.WriteString(")") + if mt.Results != nil && len(mt.Results.List) > 0 { + results := fieldListString(fset, mt.Results) + if len(mt.Results.List) > 1 || (len(mt.Results.List) == 1 && len(mt.Results.List[0].Names) > 0) { + sb.WriteString(" (") + sb.WriteString(results) + sb.WriteString(")") + } else { + sb.WriteString(" ") + sb.WriteString(results) + } + } + sb.WriteByte('\n') + } + default: + // Embedded type or type constraint. + sb.WriteString("\t") + sb.WriteString(exprString(fset, method.Type)) + sb.WriteByte('\n') + } + } + } + sb.WriteString("}") + return sb.String() +} + +// --- expression stringifiers --- + +func exprString(fset *token.FileSet, expr ast.Expr) string { + if expr == nil { + return "" + } + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.StarExpr: + return "*" + exprString(fset, e.X) + case *ast.SelectorExpr: + return exprString(fset, e.X) + "." + e.Sel.Name + case *ast.ArrayType: + if e.Len == nil { + return "[]" + exprString(fset, e.Elt) + } + return "[" + exprString(fset, e.Len) + "]" + exprString(fset, e.Elt) + case *ast.MapType: + return "map[" + exprString(fset, e.Key) + "]" + exprString(fset, e.Value) + case *ast.ChanType: + switch e.Dir { + case ast.SEND: + return "chan<- " + exprString(fset, e.Value) + case ast.RECV: + return "<-chan " + exprString(fset, e.Value) + default: + return "chan " + exprString(fset, e.Value) + } + case *ast.FuncType: + var sb strings.Builder + sb.WriteString("func(") + if e.Params != nil { + sb.WriteString(fieldListString(fset, e.Params)) + } + sb.WriteString(")") + if e.Results != nil && len(e.Results.List) > 0 { + sb.WriteString(" ") + sb.WriteString(fieldListString(fset, e.Results)) + } + return sb.String() + case *ast.InterfaceType: + if e.Methods == nil || len(e.Methods.List) == 0 { + return "interface{}" + } + return "interface{ ... }" + case *ast.StructType: + return "struct{ ... }" + case *ast.Ellipsis: + return "..." + exprString(fset, e.Elt) + case *ast.IndexExpr: + return exprString(fset, e.X) + "[" + exprString(fset, e.Index) + "]" + case *ast.IndexListExpr: + // Generic instantiation with multiple type parameters. + indices := make([]string, len(e.Indices)) + for i, idx := range e.Indices { + indices[i] = exprString(fset, idx) + } + return exprString(fset, e.X) + "[" + strings.Join(indices, ", ") + "]" + case *ast.BinaryExpr: + return exprString(fset, e.X) + " " + e.Op.String() + " " + exprString(fset, e.Y) + case *ast.UnaryExpr: + return e.Op.String() + exprString(fset, e.X) + case *ast.BasicLit: + return e.Value + case *ast.ParenExpr: + return "(" + exprString(fset, e.X) + ")" + case *ast.CompositeLit: + return exprString(fset, e.Type) + "{...}" + case *ast.CallExpr: + args := make([]string, len(e.Args)) + for i, a := range e.Args { + args[i] = exprString(fset, a) + } + return exprString(fset, e.Fun) + "(" + strings.Join(args, ", ") + ")" + default: + return fmt.Sprintf("<%T>", expr) + } +} + +func fieldListString(fset *token.FileSet, fl *ast.FieldList) string { + if fl == nil { + return "" + } + parts := make([]string, 0, len(fl.List)) + for _, field := range fl.List { + typeStr := exprString(fset, field.Type) + if len(field.Names) == 0 { + parts = append(parts, typeStr) + } else { + names := make([]string, len(field.Names)) + for i, n := range field.Names { + names[i] = n.Name + } + parts = append(parts, strings.Join(names, ", ")+" "+typeStr) + } + } + return strings.Join(parts, ", ") +} + +func typeParamsString(fset *token.FileSet, tparams *ast.FieldList) string { + return "[" + fieldListString(fset, tparams) + "]" +} + +// inferredType attempts to derive a human-readable type from a value expression. +// It's intentionally shallow β€” just enough for common const/var patterns. +func inferredType(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.BasicLit: + switch e.Kind { //nolint:exhaustive // only INT, FLOAT, STRING, CHAR map to useful type names + case token.INT: + return "int" + case token.FLOAT: + return "float64" + case token.STRING: + return "string" + case token.CHAR: + return "rune" + default: + // token.IMAG and others β€” not useful for type inference. + } + case *ast.Ident: + switch e.Name { + case "true", "false": + return "bool" + case "nil": + return "" + } + case *ast.CallExpr: + if id, ok := e.Fun.(*ast.Ident); ok { + // e.g. errors.New(...) β€” just return the func name as a hint. + return id.Name + "(...)" + } + if sel, ok := e.Fun.(*ast.SelectorExpr); ok { + return exprString(nil, sel) + } + case *ast.UnaryExpr: + return inferredType(e.X) + } + return "" +} + +// --- helpers --- + +func usage() { + fmt.Fprintln(os.Stderr, `inspect β€” extract exported declarations from a cached Go module + +Usage: + inspect [flags] [@version] + +Flags: + --vars show exported var declarations + --interfaces show exported interface types (with method signatures) + --types show exported type declarations + --consts show exported const declarations + --funcs show exported function/method signatures + +Without any flags, all declaration kinds are shown. + +Examples: + inspect github.com/spf13/cobra@v1.8.0 + inspect --funcs --types github.com/spf13/cobra + inspect --interfaces golang.org/x/net/html`) +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "inspect: "+format+"\n", args...) + os.Exit(1) +}