diff --git a/README.md b/README.md index 42c730e..8037ac2 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,4 @@ # cfgraft -A small Unix-style TUI and CLI for safely syncing files and directories from Git repositories into local configuration paths. +A small Unix-style CLI for safely syncing files and directories from Git repositories into local configuration paths, with an explicit `tui` subcommand for interactive configuration. diff --git a/docs/USAGE.md b/docs/USAGE.md index e992260..52c075d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -36,6 +36,36 @@ Each configured source gets its own state file named after the source ID, such a Older `~/.config/cfgraft/state.yaml` files are still read for compatibility and are removed after the next successful split-state write. Do not edit state files by hand. Hashes are intentionally kept out of `config.yaml` so the config remains the user-owned source of truth. +## Commands + +Run `cfgraft` without a subcommand to show top-level help, including available subcommands and global environment flags. + +```text +cfgraft +``` + +Each subcommand also supports `--help`: + +```text +cfgraft tui --help +cfgraft sync --help +cfgraft diff --help +cfgraft version --help +``` + +Use `tui` for interactive configuration: + +```text +cfgraft tui +``` + +Use `sync` and `diff` for scripting and non-interactive workflows: + +```text +cfgraft sync --dry-run +cfgraft diff --verbose +``` + ## Configuration Example: @@ -152,7 +182,7 @@ If state entries remain for mappings that are no longer referenced by active con ## TUI -Running `cfgraft` without a subcommand launches a Bubble Tea terminal UI for managing `config.yaml` and running targeted sync operations. +Run `cfgraft tui` to launch a Bubble Tea terminal UI for managing `config.yaml` and running targeted sync operations. The TUI uses Bubble Tea v2 with an ASCII `cfgraft` header, contextual breadcrumbs, Bubbles list/table/text-input components, bottom action buttons, hover highlighting, styled selections, and colored messages/errors. It supports: diff --git a/internal/cfgraft/app_test.go b/internal/cfgraft/app_test.go index 3f16777..d66429e 100644 --- a/internal/cfgraft/app_test.go +++ b/internal/cfgraft/app_test.go @@ -41,6 +41,74 @@ func TestSourceIDDerivedFromRepoURL(t *testing.T) { } } +func TestRunWithoutSubcommandPrintsRootHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + if err := Run(nil, &stdout, &stderr, "dev"); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if stderr.Len() != 0 { + t.Fatalf("expected no stderr, got %q", stderr.String()) + } + out := stdout.String() + for _, want := range []string{ + "Usage:", + "cfgraft show this help", + "cfgraft tui launch the interactive TUI", + "cfgraft sync [flags]", + "cfgraft diff [flags]", + } { + if !strings.Contains(out, want) { + t.Fatalf("expected help to contain %q, got:\n%s", want, out) + } + } +} + +func TestSubcommandHelp(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + { + name: "tui", + args: []string{"tui", "--help"}, + want: []string{"Usage:", "cfgraft tui", "interactive cfgraft terminal UI"}, + }, + { + name: "sync", + args: []string{"sync", "--help"}, + want: []string{"Usage:", "cfgraft sync [flags]", "--dry-run", "--interactive", "--force", "--verbose"}, + }, + { + name: "diff", + args: []string{"diff", "--help"}, + want: []string{"Usage:", "cfgraft diff [flags]", "--verbose"}, + }, + { + name: "version", + args: []string{"version", "--help"}, + want: []string{"Usage:", "cfgraft version", "cfgraft --version", "cfgraft -v"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if err := Run(tt.args, &stdout, &stderr, "dev"); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if stderr.Len() != 0 { + t.Fatalf("expected no stderr, got %q", stderr.String()) + } + out := stdout.String() + for _, want := range tt.want { + if !strings.Contains(out, want) { + t.Fatalf("expected help to contain %q, got:\n%s", want, out) + } + } + }) + } +} + func TestTUIMouseHoverAndClickRegions(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/cfgraft/cli.go b/internal/cfgraft/cli.go index 11f94ad..8012528 100644 --- a/internal/cfgraft/cli.go +++ b/internal/cfgraft/cli.go @@ -24,10 +24,21 @@ func VersionString(version string) string { func Run(args []string, stdout, stderr io.Writer, version string) error { if len(args) == 0 { - return runTUI() + printHelp(stdout) + return nil } switch args[0] { + case "tui": + if wantsHelp(args[1:]) { + printTUIHelp(stdout) + return nil + } + return runTUI() case "sync": + if wantsHelp(args[1:]) { + printSyncHelp(stdout) + return nil + } fs := flag.NewFlagSet("sync", flag.ContinueOnError) fs.SetOutput(stderr) opts := SyncOptions{Refresh: true} @@ -40,6 +51,10 @@ func Run(args []string, stdout, stderr io.Writer, version string) error { } return syncCommand(opts, stdout) case "diff": + if wantsHelp(args[1:]) { + printDiffHelp(stdout) + return nil + } fs := flag.NewFlagSet("diff", flag.ContinueOnError) fs.SetOutput(stderr) verbose := fs.Bool("verbose", false, "show safety information") @@ -47,7 +62,14 @@ func Run(args []string, stdout, stderr io.Writer, version string) error { return err } return diffCommand(*verbose, stdout) - case "version", "--version", "-v": + case "version": + if wantsHelp(args[1:]) { + printVersionHelp(stdout) + return nil + } + fmt.Fprintln(stdout, VersionString(version)) + return nil + case "--version", "-v": fmt.Fprintln(stdout, VersionString(version)) return nil case "help", "--help", "-h": @@ -58,11 +80,21 @@ func Run(args []string, stdout, stderr io.Writer, version string) error { } } +func wantsHelp(args []string) bool { + for _, arg := range args { + if arg == "--help" || arg == "-h" || arg == "help" { + return true + } + } + return false +} + func printHelp(w io.Writer) { fmt.Fprintln(w, `cfgraft safely synchronizes files from Git repositories. Usage: - cfgraft launch the interactive TUI + cfgraft show this help + cfgraft tui launch the interactive TUI cfgraft sync [flags] refresh repositories and synchronize mappings cfgraft diff [flags] compare cached sources with destinations cfgraft version print version @@ -77,8 +109,59 @@ Environment: NO_COLOR disable colored output Examples: - cfgraft + cfgraft tui + cfgraft sync --dry-run + cfgraft sync --interactive + cfgraft diff --verbose`) +} + +func printTUIHelp(w io.Writer) { + fmt.Fprintln(w, `Launch the interactive cfgraft terminal UI. + +Usage: + cfgraft tui + +The TUI manages config.yaml sources and mappings, and can run sync or diff +operations for all sources or a selected source.`) +} + +func printSyncHelp(w io.Writer) { + fmt.Fprintln(w, `Refresh repositories and synchronize configured mappings. + +Usage: + cfgraft sync [flags] + +Flags: + --force overwrite conflicts with repository content + --interactive prompt for each conflict + --dry-run plan changes without writing destinations or state + --verbose show no-op and extra detail + +Examples: cfgraft sync --dry-run cfgraft sync --interactive + cfgraft sync --force --verbose`) +} + +func printDiffHelp(w io.Writer) { + fmt.Fprintln(w, `Compare cached source content with local destinations. + +Usage: + cfgraft diff [flags] + +Flags: + --verbose show safety information for conflicts + +Examples: + cfgraft diff cfgraft diff --verbose`) } + +func printVersionHelp(w io.Writer) { + fmt.Fprintln(w, `Print cfgraft version information. + +Usage: + cfgraft version + cfgraft --version + cfgraft -v`) +}