From c927daf06683fa0cbbf62195f041f04527cb83f2 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Wed, 20 May 2026 14:58:52 -0600 Subject: [PATCH] feat: compose new cheats in the commandline --- cmd/cheatmd/compose.go | 141 +++++++++++++++++++++++++++++++++++++ cmd/cheatmd/root.go | 1 + internal/ui/resolve.go | 4 +- pkg/parser/compose.go | 44 ++++++++++++ pkg/parser/compose_test.go | 54 ++++++++++++++ 5 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 cmd/cheatmd/compose.go create mode 100644 pkg/parser/compose.go create mode 100644 pkg/parser/compose_test.go diff --git a/cmd/cheatmd/compose.go b/cmd/cheatmd/compose.go new file mode 100644 index 0000000..5905694 --- /dev/null +++ b/cmd/cheatmd/compose.go @@ -0,0 +1,141 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/gubarz/cheatmd/pkg/config" + "github.com/gubarz/cheatmd/pkg/parser" +) + +var composeCmd = &cobra.Command{ + Use: "compose [command]", + Short: "Compose a new cheat template from a raw command line", + Long: `Quickly templatize a shell command you just used into a CheatMD snippet. +It automatically extracts any $var or variables and writes a complete cheat block. + +Examples: + cheatmd compose -n "My Cheat" "curl -X POST -H 'Auth: '" + echo "ssh -p $port $user@$host" | cheatmd compose -f ~/cheats.md`, + RunE: runCompose, +} + +func init() { + composeCmd.Flags().StringP("name", "n", "Snippet", "Name of the cheat") + composeCmd.Flags().StringP("description", "d", "", "Description of the cheat") + composeCmd.Flags().StringP("file", "f", "", "File to save to (defaults to first cheat path + /snippets.md)") + composeCmd.Flags().BoolP("print", "p", false, "Print to stdout instead of saving to a file") +} + +func runCompose(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + desc, _ := cmd.Flags().GetString("description") + file, _ := cmd.Flags().GetString("file") + printOnly, _ := cmd.Flags().GetBool("print") + + var command string + if len(args) > 0 { + command = strings.Join(args, " ") + } else { + // Read from stdin + stat, err := os.Stdin.Stat() + if err != nil { + return err + } + if (stat.Mode() & os.ModeCharDevice) != 0 { + return fmt.Errorf("no command provided and no input piped. Use `cheatmd compose \"command\"`") + } + b, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + command = strings.TrimSpace(string(b)) + } + + if command == "" { + return fmt.Errorf("command cannot be empty") + } + + // Extract variables + vars := parser.ExtractVars(command) + + // Build markdown + var sb strings.Builder + sb.WriteString("\n# ") + sb.WriteString(name) + sb.WriteString("\n\n") + + if desc != "" { + sb.WriteString(desc) + sb.WriteString("\n\n") + } + + if len(vars) > 0 { + sb.WriteString("\n\n") + } + + sb.WriteString("```sh\n") + sb.WriteString(command) + sb.WriteString("\n```\n") + + outStr := sb.String() + + if printOnly { + fmt.Fprint(cmd.OutOrStdout(), strings.TrimPrefix(outStr, "\n")) + return nil + } + + // Determine output file + targetFile := file + if targetFile == "" { + // Use default + cfgPath := config.GetPath() + if cfgPath == "" { + return fmt.Errorf("no cheat path configured, please use -f or set cheatmd path") + } + firstPath := strings.Split(cfgPath, ",")[0] + stat, err := os.Stat(firstPath) + if err != nil { + // Path does not exist, assume it's a directory we should create? + // To be safe, let's check if it ends in .md + if strings.HasSuffix(strings.ToLower(firstPath), ".md") { + targetFile = firstPath + } else { + targetFile = filepath.Join(firstPath, "snippets.md") + } + } else if stat.IsDir() { + targetFile = filepath.Join(firstPath, "snippets.md") + } else { + targetFile = firstPath + } + } + + // Ensure directory exists + targetDir := filepath.Dir(targetFile) + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("failed to create directory for snippets: %w", err) + } + + // Append to file + f, err := os.OpenFile(targetFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open file %s: %w", targetFile, err) + } + defer f.Close() + + if _, err := f.WriteString(outStr); err != nil { + return fmt.Errorf("failed to write to file %s: %w", targetFile, err) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "✓ Cheat '%s' appended to %s\n", name, targetFile) + return nil +} diff --git a/cmd/cheatmd/root.go b/cmd/cheatmd/root.go index 18a2e17..f91c6a2 100644 --- a/cmd/cheatmd/root.go +++ b/cmd/cheatmd/root.go @@ -32,6 +32,7 @@ func init() { rootCmd.AddCommand(widgetCmd) rootCmd.AddCommand(chainCmd) rootCmd.AddCommand(dumpCmd) + rootCmd.AddCommand(composeCmd) chainCmd.AddCommand(chainResetCmd) rootCmd.PersistentFlags().StringP("query", "q", "", "Initial search query") diff --git a/internal/ui/resolve.go b/internal/ui/resolve.go index a1bce2a..531a99a 100644 --- a/internal/ui/resolve.go +++ b/internal/ui/resolve.go @@ -51,8 +51,8 @@ func getDisplayColumn(line, delimiter string, column int) string { // varState tracks a variable and its resolved value type varState struct { - def parser.VarDef // The selected/active definition - variants []parser.VarDef // All conditional variants (for if/fi blocks) + def parser.VarDef // The selected/active definition + variants []parser.VarDef // All conditional variants (for if/fi blocks) value string resolved bool prefill string diff --git a/pkg/parser/compose.go b/pkg/parser/compose.go new file mode 100644 index 0000000..f65edef --- /dev/null +++ b/pkg/parser/compose.go @@ -0,0 +1,44 @@ +package parser + +import ( + "regexp" +) + +var ( + dollarVarRegex = regexp.MustCompile(`\$([a-zA-Z_][a-zA-Z0-9_]*)`) + angleVarRegex = regexp.MustCompile(`<([a-zA-Z_][a-zA-Z0-9_]*)>`) +) + +// ExtractVars finds all variables in a command string using both dollar ($var) +// and angle bracket () syntaxes. It returns a deduplicated list of +// variable names. +func ExtractVars(command string) []string { + varMap := make(map[string]bool) + var vars []string + + // Find dollar variables + dollarMatches := dollarVarRegex.FindAllStringSubmatch(command, -1) + for _, match := range dollarMatches { + if len(match) > 1 { + name := match[1] + if !varMap[name] { + varMap[name] = true + vars = append(vars, name) + } + } + } + + // Find angle bracket variables + angleMatches := angleVarRegex.FindAllStringSubmatch(command, -1) + for _, match := range angleMatches { + if len(match) > 1 { + name := match[1] + if !varMap[name] { + varMap[name] = true + vars = append(vars, name) + } + } + } + + return vars +} diff --git a/pkg/parser/compose_test.go b/pkg/parser/compose_test.go new file mode 100644 index 0000000..59341a9 --- /dev/null +++ b/pkg/parser/compose_test.go @@ -0,0 +1,54 @@ +package parser + +import ( + "reflect" + "testing" +) + +func TestExtractVars(t *testing.T) { + tests := []struct { + name string + command string + want []string + }{ + { + name: "dollar syntax", + command: "curl -X POST $url -H 'Auth: $token'", + want: []string{"url", "token"}, + }, + { + name: "angle syntax", + command: "nmap -p ", + want: []string{"port", "host"}, + }, + { + name: "mixed syntax", + command: "ssh $user@ -p $port", + want: []string{"user", "port", "host"}, + }, + { + name: "duplicates removed", + command: "echo $var $var2 $var", + want: []string{"var", "var2"}, + }, + { + name: "no variables", + command: "ls -la /tmp", + want: nil, + }, + { + name: "escaped variables", // the parser currently extracts it anyway, which is fine for compose + command: "echo \\$var", + want: []string{"var"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractVars(tt.command) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ExtractVars() = %v, want %v", got, tt.want) + } + }) + } +}