diff --git a/README.md b/README.md index e8f7d22..962d72f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,10 @@ A Nix flake is also provided (`nix build .#mise`). `mise` reads a config file from `$XDG_CONFIG_HOME/mise/config.toml` - see [the example config](./docs/EXAMPLE_CONFIG.toml) for an example. You can also configure mise using env vars e.g. `MISE_TANDOOR_TOKEN` or with a global flag -e.g. `--tandoor.token`. +e.g. `--tandoor-url`. + +Environment variables use `MISE_`, then the section and key in uppercase; +for example, `tandoor.token` becomes `MISE_TANDOOR_TOKEN`. ### Providers diff --git a/cmd/configure.go b/cmd/configure.go index 005f984..54d7771 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -16,8 +16,9 @@ import ( ) var configureCmd = &cli.Command{ - Name: "configure", - Usage: "Provide configuration values for mise", + Name: "configure", + Usage: "Provide configuration values for mise", + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions, cliutil.TandoorOptions), Flags: []cli.Flag{ &cli.BoolFlag{ Name: "print", diff --git a/cmd/genkit.go b/cmd/genkit.go index d666d89..5145e1c 100644 --- a/cmd/genkit.go +++ b/cmd/genkit.go @@ -18,9 +18,10 @@ import ( // Initializes the AI client and idles, for use with genkit's dev tooling // Run with `genkit start -- mise genkit` var genkitDevCmd = &cli.Command{ - Name: "genkit", - Usage: "Initialize the AI client and idle, for use with genkit's dev tooling", - Hidden: true, + Name: "genkit", + Usage: "Initialize the AI client and idle, for use with genkit's dev tooling", + Hidden: true, + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions, cliutil.TandoorOptions), Action: func(ctx context.Context, cmd *cli.Command) error { cfg := config.FromContext(ctx) diff --git a/cmd/help.go b/cmd/help.go index 387c2ff..628f988 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -1,8 +1,10 @@ package cmd import ( + "context" "sort" + "github.com/derethil/mise/internal/cliutil" "github.com/urfave/cli/v3" ) @@ -15,26 +17,26 @@ const commandHelpTemplate = `NAME: {{template "helpNameTemplate" .}} USAGE: - {{template "usageTemplate" .}}{{if .Category}} + {{if .UsageText}}{{wrap .UsageText 3}}{{else}}{{.FullName}}{{if .VisibleFlags}} [options]{{end}}{{if .VisibleCommands}} [command [command options]]{{end}}{{if .ArgsUsage}} {{trim .ArgsUsage}}{{else}}{{range .Arguments}} {{.Usage}}{{end}}{{end}}{{end}}{{if .Category}} CATEGORY: {{.Category}}{{end}}{{if .Description}} DESCRIPTION: - {{template "descriptionTemplate" .}}{{end}}{{if .VisibleFlagCategories}} + {{template "descriptionTemplate" .}}{{end}}{{if .Metadata.hasLocalOptions}} -OPTIONS:{{template "visibleFlagCategoryTemplate" .}}{{else if .VisibleFlags}} - -OPTIONS:{{template "visibleFlagTemplate" .}}{{end}}{{if .VisiblePersistentFlags}} - -GLOBAL OPTIONS:{{template "visibleFlagCategoryTemplate" .Root.Metadata.globalFlagCategories}}{{end}} +OPTIONS:{{if .VisibleFlagCategories}}{{template "visibleFlagCategoryTemplate" .}}{{else}}{{template "visibleFlagTemplate" .}} +{{end}}{{else}} +{{end}}{{if .VisiblePersistentFlags}} +GLOBAL OPTIONS:{{template "visibleFlagCategoryTemplate" .Metadata.visibleGlobalFlagCategories}} + See 'mise --help' for all global configuration overrides.{{end}} ` const subcommandHelpTemplate = `NAME: {{template "helpNameTemplate" .}} USAGE: - {{if .UsageText}}{{wrap .UsageText 3}}{{else}}{{.FullName}}{{if .VisibleCommands}} [command [command options]]{{end}}{{if .ArgsUsage}} {{.ArgsUsage}}{{else}}{{if .Arguments}} [arguments...]{{end}}{{end}}{{end}}{{if .Category}} + {{if .UsageText}}{{wrap .UsageText 3}}{{else}}{{.FullName}}{{if .VisibleFlags}} [options]{{end}}{{if .VisibleCommands}} [command [command options]]{{end}}{{if .ArgsUsage}} {{trim .ArgsUsage}}{{else}}{{range .Arguments}} {{.Usage}}{{end}}{{end}}{{end}}{{if .Category}} CATEGORY: {{.Category}}{{end}}{{if .Description}} @@ -42,15 +44,20 @@ CATEGORY: DESCRIPTION: {{template "descriptionTemplate" .}}{{end}}{{if .VisibleCommands}} -COMMANDS:{{template "visibleCommandTemplate" .}}{{end}}{{if .VisibleFlagCategories}} - -OPTIONS:{{template "visibleFlagCategoryTemplate" .}}{{else if .VisibleFlags}} +COMMANDS:{{template "visibleCommandTemplate" .}}{{end}}{{if .Metadata.hasLocalOptions}} -OPTIONS:{{template "visibleFlagTemplate" .}}{{end}}{{if .VisiblePersistentFlags}} - -GLOBAL OPTIONS:{{template "visibleFlagCategoryTemplate" .Root.Metadata.globalFlagCategories}}{{end}} +OPTIONS:{{if .VisibleFlagCategories}}{{template "visibleFlagCategoryTemplate" .}}{{else}}{{template "visibleFlagTemplate" .}} +{{end}}{{else}} +{{end}}{{if .VisiblePersistentFlags}} +GLOBAL OPTIONS:{{template "visibleFlagCategoryTemplate" .Metadata.visibleGlobalFlagCategories}} + See 'mise --help' for all global configuration overrides.{{end}} ` +const ( + hasLocalOptionsMetadataKey = "hasLocalOptions" + visibleGlobalFlagCategoriesMetadataKey = "visibleGlobalFlagCategories" +) + type globalFlagCategories struct { categories []cli.VisibleFlagCategory } @@ -72,10 +79,18 @@ func (g globalFlagCategory) Flags() []cli.Flag { return g.flags } -func newGlobalFlagCategories(flags []cli.Flag) globalFlagCategories { +func newGlobalFlagCategories(flags []cli.Flag, allowedNames ...string) globalFlagCategories { + allowed := make(map[string]bool, len(allowedNames)) + for _, name := range allowedNames { + allowed[name] = true + } + byCategory := make(map[string][]cli.Flag) for _, flag := range flags { category := flag.(cli.CategorizableFlag).GetCategory() + if len(allowed) > 0 && !allowed[category] { + continue + } byCategory[category] = append(byCategory[category], flag) } @@ -96,3 +111,34 @@ func newGlobalFlagCategories(flags []cli.Flag) globalFlagCategories { return globalFlagCategories{categories: categories} } + +func configureGlobalHelp(cmd *cli.Command, flags []cli.Flag) { + categories := cmd.Metadata[cliutil.GlobalFlagCategoriesMetadataKey].([]string) + cmd.Metadata[hasLocalOptionsMetadataKey] = len(cmd.Flags) > 0 + cmd.Metadata[visibleGlobalFlagCategoriesMetadataKey] = newGlobalFlagCategories(flags, categories...) + if cmd.Name != "mise" { + cmd.ShellComplete = completeWithGlobalFlags + } + + for _, child := range cmd.Commands { + configureGlobalHelp(child, flags) + } +} + +func completeWithGlobalFlags(ctx context.Context, cmd *cli.Command) { + localFlags := cmd.Flags + cmd.Flags = append(append([]cli.Flag{}, localFlags...), relevantGlobalFlags(cmd)...) + defer func() { cmd.Flags = localFlags }() + + cli.DefaultCompleteWithFlags(ctx, cmd) +} + +func relevantGlobalFlags(cmd *cli.Command) []cli.Flag { + categories := cmd.Metadata[visibleGlobalFlagCategoriesMetadataKey].(globalFlagCategories) + var flags []cli.Flag + for _, category := range categories.VisibleFlagCategories() { + flags = append(flags, category.Flags()...) + } + + return flags +} diff --git a/cmd/help_test.go b/cmd/help_test.go new file mode 100644 index 0000000..6f0dd93 --- /dev/null +++ b/cmd/help_test.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "bytes" + "context" + "testing" + + "github.com/derethil/mise/internal/cliutil" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestGlobalHelpCategoriesAreRelevantToCommand(t *testing.T) { + tests := []struct { + name string + command *cli.Command + categories []string + }{ + {name: "logs", command: rootCmd.Command("logs"), categories: []string{"GENERAL OPTIONS"}}, + {name: "recipe backup", command: rootCmd.Command("recipe").Command("backup"), categories: []string{"GENERAL OPTIONS", "TANDOOR OPTIONS"}}, + {name: "recipe keyword", command: rootCmd.Command("recipe").Command("keyword"), categories: []string{"GENERAL OPTIONS", "PROVIDER OPTIONS", "TANDOOR OPTIONS"}}, + {name: "models clear", command: rootCmd.Command("models").Command("clear"), categories: []string{"GENERAL OPTIONS", "PROVIDER OPTIONS"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + metadata := test.command.Metadata[visibleGlobalFlagCategoriesMetadataKey].(globalFlagCategories) + var names []string + for _, category := range metadata.VisibleFlagCategories() { + names = append(names, category.Name()) + } + assert.Equal(t, test.categories, names) + }) + } +} + +func TestEveryCommandDeclaresGlobalFlagCategories(t *testing.T) { + var check func(*cli.Command) + check = func(command *cli.Command) { + if command.Name == "help" { + return + } + + categories, ok := command.Metadata[cliutil.GlobalFlagCategoriesMetadataKey].([]string) + assert.True(t, ok, "%s must declare its global flag categories", command.Name) + assert.Contains(t, categories, cliutil.GeneralOptions) + + for _, child := range command.Commands { + check(child) + } + } + + check(rootCmd) +} + +func TestCompletionIncludesRelevantGlobalFlags(t *testing.T) { + assert.ElementsMatch(t, + []string{"config", "verbose", "model", "ollama-url", "start-ollama", "tandoor-url"}, + flagNames(relevantGlobalFlags(rootCmd.Command("import"))), + ) + assert.ElementsMatch(t, + []string{"config", "verbose", "tandoor-url"}, + flagNames(relevantGlobalFlags(rootCmd.Command("recipe").Command("backup"))), + ) + assert.ElementsMatch(t, + []string{"config", "verbose"}, + flagNames(relevantGlobalFlags(rootCmd.Command("logs"))), + ) +} + +func TestOptionsSectionRequiresLocalOptions(t *testing.T) { + withoutOptions := renderCommandHelp(t, nil) + assert.NotContains(t, withoutOptions, "\nOPTIONS:") + assert.Contains(t, withoutOptions, "\nGLOBAL OPTIONS:") + assert.Contains(t, withoutOptions, "mise child [options]\n\nGLOBAL OPTIONS:") + + withOptions := renderCommandHelp(t, []cli.Flag{&cli.BoolFlag{Name: "local"}}) + assert.Contains(t, withOptions, "\nOPTIONS:") + assert.Contains(t, withOptions, "--local") + assert.Contains(t, withOptions, "--help, -h show help\n\nGLOBAL OPTIONS:") + + withArgument := renderCommandHelpWithArguments(t, nil, []cli.Argument{&cli.StringArg{Name: "value"}}) + assert.Contains(t, withArgument, "mise child [options] [value]\n") + assert.NotContains(t, withArgument, "[value] \n") +} + +func renderCommandHelp(t *testing.T, localFlags []cli.Flag) string { + return renderCommandHelpWithArguments(t, localFlags, nil) +} + +func renderCommandHelpWithArguments(t *testing.T, localFlags []cli.Flag, arguments []cli.Argument) string { + t.Helper() + + var output bytes.Buffer + command := &cli.Command{ + Name: "child", + Flags: localFlags, + Arguments: arguments, + Metadata: cliutil.GlobalFlagMetadata(), + } + root := &cli.Command{ + Name: "mise", + Writer: &output, + Flags: []cli.Flag{&cli.BoolFlag{Name: "verbose", Category: cliutil.GeneralOptions}}, + Metadata: cliutil.GlobalFlagMetadata(), + Commands: []*cli.Command{command}, + } + configureGlobalHelp(root, root.Flags) + + err := root.Run(context.Background(), []string{"mise", "child", "--help"}) + assert.NoError(t, err) + + return output.String() +} + +func flagNames(flags []cli.Flag) []string { + names := make([]string, 0, len(flags)) + for _, flag := range flags { + names = append(names, flag.Names()[0]) + } + + return names +} diff --git a/cmd/import/import.go b/cmd/import/import.go index f86b393..a6c91c7 100644 --- a/cmd/import/import.go +++ b/cmd/import/import.go @@ -13,24 +13,28 @@ import ( ) const ( - flagCookiesFile = "video.cookies-file" - flagCookiesFromBrowser = "video.cookies-from-browser" + flagCookiesFile = "cookies-file" + flagCookiesFromBrowser = "cookies-from-browser" flagDryRun = "dry-run" ) var Command = &cli.Command{ - Name: "import", - Usage: "Import a recipe from various social media video sources", + Name: "import", + Usage: "Import a recipe from various social media video sources", + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions, cliutil.TandoorOptions), Arguments: []cli.Argument{ &cli.StringArg{ Name: "url", Required: true, }, }, - Flags: append(config.FlagsForCommand("import"), &cli.BoolFlag{ - Name: flagDryRun, - Usage: "Fetch and print video metadata without downloading", - }), + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: flagDryRun, + Usage: "Fetch and print video metadata without downloading", + Category: "ACTION OPTIONS", + }, + }, config.FlagsForCommand("import")...), ArgValidator: func(ctx context.Context, cmd *cli.Command) error { validators := []func(*cli.Command) error{ validateUrl, diff --git a/cmd/logs.go b/cmd/logs.go index eda103c..4019b31 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -4,13 +4,15 @@ import ( "context" "fmt" + "github.com/derethil/mise/internal/cliutil" "github.com/derethil/mise/internal/logging" "github.com/urfave/cli/v3" ) var logsCmd = &cli.Command{ - Name: "logs", - Usage: "Print path to the mise logs file", + Name: "logs", + Usage: "Print path to the mise logs file", + Metadata: cliutil.GlobalFlagMetadata(), Action: func(ctx context.Context, cmd *cli.Command) error { fmt.Println(logging.LogPath) return nil diff --git a/cmd/model/clear.go b/cmd/model/clear.go index b29dd87..4f6174d 100644 --- a/cmd/model/clear.go +++ b/cmd/model/clear.go @@ -13,8 +13,9 @@ import ( ) var clearCmd = &cli.Command{ - Name: "clear", - Usage: "Delete Ollama models that are not configured for use by mise", + Name: "clear", + Usage: "Delete Ollama models that are not configured for use by mise", + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions), Flags: []cli.Flag{ &cli.BoolFlag{ Name: "yes", diff --git a/cmd/model/list.go b/cmd/model/list.go index 6b509bc..c1ad0dd 100644 --- a/cmd/model/list.go +++ b/cmd/model/list.go @@ -7,14 +7,16 @@ import ( "strings" "github.com/derethil/mise/internal/ai/providers" + "github.com/derethil/mise/internal/cliutil" "github.com/derethil/mise/internal/config" "github.com/ollama/ollama/format" "github.com/urfave/cli/v3" ) var listCmd = &cli.Command{ - Name: "list", - Usage: "Print Mise's configured Ollama models and their availability", + Name: "list", + Usage: "Print Mise's configured Ollama models and their availability", + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions), Action: func(ctx context.Context, cmd *cli.Command) error { cfg := config.FromContext(ctx) diff --git a/cmd/model/model.go b/cmd/model/model.go index 37bc902..d6f3c0d 100644 --- a/cmd/model/model.go +++ b/cmd/model/model.go @@ -2,11 +2,15 @@ // pulling, and clearing Ollama models. package model -import "github.com/urfave/cli/v3" +import ( + "github.com/derethil/mise/internal/cliutil" + "github.com/urfave/cli/v3" +) var Command = &cli.Command{ - Name: "models", - Usage: "Manage your configured models and their availability", + Name: "models", + Usage: "Manage your configured models and their availability", + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions), Commands: []*cli.Command{ listCmd, pullCmd, diff --git a/cmd/model/pull.go b/cmd/model/pull.go index 7a5ce04..af877db 100644 --- a/cmd/model/pull.go +++ b/cmd/model/pull.go @@ -12,8 +12,9 @@ import ( ) var pullCmd = &cli.Command{ - Name: "pull", - Usage: "Pull configured models from the Ollama model registry", + Name: "pull", + Usage: "Pull configured models from the Ollama model registry", + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions), Action: func(ctx context.Context, cmd *cli.Command) error { cfg := config.FromContext(ctx) diff --git a/cmd/recipe/backup.go b/cmd/recipe/backup.go index 5c2da40..e53a059 100644 --- a/cmd/recipe/backup.go +++ b/cmd/recipe/backup.go @@ -12,8 +12,9 @@ import ( ) var backupCmd = &cli.Command{ - Name: "backup", - Usage: "Backup a recipe to the local backup directory", + Name: "backup", + Usage: "Backup a recipe to the local backup directory", + Metadata: cliutil.GlobalFlagMetadata(cliutil.TandoorOptions), Arguments: []cli.Argument{ &cli.IntArg{Name: "recipe_id", Required: true}, }, diff --git a/cmd/recipe/keyword.go b/cmd/recipe/keyword.go index e628b55..4815661 100644 --- a/cmd/recipe/keyword.go +++ b/cmd/recipe/keyword.go @@ -20,8 +20,9 @@ import ( ) var keywordCmd = &cli.Command{ - Name: "keyword", - Usage: "Assign keywords to a recipe using AI, following a keyword schema you provide", + Name: "keyword", + Usage: "Assign keywords to a recipe using AI, following a keyword schema you provide", + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions, cliutil.TandoorOptions), Arguments: []cli.Argument{ &cli.IntArg{Name: "id"}, }, @@ -32,20 +33,24 @@ var keywordCmd = &cli.Command{ Aliases: []string{"d"}, }, &cli.BoolFlag{ - Name: "replace", - Usage: "Replace the recipe's keywords instead of adding to them", + Name: "replace", + Aliases: []string{"r"}, + Usage: "Replace the recipe's keywords instead of adding to them", }, &cli.BoolFlag{ - Name: "all", - Usage: "Run on all recipes in the Tandoor instance. Overrides id argument.", + Name: "all", + Aliases: []string{"a"}, + Usage: "Run on all recipes in the Tandoor instance. Overrides id argument.", }, &cli.BoolFlag{ - Name: "failed", - Usage: "Re-run only the recipes that failed on a previous run", + Name: "failed", + Aliases: []string{"f"}, + Usage: "Re-run only the recipes that failed on a previous run", }, &cli.BoolFlag{ - Name: "new", - Usage: "Run on all recipes that haven't been keyworded before", + Name: "new", + Aliases: []string{"n"}, + Usage: "Run on all recipes that haven't been keyworded before", }, }...), Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { @@ -91,7 +96,7 @@ var keywordCmd = &cli.Command{ opts := keywordOptions{ dryRun: cmd.Bool("dry-run"), replace: cmd.Bool("replace"), - ignore: cfg.Keywords.Ignore, + keep: cfg.Keywords.Keep, } run := func(ctx context.Context, id int) error { @@ -109,7 +114,7 @@ var keywordCmd = &cli.Command{ type keywordOptions struct { dryRun bool replace bool - ignore []string + keep []string } func readSchema(path string) (string, error) { @@ -147,7 +152,7 @@ func keywordRecipe(ctx context.Context, tclient *tandoor.Client, feature *assign updated, changes, err := runAssignment(ctx, feature, model, recipe, assignkeywords.ApplyOptions{ Replace: opts.replace, - Protect: opts.ignore, + Protect: opts.keep, }) if err != nil { return err diff --git a/cmd/recipe/normalize.go b/cmd/recipe/normalize.go index fb04780..3d4b295 100644 --- a/cmd/recipe/normalize.go +++ b/cmd/recipe/normalize.go @@ -19,8 +19,9 @@ import ( ) var normalizeCmd = &cli.Command{ - Name: "normalize", - Usage: "Normalize a recipe's ingredient amounts, units, and foods using AI", + Name: "normalize", + Usage: "Normalize a recipe's ingredient amounts, units, and foods using AI", + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions, cliutil.TandoorOptions), Arguments: []cli.Argument{ &cli.IntArg{Name: "id"}, }, @@ -31,16 +32,19 @@ var normalizeCmd = &cli.Command{ Aliases: []string{"d"}, }, &cli.BoolFlag{ - Name: "all", - Usage: "Run on all recipes in the Tandoor instance. Overrides id argument.", + Name: "all", + Aliases: []string{"a"}, + Usage: "Run on all recipes in the Tandoor instance. Overrides id argument.", }, &cli.BoolFlag{ - Name: "failed", - Usage: "Re-run only the recipes that failed on a previous run", + Name: "failed", + Aliases: []string{"f"}, + Usage: "Re-run only the recipes that failed on a previous run", }, &cli.BoolFlag{ - Name: "new", - Usage: "Run on all recipes that haven't been normalized before", + Name: "new", + Aliases: []string{"n"}, + Usage: "Run on all recipes that haven't been normalized before", }, &cli.IntFlag{ Name: "batch-size", diff --git a/cmd/recipe/recipe.go b/cmd/recipe/recipe.go index df29215..da3a146 100644 --- a/cmd/recipe/recipe.go +++ b/cmd/recipe/recipe.go @@ -11,8 +11,9 @@ import ( ) var Command = &cli.Command{ - Name: "recipe", - Usage: "Manage an existing Tandoor recipe", + Name: "recipe", + Usage: "Manage an existing Tandoor recipe", + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions, cliutil.TandoorOptions), Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { cfg := config.FromContext(ctx) cliutil.WarnIfTandoorVersionUnsupported(ctx, tandoor.FromConfig(cfg)) diff --git a/cmd/recipe/restore.go b/cmd/recipe/restore.go index 461a732..63f10d7 100644 --- a/cmd/recipe/restore.go +++ b/cmd/recipe/restore.go @@ -11,8 +11,9 @@ import ( ) var restoreCmd = &cli.Command{ - Name: "restore", - Usage: "Restore a recipe from a local backup", + Name: "restore", + Usage: "Restore a recipe from a local backup", + Metadata: cliutil.GlobalFlagMetadata(cliutil.TandoorOptions), Arguments: []cli.Argument{ &cli.IntArg{Name: "recipe_id", Required: true}, }, diff --git a/cmd/root.go b/cmd/root.go index aa43fc2..a9992c4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,7 @@ var globalFlags = []cli.Flag{ Name: string(cliutil.GlobalFlagModel), Usage: "Override the AI model to use for this command", Aliases: []string{"m"}, - Category: "GENERAL OPTIONS", + Category: "PROVIDER OPTIONS", }, &cli.BoolFlag{ Name: string(cliutil.GlobalFlagVerbose), @@ -51,7 +51,7 @@ var rootCmd = &cli.Command{ Usage: "mise is a CLI for managing Tandoor recipes", Version: version, Flags: rootFlags, - Metadata: map[string]any{"globalFlagCategories": newGlobalFlagCategories(rootFlags)}, + Metadata: cliutil.GlobalFlagMetadata(cliutil.ProviderOptions, cliutil.TandoorOptions), UseShortOptionHandling: true, Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { @@ -84,6 +84,10 @@ var rootCmd = &cli.Command{ EnableShellCompletion: true, } +func init() { + configureGlobalHelp(rootCmd, rootFlags) +} + func Execute() { args := os.Args[1:] diff --git a/docs/EXAMPLE_CONFIG.toml b/docs/EXAMPLE_CONFIG.toml index 3dc5e71..5310465 100644 --- a/docs/EXAMPLE_CONFIG.toml +++ b/docs/EXAMPLE_CONFIG.toml @@ -16,5 +16,5 @@ autostart = false # large = "ollama/qwen3:14b" # Model for harder tasks, as provider/model [keywords] -ignore = [] # Keywords that don't count as tagged when using --untagged -# schema_file = "$XDG_CONFIG_HOME/mise/keyword_schema.md" +keep = [] # Keywords to preserve when using recipe keyword --replace +# schema_file = "$XDG_CONFIG_HOME/mise/keyword_schema.md" diff --git a/internal/cliutil/errors.go b/internal/cliutil/errors.go index 7ac0a5d..387276d 100644 --- a/internal/cliutil/errors.go +++ b/internal/cliutil/errors.go @@ -82,7 +82,7 @@ func AIUserError(err error, model string) error { case errors.Is(err, providers.ErrOllamaNotInstalled): return ErrWithUserMessage(err, "Could not find `ollama` on your PATH. Install Ollama or add it to your PATH, then try again.") case errors.Is(err, providers.ErrOllamaUnavailable): - return ErrWithUserMessage(err, "Could not connect to Ollama. Make sure it is running (try `ollama serve`) or provide --providers.ollama.autostart and try again.") + return ErrWithUserMessage(err, "Could not connect to Ollama. Make sure it is running (try `ollama serve`) or provide --start-ollama and try again.") case errors.Is(err, ai.ErrModelMissingTools): return ErrWithUserMessage(err, "Model %s doesn't support tool calling, which mise's AI commands need to look up existing entries in Tandoor. Choose a different model with --model or modify your config.", model) case errors.Is(err, config.ErrInvalidConfig): @@ -98,23 +98,22 @@ func AIUserError(err error, model string) error { func VideoUserError(err error) error { if tooLong, ok := errors.AsType[*video.TooLongError](err); ok { - return ErrWithUserMessage(err, "That video is %s long (limit %s). Raise `video.max_duration_minutes` to import it anyway.", + return ErrWithUserMessage(err, "That video is %s long (limit %s). Raise `--max-duration` to import it anyway.", tooLong.Duration.Round(time.Second), tooLong.Limit) } if download, ok := errors.AsType[*video.DownloadError](err); ok && download.Stderr != "" { return ErrWithUserMessage(err, "yt-dlp could not download this video:\n%s", download.Stderr) } - switch { case errors.Is(err, video.ErrBinaryMissing): - return ErrWithUserMessage(err, "Could not find `yt-dlp` on your PATH, and downloading one failed. Install it (`nix profile install nixpkgs#yt-dlp`) or set `video.ytdlp_path`.") + return ErrWithUserMessage(err, "Could not find `yt-dlp` on your PATH, and downloading one failed. Install it (`nix profile install nixpkgs#yt-dlp`) or set `--yt-dlp-path`.") case errors.Is(err, video.ErrPlaylistURL): return ErrWithUserMessage(err, "That URL is a playlist or profile. Pass a link to a single video.") case errors.Is(err, video.ErrLiveVideo): return ErrWithUserMessage(err, "That URL is a live stream, which mise can't import. Wait for the recording to be published and try again.") case errors.Is(err, video.ErrDurationUnknown): - return ErrWithUserMessage(err, "yt-dlp didn't report a duration for that video, so mise can't check it against `video.max_duration_minutes`. Set that to 0 to import it anyway.") + return ErrWithUserMessage(err, "yt-dlp didn't report a duration for that video, so mise can't check it against `--max-duration`. Set it to 0 to import it anyway.") case errors.Is(err, video.ErrNoVideo): return ErrWithUserMessage(err, "No video was found at that URL.") case errors.Is(err, video.ErrDownloadFailed): diff --git a/internal/cliutil/errors_test.go b/internal/cliutil/errors_test.go new file mode 100644 index 0000000..bb70bb3 --- /dev/null +++ b/internal/cliutil/errors_test.go @@ -0,0 +1,25 @@ +package cliutil + +import ( + "testing" + "time" + + "github.com/derethil/mise/internal/video" + "github.com/stretchr/testify/assert" +) + +func TestVideoUserErrorReferencesPublicDurationFlag(t *testing.T) { + err := &video.TooLongError{Duration: 45 * time.Minute, Limit: 30 * time.Minute} + + message := UserMessage(VideoUserError(err)) + + assert.Contains(t, message, "--max-duration") + assert.NotContains(t, message, "video.max_duration_minutes") +} + +func TestVideoUserErrorReferencesPublicYtdlpFlag(t *testing.T) { + message := UserMessage(VideoUserError(video.ErrBinaryMissing)) + + assert.Contains(t, message, "--yt-dlp-path") + assert.NotContains(t, message, "video.ytdlp_path") +} diff --git a/internal/cliutil/metadata.go b/internal/cliutil/metadata.go new file mode 100644 index 0000000..3bcc48e --- /dev/null +++ b/internal/cliutil/metadata.go @@ -0,0 +1,14 @@ +package cliutil + +const ( + GlobalFlagCategoriesMetadataKey = "globalFlagCategories" + GeneralOptions = "GENERAL OPTIONS" + ProviderOptions = "PROVIDER OPTIONS" + TandoorOptions = "TANDOOR OPTIONS" +) + +func GlobalFlagMetadata(categories ...string) map[string]any { + categories = append([]string{GeneralOptions}, categories...) + + return map[string]any{GlobalFlagCategoriesMetadataKey: categories} +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e0fcc43..53f12ea 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -73,17 +73,55 @@ func (s *ConfigSuite) TestFlagsHaveCategories() { categories[flag.Names()[0]] = flag.(cli.CategorizableFlag).GetCategory() } - s.Equal("TANDOOR OPTIONS", categories["tandoor.token"]) - s.Equal("TANDOOR OPTIONS", categories["tandoor.base-url"]) - s.Equal("PROVIDER OPTIONS", categories["providers.ollama.base-url"]) - s.NotContains(categories, "keywords.schema_file") - s.NotContains(categories, "keywords.ignore") + s.Equal("TANDOOR OPTIONS", categories["tandoor-url"]) + s.Equal("PROVIDER OPTIONS", categories["ollama-url"]) + s.Equal("PROVIDER OPTIONS", categories["start-ollama"]) + s.NotContains(categories, "tandoor.token") + s.NotContains(categories, "schema-file") + s.NotContains(categories, "keep") for _, flag := range FlagsForCommand("recipe keyword") { categories[flag.Names()[0]] = flag.(cli.CategorizableFlag).GetCategory() } - s.Empty(categories["keywords.schema_file"]) - s.Empty(categories["keywords.ignore"]) + s.Empty(categories["schema-file"]) + s.Empty(categories["keep"]) +} + +func (s *ConfigSuite) TestOnlyExplicitFlagsAreGenerated() { + s.Equal( + []string{"tandoor-url", "ollama-url", "start-ollama"}, + flagNames(Flags()), + ) + s.Equal( + []string{"format", "cookies-file", "cookies-from-browser", "impersonate", "yt-dlp-path", "yt-dlp-arg", "max-duration"}, + flagNames(FlagsForCommand("import")), + ) + s.Equal( + []string{"schema-file", "keep"}, + flagNames(FlagsForCommand("recipe keyword")), + ) +} + +func (s *ConfigSuite) TestGeneratedDefaultTextDoesNotSetFlagValue() { + var maxDuration *cli.IntFlag + for _, flag := range FlagsForCommand("import") { + if flag.Names()[0] == "max-duration" { + maxDuration = flag.(*cli.IntFlag) + } + } + + s.Require().NotNil(maxDuration) + s.Equal("30", maxDuration.DefaultText) + s.Zero(maxDuration.Value) +} + +func flagNames(flags []cli.Flag) []string { + names := make([]string, 0, len(flags)) + for _, flag := range flags { + names = append(names, flag.Names()[0]) + } + + return names } func (s *ConfigSuite) TestConfigFileOverridesDefaults() { @@ -122,7 +160,7 @@ base_url = "https://from-file.example" func (s *ConfigSuite) TestFlagOverridesEnv() { s.T().Setenv("MISE_TANDOOR_BASE_URL", "https://from-env.example") - cfg := s.load("--tandoor.base-url", "https://from-flag.example") + cfg := s.load("--tandoor-url", "https://from-flag.example") s.Equal("https://from-flag.example", cfg.Tandoor.BaseURL) } @@ -151,7 +189,7 @@ func (s *ConfigSuite) TestFlagOptOutStillLoadsFromEnv() { s.Equal("/tmp/from-env", cfg.Tandoor.BackupDir) for _, flag := range Flags() { - s.NotContains(flag.Names(), "tandoor.backup-dir") + s.NotContains(flag.Names(), "backup-dir") } } @@ -163,7 +201,7 @@ func (s *ConfigSuite) TestModelFlagOptOutStillLoadsFromEnv() { s.Equal("ollama/from-env", cfg.Models.Small) for _, flag := range Flags() { - s.NotContains(flag.Names(), "models.small") + s.NotContains(flag.Names(), "small-model") } } @@ -171,21 +209,27 @@ func TestConfigSuite(t *testing.T) { suite.Run(t, new(ConfigSuite)) } -func (s *ConfigSuite) TestKeywordsIgnoreFlagAcceptsMultipleValues() { - cfg := s.loadWithFlags(FlagsForCommand("recipe keyword"), "--keywords.ignore", "Uncategorized", "--keywords.ignore", "Basics") +func (s *ConfigSuite) TestKeywordsKeepFlagAcceptsMultipleValues() { + cfg := s.loadWithFlags(FlagsForCommand("recipe keyword"), "--keep", "Uncategorized,Basics") + + s.Equal([]string{"Uncategorized", "Basics"}, cfg.Keywords.Keep) +} + +func (s *ConfigSuite) TestYtdlpArgFlagAcceptsMultipleValues() { + cfg := s.loadWithFlags(FlagsForCommand("import"), "--yt-dlp-arg=--no-playlist,--write-info-json") - s.Equal([]string{"Uncategorized", "Basics"}, cfg.Keywords.Ignore) + s.Equal([]string{"--no-playlist", "--write-info-json"}, cfg.Video.YtdlpArgs) } -func (s *ConfigSuite) TestKeywordsIgnoreFromConfigFile() { +func (s *ConfigSuite) TestKeywordsKeepFromConfigFile() { s.writeConfigFile(` [keywords] -ignore = ["Uncategorized", "Basics"] +keep = ["Uncategorized", "Basics"] `) cfg := s.load() - s.Equal([]string{"Uncategorized", "Basics"}, cfg.Keywords.Ignore) + s.Equal([]string{"Uncategorized", "Basics"}, cfg.Keywords.Keep) } func (s *ConfigSuite) TestKeywordsSchemaFileDefault() { diff --git a/internal/config/flags.go b/internal/config/flags.go index dba0734..815725c 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -1,7 +1,9 @@ package config import ( + "fmt" "reflect" + "strings" "github.com/urfave/cli/v3" ) @@ -18,7 +20,7 @@ func flagsFor(command string) []cli.Flag { var flags []cli.Flag walkSchema(reflect.TypeFor[Config](), "", func(f schemaField) { - if !f.Flag || f.Command != command { + if f.FlagName == "" || f.Command != command { return } @@ -29,14 +31,57 @@ func flagsFor(command string) []cli.Flag { } func newFlag(f schemaField) cli.Flag { + defaultText := flagDefaultText(f.Key) + var aliases []string + if f.Alias != "" { + aliases = []string{f.Alias} + } + switch f.Type { case fieldTypeStrings: - return &cli.StringSliceFlag{Name: f.FlagName, Usage: f.Usage, Category: f.Category} + return &stringSliceFlag{StringSliceFlag: &cli.StringSliceFlag{ + Name: f.FlagName, Aliases: aliases, Usage: f.Usage, Category: f.Category, + DefaultText: defaultText, OnlyOnce: true, + }} case fieldTypeInt: - return &cli.IntFlag{Name: f.FlagName, Usage: f.Usage, Category: f.Category} + return &cli.IntFlag{Name: f.FlagName, Aliases: aliases, Usage: f.Usage, Category: f.Category, DefaultText: defaultText} case fieldTypeBool: - return &cli.BoolFlag{Name: f.FlagName, Usage: f.Usage, Category: f.Category} + return &cli.BoolFlag{Name: f.FlagName, Aliases: aliases, Usage: f.Usage, Category: f.Category, DefaultText: defaultText} default: - return &cli.StringFlag{Name: f.FlagName, Usage: f.Usage, Category: f.Category} + return &cli.StringFlag{Name: f.FlagName, Aliases: aliases, Usage: f.Usage, Category: f.Category, DefaultText: defaultText} + } +} + +// stringSliceFlag accepts a comma-separated list in one occurrence. urfave/cli +// otherwise presents every slice as a repeatable multi-value flag in help. +type stringSliceFlag struct { + *cli.StringSliceFlag +} + +func (f *stringSliceFlag) String() string { + return cli.FlagStringer(f) +} + +func (f *stringSliceFlag) IsMultiValueFlag() bool { + return false +} + +func flagDefaultText(key string) string { + value := reflect.ValueOf(defaultConfig) + + for _, part := range strings.Split(key, ".") { + typeOfValue := value.Type() + for i := range typeOfValue.NumField() { + if typeOfValue.Field(i).Tag.Get("key") == part { + value = value.Field(i) + break + } + } + } + + if value.Kind() == reflect.Slice && value.Len() == 0 { + return "" } + + return fmt.Sprint(value.Interface()) } diff --git a/internal/config/load.go b/internal/config/load.go index ca380e3..3762487 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -64,7 +64,7 @@ func flagValues(cmd *cli.Command) map[string]any { values := map[string]any{} walkSchema(reflect.TypeFor[Config](), "", func(f schemaField) { - if f.Flag && cmd.IsSet(f.FlagName) { + if f.FlagName != "" && cmd.IsSet(f.FlagName) { values[f.Key] = flagValue(cmd, f) } }) diff --git a/internal/config/schema.go b/internal/config/schema.go index b85d999..39004ea 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -1,9 +1,6 @@ package config -import ( - "reflect" - "strings" -) +import "reflect" type fieldType int @@ -17,26 +14,23 @@ const ( type schemaField struct { Key string FlagName string + Alias string Usage string Category string Command string Type fieldType - - // Flag determines whether this field is settable via command line flag. Fields with - // `flag:"-"` are ignored and only settable via config file or environment variable. - Flag bool } func walkSchema(t reflect.Type, prefix string, visit func(schemaField)) { - walkFields(t, prefix, "", "", true, visit) + walkFields(t, prefix, "", "", visit) } -func walkFields(t reflect.Type, prefix, category, command string, flag bool, visit func(schemaField)) { +func walkFields(t reflect.Type, prefix, category, command string, visit func(schemaField)) { for field := range t.Fields() { name := field.Tag.Get("key") if name == "" { if field.Anonymous && field.Type.Kind() == reflect.Struct { - walkFields(field.Type, prefix, category, command, flag, visit) + walkFields(field.Type, prefix, category, command, visit) } continue } @@ -46,8 +40,6 @@ func walkFields(t reflect.Type, prefix, category, command string, flag bool, vis key = prefix + "." + name } - enabled := flag && field.Tag.Get("flag") != "-" - fieldCategory := category if taggedCategory := field.Tag.Get("category"); taggedCategory != "" { fieldCategory = taggedCategory @@ -59,18 +51,20 @@ func walkFields(t reflect.Type, prefix, category, command string, flag bool, vis } if field.Type.Kind() == reflect.Struct { - walkFields(field.Type, key, fieldCategory, fieldCommand, enabled, visit) + walkFields(field.Type, key, fieldCategory, fieldCommand, visit) continue } + flagName := field.Tag.Get("flag") + visit(schemaField{ Key: key, - FlagName: strings.ReplaceAll(key, "_", "-"), + FlagName: flagName, + Alias: field.Tag.Get("alias"), Usage: field.Tag.Get("usage"), Category: fieldCategory, Command: fieldCommand, Type: fieldTypeOf(field.Type), - Flag: enabled, }) } } diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go index b3f11fb..87d5ff2 100644 --- a/internal/config/schema_test.go +++ b/internal/config/schema_test.go @@ -7,28 +7,24 @@ import ( "github.com/stretchr/testify/assert" ) -func TestWalkSchemaFlagOptOut(t *testing.T) { +func TestWalkSchemaFlagsAreOptIn(t *testing.T) { type leaf struct { - Plain string `key:"plain"` - Opaque string `key:"opaque" flag:"-"` + Plain string `key:"plain"` + Exposed string `key:"exposed" flag:"public-name"` } type root struct { - Visible leaf `key:"visible"` - Hidden leaf `key:"hidden" flag:"-"` + Section leaf `key:"section"` } - flags := map[string]bool{} + flags := map[string]string{} walkSchema(reflect.TypeFor[root](), "", func(f schemaField) { - flags[f.Key] = f.Flag + if f.FlagName != "" { + flags[f.Key] = f.FlagName + } }) - assert.Equal(t, map[string]bool{ - "visible.plain": true, - "visible.opaque": false, - "hidden.plain": false, - "hidden.opaque": false, - }, flags) + assert.Equal(t, map[string]string{"section.exposed": "public-name"}, flags) } func TestWalkSchemaSkipsUntaggedFields(t *testing.T) { diff --git a/internal/config/section/backup.go b/internal/config/section/backup.go index 7014f48..91666f9 100644 --- a/internal/config/section/backup.go +++ b/internal/config/section/backup.go @@ -1,5 +1,5 @@ package section type BackupConfig struct { - Keep int `key:"keep" flag:"-" usage:"Number of backups to keep per recipe (0 = keep all)"` + Keep int `key:"keep" usage:"Number of backups to keep per recipe (0 = keep all)"` } diff --git a/internal/config/section/keywords.go b/internal/config/section/keywords.go index 26a7869..ab212ee 100644 --- a/internal/config/section/keywords.go +++ b/internal/config/section/keywords.go @@ -1,6 +1,6 @@ package section type KeywordsConfig struct { - SchemaFile string `key:"schema_file" usage:"Path to the file describing your keyword schema"` - Ignore []string `key:"ignore" usage:"Keywords to protect from removal when using recipe keyword --replace"` + SchemaFile string `key:"schema_file" flag:"schema-file" usage:"Path to the file describing your keyword schema"` + Keep []string `key:"keep" flag:"keep" alias:"k" usage:"Comma-separated keywords to preserve when using recipe keyword --replace"` } diff --git a/internal/config/section/models.go b/internal/config/section/models.go index be14235..7faa02f 100644 --- a/internal/config/section/models.go +++ b/internal/config/section/models.go @@ -8,8 +8,8 @@ const ( ) type ModelsConfig struct { - Small string `key:"small" flag:"-" usage:"Model for simpler tasks, as provider/model"` - Large string `key:"large" flag:"-" usage:"Model for harder tasks, as provider/model"` + Small string `key:"small" usage:"Model for simpler tasks, as provider/model"` + Large string `key:"large" usage:"Model for harder tasks, as provider/model"` } func (m ModelsConfig) Get(size ModelSize) string { diff --git a/internal/config/section/providers.go b/internal/config/section/providers.go index 3648416..9e8397a 100644 --- a/internal/config/section/providers.go +++ b/internal/config/section/providers.go @@ -15,9 +15,9 @@ type ProviderSettings interface { } type OllamaConfig struct { - BaseURL string `key:"base_url" usage:"Base URL for the Ollama API, e.g. http://localhost:11434"` - Timeout int `key:"timeout" flag:"-" usage:"Seconds to wait for a response from Ollama"` - Autostart bool `key:"autostart" usage:"Start Ollama automatically if not running, requires Ollama to be installed and in PATH"` + BaseURL string `key:"base_url" flag:"ollama-url" usage:"Base URL for the Ollama API, e.g. http://localhost:11434"` + Timeout int `key:"timeout" usage:"Seconds to wait for a response from Ollama"` + Autostart bool `key:"autostart" flag:"start-ollama" usage:"Start Ollama automatically if not running, requires Ollama to be installed and in PATH"` } func (o *OllamaConfig) Common() ProviderConfig { diff --git a/internal/config/section/tandoor.go b/internal/config/section/tandoor.go index 7fa0a48..ef18868 100644 --- a/internal/config/section/tandoor.go +++ b/internal/config/section/tandoor.go @@ -2,6 +2,6 @@ package section type TandoorConfig struct { Token string `key:"token" usage:"Tandoor API token"` - BaseURL string `key:"base_url" usage:"Tandoor base URL"` - BackupDir string `key:"backup_dir" flag:"-" usage:"Directory to store recipe backups"` + BaseURL string `key:"base_url" flag:"tandoor-url" usage:"Tandoor base URL"` + BackupDir string `key:"backup_dir" usage:"Directory to store recipe backups"` } diff --git a/internal/config/section/video.go b/internal/config/section/video.go index 4564864..7d3830f 100644 --- a/internal/config/section/video.go +++ b/internal/config/section/video.go @@ -1,11 +1,11 @@ package section type VideoConfig struct { - Format string `key:"format" usage:"yt-dlp format selector"` - CookiesFile string `key:"cookies_file" usage:"Path to a Netscape-format cookie jar for login-walled videos"` - CookiesFromBrowser string `key:"cookies_from_browser" usage:"Browser to pull cookies from, e.g. firefox"` - Impersonate string `key:"impersonate" usage:"TLS client to impersonate, e.g. chrome. Often required for Instagram"` - YtdlpPath string `key:"ytdlp_path" usage:"Path to a yt-dlp binary. Empty resolves from PATH, downloading one if absent"` - YtdlpArgs []string `key:"ytdlp_args" usage:"Extra arguments passed through to yt-dlp"` - MaxDurationMinutes int `key:"max_duration_minutes" usage:"Refuse videos longer than this. 0 disables the check"` + Format string `key:"format" flag:"format" category:"DOWNLOAD OPTIONS" usage:"yt-dlp format selector"` + CookiesFile string `key:"cookies_file" flag:"cookies-file" category:"DOWNLOAD OPTIONS" usage:"Path to a Netscape-format cookie jar for login-walled videos"` + CookiesFromBrowser string `key:"cookies_from_browser" flag:"cookies-from-browser" category:"DOWNLOAD OPTIONS" usage:"Browser to pull cookies from, e.g. firefox"` + Impersonate string `key:"impersonate" flag:"impersonate" category:"DOWNLOAD OPTIONS" usage:"TLS client to impersonate, e.g. chrome. Often required for Instagram"` + YtdlpPath string `key:"ytdlp_path" flag:"yt-dlp-path" category:"TOOL OPTIONS" usage:"Path to a yt-dlp binary. Empty resolves from PATH, downloading one if absent"` + YtdlpArgs []string `key:"ytdlp_args" flag:"yt-dlp-arg" category:"TOOL OPTIONS" usage:"Comma-separated extra arguments passed through to yt-dlp"` + MaxDurationMinutes int `key:"max_duration_minutes" flag:"max-duration" category:"DOWNLOAD OPTIONS" usage:"Refuse videos longer than this many minutes. 0 disables the check"` }