// SPDX-License-Identifier: GPL-3.0-or-later // Command krino sorts files in configured directories by rules. package main import ( "errors" "flag" "fmt" "io" "os" "runtime/debug" "strings" ) // version is stamped by the Makefile with -ldflags "-X main.version=...". var version = "dev" // displayVersion returns the version to print for --version. The Makefile // stamps a git tag, which carries a leading "v" (required for // go install ...@v0.0.1, per design.md §14); that "v" belongs on the tag, // not in the output, so it is stripped here. A "go install" build carries no // ldflags, leaving version at its built-in "dev"; in that case fall back to // the build info Go embeds automatically. func displayVersion() string { v := version if v == "dev" { if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { v = info.Main.Version } } if len(v) > 1 && v[0] == 'v' && v[1] >= '0' && v[1] <= '9' { v = v[1:] } return v } const usage = `usage: krino [-y | -n] [-v] [--json] [-c FILE] [NAME...] krino init krino new NAME PATH krino check [NAME...] krino explain FILE krino log [-n N] krino undo [RUN] Sort the files in the directories listed in krino.conf by their rules. -y apply without asking -n dry run: show the plan, change no file -v also list unmatched, ignored and busy files --json with -n: print the plan as JSON -c FILE use FILE instead of ~/.config/krino/krino.conf --no-color never colour the output, as when NO_COLOR is set -P, --no-pager print the plan straight out, never through the pager --min-age D for this run, skip files modified less than D ago (0, 30m, 1d) -h, --help show this help --version print the version ` // globals holds the flags that may appear before or after a subcommand. type globals struct { yes, dry, verbose, json bool noColor, noPager bool minAge string // --min-age as given minAgeSet bool // --min-age was given, even as an empty value conf string } // command is a subcommand: it gets the parsed globals and its own arguments. type command func(g *globals, args []string, stdout, stderr io.Writer) int // commands maps subcommand names to their functions; each cmd file adds itself. var commands = map[string]command{} func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } // run is main without the process exit, so tests can drive it. func run(args []string, stdout, stderr io.Writer) int { // Errors and warnings quote file names and tool messages: all of stderr // goes through display (spec §15.1). stderr = safeWriter{stderr} g := &globals{} fs := flagSet("krino", g) fs.BoolVar(&g.yes, "y", false, "") fs.BoolVar(&g.dry, "n", false, "") fs.BoolVar(&g.verbose, "v", false, "") fs.BoolVar(&g.json, "json", false, "") showVersion := fs.Bool("version", false, "") if code, ok := parse(fs, args, stdout, stderr); !ok { return code } if *showVersion { fmt.Fprintf(stdout, "krino %s\n", displayVersion()) return 0 } rest := fs.Args() if len(rest) > 0 { if cmd, ok := commands[rest[0]]; ok { return cmd(g, rest[1:], stdout, stderr) } } // Ruling 2026-09-12/4: Go's flag package stops parsing at the first // non-flag argument, so "krino dl -n" leaves "-n" in rest as a second // directory name instead of a flag - the dry run is silently never // honoured. A leftover argument that still looks like a flag is a // usage error rather than a guess; there is deliberately no second // pass that re-parses trailing flags, which would make "krino -- // -weird-dir" ambiguous between the two syntaxes. for _, a := range rest { if strings.HasPrefix(a, "-") { fmt.Fprintf(stderr, "krino: %s: flags must come before directory names\n", a) fmt.Fprintln(stderr, "run 'krino -h' for help") return 2 } } return cmdSort(g, rest, stdout, stderr) } // flagSet returns a silent flag set with -c, --no-color and -P/--no-pager // bound to g, shared by every command. Each defaults to the value already // parsed, so a flag given before a subcommand survives the subcommand's own // flag set. func flagSet(name string, g *globals) *flag.FlagSet { fs := flag.NewFlagSet(name, flag.ContinueOnError) fs.SetOutput(io.Discard) fs.StringVar(&g.conf, "c", g.conf, "") fs.BoolVar(&g.noColor, "no-color", g.noColor, "") fs.BoolVar(&g.noPager, "no-pager", g.noPager, "") fs.BoolVar(&g.noPager, "P", g.noPager, "") fs.Var(minAgeValue{g}, "min-age", "") return fs } // minAgeValue is --min-age as a flag.Value, so that a flag given with an // empty value (--min-age=) is told apart from one not given at all. type minAgeValue struct{ g *globals } func (v minAgeValue) String() string { if v.g == nil { return "" } return v.g.minAge } func (v minAgeValue) Set(s string) error { v.g.minAge, v.g.minAgeSet = s, true return nil } // parse parses args into fs. On -h it prints the usage and returns (0, false); // on a bad flag it reports it and returns (2, false). func parse(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) (int, bool) { err := fs.Parse(args) switch { case err == nil: return 0, true case errors.Is(err, flag.ErrHelp): fmt.Fprint(stdout, usage) return 0, false default: fmt.Fprintf(stderr, "krino: %v\n", err) fmt.Fprintln(stderr, "run 'krino -h' for help") return 2, false } }