package main import ( "fmt" "io" "os" "strings" "github.com/spf13/cobra" "netdome.biz/paul/qmk-rgb/internal/device" ) // version is set at build time via -ldflags. var version = "dev" // Group IDs for the root help. They name a group in the help output and say // nothing else: no behaviour hangs off them. const ( groupLighting = "lighting" groupProfile = "profile" groupKeyboard = "keyboard" ) var targetDevice string // zoneSelection is the rule every command that writes lighting carries in its own // help. It is repeated per command rather than left to the root help because a // subcommand's --help does not print the root's, and this is the one thing a user // has to know before running one of them. const zoneSelection = "The zone is a VIA lighting channel: backlight, rgblight, rgb_matrix, audio or\n" + "led_matrix, or the name this keyboard's definition gives it. Several are written\n" + "comma separated and the word all means every channel the keyboard reports." // writesLighting is the help of a command that writes to the keyboard: what the // command does, then the one rule they share. func writesLighting(long string) string { return long + "\n\n" + zoneSelection } func newRootCommand() *cobra.Command { cmd := &cobra.Command{ Use: "qmk-rgb-tool", Short: "QMK RGB CLI — control QMK keyboard lighting", SilenceErrors: true, SilenceUsage: true, } // The commands are grouped because fifteen of them in one flat list is where // a user starts reading the help to find a command and gives up. A group says // what a command is for, and a command in none of them lands under // "Additional Commands" where the grouping says nothing. cmd.AddGroup( &cobra.Group{ID: groupLighting, Title: "Lighting Commands:"}, &cobra.Group{ID: groupProfile, Title: "Profile Commands:"}, &cobra.Group{ID: groupKeyboard, Title: "Keyboard Commands:"}, ) cmd.PersistentFlags().StringVar(&targetDevice, "device", "", "Keyboard number as printed by 'keyboard info', starting at 1") cmd.PersistentFlags().StringVar(&definitionFlag, "definition", "", "VIA definition file to read effect names from, instead of the one in the data directory") cmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Print JSON instead of text") return cmd } var rootCmd = newRootCommand() func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } func main() { Execute() } // keyboardCmd is the namespace for everything that is about one board rather // than about its lighting: which boards are connected, and the definition file // whose effect names belong to a board. The definition prose lives here because // there is no `definition` command of its own any more. var keyboardCmd = &cobra.Command{ Use: "keyboard", Short: "Keyboard management commands", GroupID: groupKeyboard, Long: "A keyboard's effect names come from a VIA definition file, the same one VIA\n" + "itself uses. `fetch` downloads the file for the connected keyboard into the\n" + "data directory; to use a file you already have, put it in that directory or\n" + "pass it with --definition.", } // inGroup places a command in one of the root help's groups. func inGroup(cmd *cobra.Command, group string) *cobra.Command { cmd.GroupID = group return cmd } // discoverAll is a seam for tests. var discoverAll = device.DiscoverAll // runKeyboardInfo writes the connected QMK keyboards, as text unless --json // asks otherwise. The 1-based index that --device accepts is in the "index" // field, so the machine shape emits no banner alongside it. Whether a keyboard // has effect names is reported per keyboard, because it differs: one board may // have a definition and the next may not. func runKeyboardInfo(out io.Writer) error { devices, err := discoverAll() if err != nil { return err } if devices == nil { devices = []device.Device{} } lines := describeDevices(devices) if !jsonOutput { return printKeyboardInfoText(out, lines) } type info struct { Devices []keyboardLine `json:"devices"` Total int `json:"total"` } return encodeJSON(out, info{Devices: lines, Total: len(lines)}) } var keyboardInfoCmd = &cobra.Command{ Use: "info", Short: "Discover connected QMK keyboards", Long: "Scan for connected QMK keyboards and print device info as JSON.\n" + "Each device carries the 1-based index that --device accepts.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runKeyboardInfo(cmd.OutOrStdout()) }, } func init() { registerCommands(rootCmd) registerFlagCompletions(rootCmd) rootCmd.Version = version rootCmd.SetVersionTemplate(" qmk-rgb-tool {{.Version}}\n") } // registerCommands adds every command to the root, each in the group the help // output lists it under. It is a function of its own so that a test can build the // same tree the binary runs and check the grouping is real, rather than repeating // a list that could fall behind the one that ships. func registerCommands(root *cobra.Command) { keyboardCmd.AddCommand( keyboardInfoCmd, NewKeyboardFetchCmd(), NewKeyboardDefinitionsCmd(), ) // The zone is a positional argument, not a flag. A flag every help lists told // `keyboard info` and `list` about a channel they have none of, and it was // ignored there rather than refused. A positional argument is spelled only // where it is read, so there is nothing to ignore. lighting := []*cobra.Command{ inGroup(NewEnableCmd(), groupLighting), inGroup(NewDisableCmd(), groupLighting), inGroup(NewInfoCmd(), groupLighting), inGroup(NewEffectCmd(), groupLighting), inGroup(NewBrightnessCmd(), groupLighting), inGroup(NewSpeedCmd(), groupLighting), inGroup(NewColorCmd(), groupLighting), } profiles := []*cobra.Command{ inGroup(NewProfileSaveCmd(), groupProfile), inGroup(NewProfileLoadCmd(), groupProfile), } rest := []*cobra.Command{ inGroup(NewProfileListCmd(), groupProfile), inGroup(NewProfileDeleteCmd(), groupProfile), keyboardCmd, } commands := make([]*cobra.Command, 0, len(lighting)+len(profiles)+len(rest)) commands = append(commands, lighting...) commands = append(commands, profiles...) commands = append(commands, rest...) root.AddCommand(commands...) } // zoneArgs is the argument count of a command that reads a zone, together with // the message a user gets when the count is wrong. Cobra's own "accepts 2 arg(s), // received 1" does not say what the argument should have been, and a command that // writes lighting now fails at its arity rather than at a choice of channel, so // the usage line is the only help there is. func zoneArgs(min, max int, what string) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) < min || len(args) > max { // cmd.Use already starts with the command's own name, so the path is // the name and what follows it, not the name twice. return fmt.Errorf("%s takes %s\nusage: %s%s", cmd.Name(), what, cmd.CommandPath(), strings.TrimPrefix(cmd.Use, cmd.Name())) } return nil } } // withZoneArgs declares a command's zone argument count, its usage line and the // completion for it, in one call. A command that names a channel and does not // call this is a command the shell knows nothing about, so the three belong // together: the count is what makes the zone required, the usage line is where // the vocabulary is documented, and the completion is how a user finds the names // a board supplies. // // A command that takes a zone and something else first — `load` takes a profile // name — cannot use this, because the zone is not what its first argument is. It // declares the count and its own completion instead. func withZoneArgs(cmd *cobra.Command, min, max int, what string) *cobra.Command { cmd.Args = zoneArgs(min, max, what) cmd.ValidArgsFunction = completeZoneArgs return cmd }