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
6 changes: 6 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ archives:
- id: cdd
formats: ["tar.gz"]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
# Setting files replaces GoReleaser's default list, so name README and
# LICENSE too.
files:
- README.md
- LICENSE
- config.example.toml

checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ the hints, leaving the count and messages.
### Actions

An **Action** is a named command bound to a key and run on the selected
Project. Define one in `config.toml`:
Project. Define one in `config.toml` (`cdd config init` writes a starting
file with examples):

```toml
[actions.code]
Expand Down Expand Up @@ -285,7 +286,13 @@ directory as well as the name (see [Query syntax](#query-syntax)).

`cdd` reads `config.toml` from `$XDG_CONFIG_HOME/cdd/config.toml`, falling
back to `~/.config/cdd/config.toml` when `XDG_CONFIG_HOME` is unset. The
file is optional, and so is every key in it; these are the defaults:
file is optional, and so is every key in it. `cdd config init` writes a
commented example with every key and some Action recipes (it refuses to
replace an existing file unless you pass `--force`), `cdd config path`
prints where the file lives, and `cdd config edit` opens it in `$VISUAL`,
else `$EDITOR`, else `vi`, running `init` first if there is none. The same
file ships as `config.example.toml` in each release archive. These are the
defaults:

```toml
exclude = []
Expand Down
88 changes: 88 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# cdd config.toml, with every key and its default.
#
# Write this to the config path with `cdd config init`, and open it with
# `cdd config edit`. The file is optional, and so is every key in it.

# Glob patterns (filepath.Match semantics) for directories `cdd scan` skips,
# along with everything below them. A pattern containing "/" is matched
# against the directory's absolute path, any other against its name alone,
# as in .gitignore. A leading "~" is expanded to your home directory; "$VAR"
# is left as-is.
# exclude = ["node_modules", "~/go/pkg/*"]
exclude = []

# When false, `cdd scan` skips hidden directories.
include_hidden = false

[history]
# The maximum number of Visits kept in History. Must be at least 1.
max_visits = 1000

[keys]
# When true, the Picker opens with the list focused and uses vim-style keys:
# j/k move, f focuses the filter, esc returns to the list, ? opens a help
# overlay.
vim = false

[picker]
# Which layout the Picker draws. "list", a flat fzf-style list, is the only
# one for now. Any other value is a config error.
layout = "list"

# When true, the last line lists the keys of your Actions. false hides them,
# leaving the match count and messages.
hints = true

# Actions
#
# An Action is a named command bound to a key and run on the selected
# Project. Built-in Actions exist unless you override them by name:
#
# jump enter Jump to the Project
# files ctrl+o open the Project in the file manager (detached)
# editor ctrl+e open the Project in $VISUAL, then $EDITOR, then vi
# remote ctrl+g open the git remote's home page (detached)
# copy ctrl+y copy the Project's path to the clipboard
# forget ctrl+d forget the Project, after a y/n confirm
#
# Each [actions.<name>] table takes:
# key the Picker key that runs it ("ctrl+x", "alt+x", "f1", ...), or ""
# to unbind it. A binding displaces a built-in on the same key.
# esc and ctrl+c can never be bound. A plain printable key (a, ?)
# is an error unless keys.vim = true.
# run a shell command, handed to `sh -c` with the Project as the working
# directory and $CDD_PATH set to its path. {path} stands for the
# shell-quoted absolute path, {remote} for the shell-quoted home
# page URL of the git remote. Use {path}, not $path, which zsh ties
# to $PATH.
# jump when true, Jump to the Project once run exits successfully.
# detach when true, start run in its own session without waiting and keep
# the Picker open. Otherwise the Picker quits and run gets your
# terminal, so a TUI works.

# Open the Project in VS Code, without leaving the Picker.
# [actions.code]
# key = "ctrl+v"
# run = "code {path}"
# detach = true

# Run a TUI in the Project; it runs in the Project's directory, so it needs
# no {path}.
# [actions.lazygit]
# key = "ctrl+l"
# run = "lazygit"

# Remap enter: open the editor on enter and move the Jump to ctrl+t. The
# editor's binding on enter displaces the Jump's, so move the Jump first.
# [actions.jump]
# key = "ctrl+t"
# [actions.editor]
# key = "enter"

# Override a built-in's key: copy on ctrl+x instead of ctrl+y.
# [actions.copy]
# key = "ctrl+x"

# Unbind a built-in, so a stray key press cannot forget a Project.
# [actions.forget]
# key = ""
12 changes: 12 additions & 0 deletions config_example.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Package cdd holds config.example.toml, the commented config the release
// ships and `cdd config init` writes. The file lives at the repository
// root, where a release tarball picks it up, and //go:embed cannot reach
// above a package's own directory, so this root package embeds it.
package cdd

import _ "embed"

// ConfigExample is config.example.toml.
//
//go:embed config.example.toml
var ConfigExample []byte
48 changes: 48 additions & 0 deletions config_example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cdd_test

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/kryft-dev/cdd"
"github.com/kryft-dev/cdd/internal/config"
)

// TestConfigExampleLoads checks the embedded example is a valid config, as
// shipped and with every commented example uncommented.
func TestConfigExampleLoads(t *testing.T) {
t.Run("as shipped", func(t *testing.T) {
assertLoads(t, string(cdd.ConfigExample))
})

// The Action examples are commented out, so uncomment every one: they
// must also load, all together.
t.Run("actions uncommented", func(t *testing.T) {
_, actions, found := strings.Cut(string(cdd.ConfigExample), "# [actions.code]")
if !found {
t.Fatal("no [actions.code] example found")
}
var b strings.Builder
b.WriteString("[actions.code]\n")
for _, line := range strings.Split(actions, "\n") {
if rest, ok := strings.CutPrefix(line, "# "); ok && strings.ContainsAny(rest, "=[") {
b.WriteString(rest + "\n")
}
}
assertLoads(t, b.String())
})
}

func assertLoads(t *testing.T, body string) {
t.Helper()

path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := config.LoadFrom(path); err != nil {
t.Fatalf("LoadFrom: %v\n%s", err, body)
}
}
8 changes: 4 additions & 4 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package cli wires cdd's command surface with github.com/spf13/cobra: the
// root command (bare behaves as pick), pick, init, scan, add, forget,
// version, plus
// config, version, plus
// cobra's own help and completion commands.
package cli

Expand Down Expand Up @@ -58,8 +58,8 @@ func Run(args []string, stdout, stderr io.Writer) int {
}

// newRootCmd builds the cdd command tree: root (bare behaves as pick),
// pick, init, scan, add, forget, version, plus cobra's help and completion
// commands.
// pick, init, scan, add, forget, config, version, plus cobra's help and
// completion commands.
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "cdd",
Expand All @@ -75,7 +75,7 @@ func newRootCmd() *cobra.Command {
return pickRunE(cmd, nil)
}

root.AddCommand(newPickCmd(), newInitCmd(), newScanCmd(), newAddCmd(), newForgetCmd(), newVersionCmd())
root.AddCommand(newPickCmd(), newInitCmd(), newScanCmd(), newAddCmd(), newForgetCmd(), newConfigCmd(), newVersionCmd())

// Initialized here, not left to Execute, so the command tree is
// complete (including "help" and "completion") whenever init's
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func TestInitPassthrough(t *testing.T) {
t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr)
}

for _, name := range []string{"init", "scan", "version", "help", "completion"} {
for _, name := range []string{"init", "scan", "config", "version", "help", "completion"} {
if !strings.Contains(stdout, name) {
t.Errorf("script does not mention pass-through command %q:\n%s", name, stdout)
}
Expand Down
129 changes: 129 additions & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package cli

import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"

"github.com/spf13/cobra"

"github.com/kryft-dev/cdd"
"github.com/kryft-dev/cdd/internal/config"
)

// newConfigCmd builds "cdd config": init, path and edit, which write, locate
// and open config.toml.
func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "create, locate, or edit config.toml",
}
cmd.AddCommand(newConfigInitCmd(), newConfigPathCmd(), newConfigEditCmd())
return cmd
}

// newConfigInitCmd builds "cdd config init [--force]": write the commented
// example config to the config path, creating its directories. It refuses
// to replace a file that exists unless --force is given.
func newConfigInitCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "init",
Short: "write the commented example config.toml (--force replaces an existing one)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
path, err := config.Path()
if err != nil {
return err
}
if err := writeExample(path, force); err != nil {
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", path)
return err
},
}
cmd.Flags().BoolVar(&force, "force", false, "replace config.toml if it exists")
return cmd
}

// newConfigPathCmd builds "cdd config path": print where config.toml lives,
// whether or not it exists.
func newConfigPathCmd() *cobra.Command {
return &cobra.Command{
Use: "path",
Short: "print the config.toml path",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
path, err := config.Path()
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), path)
return err
},
}
}

// newConfigEditCmd builds "cdd config edit": open config.toml in $VISUAL,
// else $EDITOR, else vi, writing the example first when there is no file.
func newConfigEditCmd() *cobra.Command {
return &cobra.Command{
Use: "edit",
Short: "open config.toml in $VISUAL or $EDITOR, creating it first if missing",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
path, err := config.Path()
if err != nil {
return err
}
switch err := writeExample(path, false); {
case err == nil:
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Wrote %s\n", path); err != nil {
return err
}
case !errors.Is(err, os.ErrExist):
return err
}

// The shell resolves the editor, so a value such as "code -w"
// works, as it does for the built-in editor Action.
editor := exec.Command("sh", "-c", `${VISUAL:-${EDITOR:-vi}} "$1"`, "sh", path)
editor.Stdin, editor.Stdout, editor.Stderr = os.Stdin, cmd.OutOrStdout(), cmd.ErrOrStderr()
if err := editor.Run(); err != nil {
return fmt.Errorf("cdd config edit: %w", err)
}
return nil
},
}
}

// writeExample writes the example config to path, creating its directories.
// Unless force is set it fails with an error wrapping os.ErrExist when path
// exists, and never overwrites it.
func writeExample(path string, force bool) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("cdd config: %w", err)
}
flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
if !force {
flags = os.O_WRONLY | os.O_CREATE | os.O_EXCL
}
f, err := os.OpenFile(path, flags, 0o644)
if err != nil {
if errors.Is(err, os.ErrExist) {
return fmt.Errorf("cdd config: %s already exists (use --force to replace it): %w", path, os.ErrExist)
}
return fmt.Errorf("cdd config: %w", err)
}
if _, err := f.Write(cdd.ConfigExample); err != nil {
_ = f.Close()
return fmt.Errorf("cdd config: write %s: %w", path, err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("cdd config: write %s: %w", path, err)
}
return nil
}
Loading
Loading