Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
32 changes: 31 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:

Expand Down
68 changes: 68 additions & 0 deletions internal/cfgraft/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
91 changes: 87 additions & 4 deletions internal/cfgraft/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -40,14 +51,25 @@ 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")
if err := fs.Parse(args[1:]); err != nil {
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":
Expand All @@ -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
Expand All @@ -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`)
}
Loading