+
+**Simple tmux session management.**
+
+Tmpl streamlines your tmux workflow by letting you describe your sessions in simple YAML files and have them
+launched with all the tools your workflow requires set up and ready to go. If you often set up the same windows and
+panes for tasks like coding, running unit tests, tailing logs, and using other tools, tmpl can automate that for you.
+
+## Highlights
+
+- **Simple and versatile configuration:** easily set up your tmux sessions using straightforward YAML files, allowing
+ you to create as many windows and panes as needed. Customize session and window names, working directories, and
+ start-up commands.
+
+- **Inheritable environment variables:** define environment variables for your entire session, a specific window, or a
+ particular pane. These variables cascade from session to window to pane, enabling you to set a variable once and
+ modify it at any level.
+
+- **Custom hook commands:** customize your setup with on-window and on-pane hook commands that run when new windows,
+ panes, or both are created. This feature is useful for initializing a virtual environment or switching between
+ language runtime versions.
+
+- **Non-intrusive workflow:** while there are many excellent session managers out there, some of them tend to be quite
+ opinionated about how you should work with them. Tmpl allows configurations to live anywhere in your filesystem and
+ focuses only on launching your session. It's intended as a secondary companion, and not a full workflow replacement.
+
+- **Stand-alone binary:** Tmpl is a single, stand-alone binary with no external dependencies, except for tmux. It's easy
+ to install and doesn't require you to have a specific language runtime or package manager on your system.
+
+## Getting started
+
+See the [Getting started guide](https://michenriksen.com/tmpl/getting-started/) for installation and usage instructions.
diff --git a/cmd/tmpl/main.go b/cmd/tmpl/main.go
new file mode 100644
index 0000000..993a7ee
--- /dev/null
+++ b/cmd/tmpl/main.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/michenriksen/tmpl/internal/cli"
+)
+
+func main() {
+ ctx, cancel := context.WithCancel(context.Background())
+ exitChan := make(chan int, 1)
+
+ signalHandler(cancel, exitChan)
+
+ app, err := cli.NewApp()
+ if err != nil {
+ panic(err)
+ }
+
+ if err := app.Run(ctx, os.Args[1:]...); err != nil {
+ if errors.Is(err, cli.ErrHelp) || errors.Is(err, cli.ErrVersion) {
+ os.Exit(0)
+ }
+
+ // Return exit code 2 when a configuration file is invalid.
+ if errors.Is(err, cli.ErrInvalidConfig) {
+ os.Exit(2)
+ }
+
+ if !errors.Is(ctx.Err(), context.Canceled) {
+ os.Exit(1)
+ }
+
+ exitCode := <-exitChan
+ os.Exit(exitCode)
+ }
+}
+
+func signalHandler(cancel context.CancelFunc, exitChan chan<- int) {
+ sigchan := make(chan os.Signal, 1)
+ signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM)
+
+ go func() {
+ for sig := range sigchan {
+ cancel()
+
+ fmt.Fprintf(os.Stderr, "received signal: %s; exiting...\n", sig)
+ time.Sleep(1 * time.Second)
+
+ exitChan <- 1
+ }
+ }()
+}
diff --git a/config.schema.json b/config.schema.json
new file mode 100644
index 0000000..10f7c04
--- /dev/null
+++ b/config.schema.json
@@ -0,0 +1,235 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://github.com/michenriksen/tmpl/config.schema.json",
+ "title": "Tmpl configuration",
+ "description": "A configuration file describing how a tmux session should be created.",
+ "version": "0.1.0",
+ "author": "Michael Henriksen",
+ "type": "object",
+ "$defs": {
+ "name": {
+ "title": "Name",
+ "description": "A name for the tmux session or window. Must only contain alphanumeric characters, underscores, dots, and dashes",
+ "type": "string",
+ "pattern": "^[\\w._-]+$"
+ },
+ "path": {
+ "title": "Path",
+ "description": "The directory path used as the working directory in a tmux session, window, or pane.\n\nThe paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory.",
+ "type": "string",
+ "examples": [
+ "/path/to/project",
+ "~/path/to/project",
+ "relative/path/to/project"
+ ]
+ },
+ "command": {
+ "title": "Shell command",
+ "description": "A shell command to run within a tmux window or pane.\n\nThe 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection.",
+ "type": "string",
+ "minLength": 1
+ },
+ "commands": {
+ "title": "Shell commands",
+ "description": "A list of shell commands to run within a tmux window or pane in the order they are listed.\n\nIf a command is also specified in the 'command' property, it will be run first.",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/command"
+ },
+ "examples": [
+ [
+ "ssh user@host",
+ "cd /var/logs",
+ "tail -f app.log"
+ ]
+ ]
+ },
+ "env": {
+ "title": "Environment variables",
+ "description": "A list of environment variables to set in a tmux session, window, or pane.\n\nThese variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores.",
+ "type": "object",
+ "propertyNames": {
+ "pattern": "^[A-Z_][A-Z0-9_]+$"
+ },
+ "additionalProperties": {
+ "type": [
+ "string",
+ "number",
+ "boolean"
+ ]
+ },
+ "examples": [
+ {
+ "APP_ENV": "development",
+ "DEBUG": true,
+ "HTTP_PORT": 8080
+ }
+ ]
+ },
+ "active": {
+ "title": "Active",
+ "description": "Whether a tmux window or pane should be selected after session creation. The first window and pane will be selected by default.",
+ "type": "boolean",
+ "default": false
+ },
+ "SessionConfig": {
+ "title": "Session configuration",
+ "description": "Session configuration describing how a tmux session should be created.",
+ "type": "object",
+ "properties": {
+ "name": {
+ "$ref": "#/$defs/name",
+ "default": "The current working directory base name."
+ },
+ "path": {
+ "$ref": "#/$defs/path",
+ "default": "The current working directory."
+ },
+ "env": {
+ "$ref": "#/$defs/env"
+ },
+ "on_window": {
+ "$ref": "#/$defs/command",
+ "title": "On-Window shell command",
+ "description": "A shell command to run first in all created windows. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command."
+ },
+ "on_pane": {
+ "$ref": "#/$defs/command",
+ "title": "On-Pane shell command",
+ "description": "A shell command to run first in all created panes. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command."
+ },
+ "on_any": {
+ "$ref": "#/$defs/command",
+ "title": "On-Window/Pane shell command",
+ "description": "A shell command to run first in all created windows and panes. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command."
+ },
+ "windows": {
+ "title": "Window configurations",
+ "description": "A list of tmux window configurations to create in the session. The first configuration will be used for the default window.",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/WindowConfig"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "WindowConfig": {
+ "title": "Window configuration",
+ "description": "Window configuration describing how a tmux window should be created.",
+ "type": "object",
+ "properties": {
+ "name": {
+ "$ref": "#/$defs/name",
+ "default": "tmux default"
+ },
+ "path": {
+ "$ref": "#/$defs/path",
+ "default": "The session path."
+ },
+ "command": {
+ "$ref": "#/$defs/command"
+ },
+ "commands": {
+ "$ref": "#/$defs/commands"
+ },
+ "env": {
+ "$ref": "#/$defs/env",
+ "default": "The session env."
+ },
+ "active": {
+ "$ref": "#/$defs/active"
+ },
+ "panes": {
+ "title": "Pane configurations",
+ "description": "A list of tmux pane configurations to create in the window.",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/PaneConfig"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "PaneConfig": {
+ "title": "Pane configuration",
+ "description": "Pane configuration describing how a tmux pane should be created.",
+ "type": "object",
+ "properties": {
+ "path": {
+ "$ref": "#/$defs/path",
+ "default": "The window path."
+ },
+ "command": {
+ "$ref": "#/$defs/command"
+ },
+ "commands": {
+ "$ref": "#/$defs/commands"
+ },
+ "env": {
+ "$ref": "#/$defs/env",
+ "default": "The window env."
+ },
+ "active": {
+ "$ref": "#/$defs/active"
+ },
+ "horizontal": {
+ "title": "Horizontal split",
+ "description": "Whether to split the window horizontally. If false, the window will be split vertically.",
+ "type": "boolean",
+ "default": false
+ },
+ "size": {
+ "title": "Size",
+ "description": "The size of the pane in lines for horizontal panes, or columns for vertical panes. The size can also be specified as a percentage of the available space.",
+ "type": "string",
+ "examples": [
+ "20%",
+ "50",
+ "215"
+ ]
+ },
+ "panes": {
+ "title": "Pane configurations",
+ "description": "A list of tmux pane configurations to create in the pane.",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/PaneConfig"
+ }
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "properties": {
+ "tmux": {
+ "title": "tmux executable",
+ "description": "The tmux executable to use. Must be an absolute path, or available in $PATH.",
+ "type": "string",
+ "default": "tmux"
+ },
+ "tmux_options": {
+ "title": "tmux command line options",
+ "description": "Additional tmux command line options to add to all tmux command invocations.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "description": "A tmux command line flag and its value, if any. See `man tmux` for available options."
+ },
+ "examples": [
+ [
+ "-f",
+ "/path/to/tmux.conf"
+ ],
+ [
+ "-L",
+ "MySocket"
+ ]
+ ]
+ },
+ "session": {
+ "$ref": "#/$defs/SessionConfig"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/config/apply.go b/config/apply.go
new file mode 100644
index 0000000..ef05cb3
--- /dev/null
+++ b/config/apply.go
@@ -0,0 +1,206 @@
+package config
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/michenriksen/tmpl/tmux"
+)
+
+// Apply applies the provided tmux session configuration using the provided
+// tmux command.
+//
+// If a session with the same name already exists, it is assumed to be in the
+// correct state and the session is returned. Otherwise, a new session is
+// created and returned.
+//
+// If the provided configuration is invalid, an error is returned. Caller can
+// check for validity beforehand by calling [config.Config.Validate] if needed.
+func Apply(ctx context.Context, cfg *Config, runner tmux.Runner) (*tmux.Session, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ if err := cfg.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid configuration file: %w", err)
+ }
+
+ sCfg := cfg.Session
+
+ sessions, err := tmux.GetSessions(ctx, runner)
+ if err != nil {
+ return nil, fmt.Errorf("getting current tmux sessions: %w", err)
+ }
+
+ for _, s := range sessions {
+ if s.Name() == sCfg.Name {
+ return s, nil
+ }
+ }
+
+ session, err := tmux.NewSession(runner, makeSessionOpts(cfg.Session)...)
+ if err != nil {
+ return fatalf(session, "creating session: %w", err)
+ }
+
+ if err := session.Apply(ctx); err != nil {
+ return fatalf(session, "applying %s: %w", session, err)
+ }
+
+ for _, wCfg := range sCfg.Windows {
+ if _, err := applyWindowCfg(ctx, runner, session, wCfg); err != nil {
+ return fatalf(session, "applying window configuration: %w", err)
+ }
+ }
+
+ if err := session.SelectActive(ctx); err != nil {
+ return fatalf(session, "selecting active window: %w", err)
+ }
+
+ return session, nil
+}
+
+// fatalf is a helper function for [Apply] that constructs an error from
+// provided format and args, and closes the provided tmux session if not nil.
+func fatalf(sess *tmux.Session, format string, args ...any) (*tmux.Session, error) {
+ err := fmt.Errorf(format, args...)
+
+ if sess != nil {
+ if closeErr := sess.Close(); closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("closing failed session: %w", closeErr))
+ }
+ }
+
+ return nil, err
+}
+
+// applyWindowCfg creates a new tmux window from the provided configuration on
+// the provided tmux session.
+func applyWindowCfg(ctx context.Context, r tmux.Runner, s *tmux.Session, cfg WindowConfig) (*tmux.Window, error) {
+ win, err := tmux.NewWindow(r, s, makeWindowOpts(cfg)...)
+ if err != nil {
+ return nil, fmt.Errorf("creating window: %w", err)
+ }
+
+ if err := win.Apply(ctx); err != nil {
+ return nil, fmt.Errorf("applying %s: %w", win, err)
+ }
+
+ for _, pCfg := range cfg.Panes {
+ if _, err := applyPaneCfg(ctx, r, win, nil, pCfg); err != nil {
+ return nil, err
+ }
+ }
+
+ return win, nil
+}
+
+func applyPaneCfg(ctx context.Context, r tmux.Runner, w *tmux.Window, pp *tmux.Pane, cfg PaneConfig) (*tmux.Pane, error) { //nolint:revive // more readable in one line.
+ pane, err := tmux.NewPane(r, w, pp, makePaneOpts(cfg)...)
+ if err != nil {
+ return nil, fmt.Errorf("creating pane: %w", err)
+ }
+
+ if err := pane.Apply(ctx); err != nil {
+ return nil, fmt.Errorf("applying %s: %w", pane, err)
+ }
+
+ for _, pCfg := range cfg.Panes {
+ if _, err := applyPaneCfg(ctx, r, w, pane, pCfg); err != nil {
+ return nil, err
+ }
+ }
+
+ return pane, nil
+}
+
+func makeSessionOpts(sCfg SessionConfig) []tmux.SessionOption {
+ opts := []tmux.SessionOption{}
+
+ if sCfg.Name != "" {
+ opts = append(opts, tmux.SessionWithName(sCfg.Name))
+ }
+
+ if sCfg.Path != "" {
+ opts = append(opts, tmux.SessionWithPath(sCfg.Path))
+ }
+
+ if sCfg.OnWindow != "" {
+ opts = append(opts, tmux.SessionWithOnWindowCommand(sCfg.OnWindow))
+ }
+
+ if sCfg.OnPane != "" {
+ opts = append(opts, tmux.SessionWithOnPaneCommand(sCfg.OnPane))
+ }
+
+ if sCfg.OnAny != "" {
+ opts = append(opts, tmux.SessionWithOnAnyCommand(sCfg.OnAny))
+ }
+
+ if len(sCfg.Env) != 0 {
+ opts = append(opts, tmux.SessionWithEnv(sCfg.Env))
+ }
+
+ return opts
+}
+
+func makeWindowOpts(wCfg WindowConfig) []tmux.WindowOption {
+ opts := []tmux.WindowOption{tmux.WindowWithName(wCfg.Name)}
+
+ if wCfg.Path != "" {
+ opts = append(opts, tmux.WindowWithPath(wCfg.Path))
+ }
+
+ if wCfg.Command != "" {
+ opts = append(opts, tmux.WindowWithCommands(wCfg.Command))
+ }
+
+ if len(wCfg.Commands) != 0 {
+ opts = append(opts, tmux.WindowWithCommands(wCfg.Commands...))
+ }
+
+ if wCfg.Active {
+ opts = append(opts, tmux.WindowAsActive())
+ }
+
+ if len(wCfg.Env) != 0 {
+ opts = append(opts, tmux.WindowWithEnv(wCfg.Env))
+ }
+
+ return opts
+}
+
+func makePaneOpts(pCfg PaneConfig) []tmux.PaneOption {
+ opts := []tmux.PaneOption{}
+
+ if pCfg.Path != "" {
+ opts = append(opts, tmux.PaneWithPath(pCfg.Path))
+ }
+
+ if pCfg.Size != "" {
+ opts = append(opts, tmux.PaneWithSize(pCfg.Size))
+ }
+
+ if pCfg.Command != "" {
+ opts = append(opts, tmux.PaneWithCommands(pCfg.Command))
+ }
+
+ if len(pCfg.Commands) != 0 {
+ opts = append(opts, tmux.PaneWithCommands(pCfg.Commands...))
+ }
+
+ if pCfg.Horizontal {
+ opts = append(opts, tmux.PaneWithHorizontalDirection())
+ }
+
+ if pCfg.Active {
+ opts = append(opts, tmux.PaneAsActive())
+ }
+
+ if len(pCfg.Env) != 0 {
+ opts = append(opts, tmux.PaneWithEnv(pCfg.Env))
+ }
+
+ return opts
+}
diff --git a/config/apply_test.go b/config/apply_test.go
new file mode 100644
index 0000000..e6c1e69
--- /dev/null
+++ b/config/apply_test.go
@@ -0,0 +1,101 @@
+package config_test
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/michenriksen/tmpl/config"
+ "github.com/michenriksen/tmpl/internal/testutils"
+ "github.com/michenriksen/tmpl/tmux"
+
+ "github.com/stretchr/testify/require"
+)
+
+// stubCmd represents an entry for a stubbed command defined in
+// testdata/apply-stubcmds.json.
+//
+// See [loadStubCmds] for more information.
+type stubCmd struct {
+ Err error `json:"err"`
+ Output string `json:"output"`
+ seen bool
+}
+
+func TestApply(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "project", "cmd"), 0755))
+
+ // Stub HOME and current working directory for consistent test results.
+ t.Setenv("HOME", dir)
+ t.Setenv("TMPL_PWD", dir)
+
+ cfg, err := config.FromFile(filepath.Join("testdata", "apply.yaml"))
+ require.NoError(t, err)
+
+ expectedCmds := loadStubCmds(t)
+
+ var mockCmdRunner tmux.OSCommandRunner = func(_ context.Context, name string, args ...string) ([]byte, error) {
+ argStr := strings.Join(args, " ")
+
+ cmd, ok := expectedCmds[argStr]
+
+ if !ok {
+ t.Fatalf("unexpected command: %s %s", name, argStr)
+ }
+
+ if cmd.seen {
+ t.Fatalf("received duplicate command: %s %s", name, argStr)
+ }
+
+ t.Logf("received expected command: %s %s", name, argStr)
+
+ cmd.seen = true
+
+ return []byte(cmd.Output), cmd.Err
+ }
+
+ cmd, err := tmux.NewRunner(tmux.WithOSCommandRunner(mockCmdRunner))
+ require.NoError(t, err)
+
+ session, err := config.Apply(context.Background(), cfg, cmd)
+ require.NoError(t, err)
+
+ for args, cmd := range expectedCmds {
+ if !cmd.seen {
+ t.Fatalf("expected command was not run: %s", args)
+ }
+ }
+
+ require.Equal(t, "tmpl_test_session", session.Name())
+}
+
+// loadStubCmds loads the stub commands defined in testdata/apply-stubcmds.json.
+//
+// Commands are defined as a map of expected tmux command line arguments mapped
+// to a stubCmd struct containing optional stub output and error.
+//
+// The map keys and stub outputs are expanded using os.ExpandEnv before being
+// returned. This allows for using environment variables in the stub commands to
+// ensure consistent test results.
+func loadStubCmds(t *testing.T) map[string]*stubCmd {
+ data := testutils.ReadFile(t, "testdata", "apply-stubcmds.json")
+
+ var cmds map[string]*stubCmd
+
+ if err := json.Unmarshal(data, &cmds); err != nil {
+ t.Fatalf("error decoding apply-stubcmds.json: %v", err)
+ }
+
+ expanded := make(map[string]*stubCmd, len(cmds))
+ for args, cmd := range cmds {
+ expanded[os.ExpandEnv(args)] = cmd
+ }
+
+ t.Logf("loaded %d stub commands", len(cmds))
+
+ return expanded
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..8856714
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,264 @@
+// Package config loads, validates, and applies tmpl configurations.
+package config
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+
+ "gopkg.in/yaml.v3"
+
+ "github.com/michenriksen/tmpl/internal/env"
+)
+
+// DefaultConfigFile is the default configuration filename.
+const DefaultConfigFile = ".tmpl.yaml"
+
+var specialCharsRegexp = regexp.MustCompile(`[^\w_]+`)
+
+// Config represents a session configuration loaded from a YAML file.
+type Config struct {
+ path string
+ Session SessionConfig `yaml:"session"` // Session configuration.
+ Tmux string `yaml:"tmux"` // Path to tmux executable.
+ TmuxOptions []string `yaml:"tmux_options"` // Additional tmux options.
+}
+
+// FromFile loads a session configuration from provided file path.
+//
+// File is expected to be in YAML format.
+func FromFile(cfgPath string) (*Config, error) {
+ cfg, err := load(cfgPath)
+ if err != nil {
+ return nil, err
+ }
+
+ return cfg, nil
+}
+
+// Path returns the path to the configuration file from which the configuration
+// was loaded.
+func (c *Config) Path() string {
+ return c.path
+}
+
+// NumWindows returns the number of window configurations for the session.
+func (c *Config) NumWindows() int {
+ n := len(c.Session.Windows)
+ if n == 0 {
+ return 1
+ }
+
+ return n
+}
+
+// NumPanes returns the number of pane configurations for the session.
+func (c *Config) NumPanes() int {
+ n := 0
+
+ for _, w := range c.Session.Windows {
+ n += len(w.Panes)
+
+ for _, p := range w.Panes {
+ n += len(p.Panes)
+ }
+ }
+
+ return n
+}
+
+// SessionConfig represents a tmux session configuration. It contains the name
+// of the session, the path to the directory where the session will be created
+// and the window configurations.
+//
+// Any environment variables defined in the session configuration will be
+// inherited by all windows and panes.
+type SessionConfig struct {
+ Name string `yaml:"name"` // Session name.
+ Path string `yaml:"path"` // Session directory.
+ OnWindow string `yaml:"on_window"` // Shell command to run in all windows.
+ OnPane string `yaml:"on_pane"` // Shell command to run in all panes.
+ OnAny string `yaml:"on_any"` // Shell command to run in all windows and panes.
+ Env map[string]string `yaml:"env"` // Session environment variables.
+ Windows []WindowConfig `yaml:"windows"` // Window configurations.
+}
+
+// WindowConfig represents a tmux window configuration. It contains the name of
+// the window, the path to the directory where the window will be created, the
+// command to run in the window and pane configurations.
+//
+// If a path is not specified, a window will inherit the session path.
+//
+// Any environment variables defined in the window configuration will be
+// inherited by all panes. If a variable is defined in both the session and
+// window configuration, the window variable will take precedence.
+type WindowConfig struct {
+ Name string `yaml:"name"` // Window name.
+ Path string `yaml:"path"` // Window directory.
+ Command string `yaml:"command"` // Command to run in the window.
+ Commands []string `yaml:"commands"` // Commands to run in the window.
+ Env map[string]string `yaml:"env"` // Window environment variables.
+ Panes []PaneConfig `yaml:"panes"` // Pane configurations.
+ Active bool `yaml:"active"` // Whether the window should be selected.
+}
+
+// PaneConfig represents a tmux pane configuration. It contains the path to the
+// directory where the pane will be created, the command to run in the pane,
+// the size of the pane and whether the pane should be split horizontally or
+// vertically.
+//
+// If a path is not specified, a pane will inherit the window path.
+//
+// Any inherited environment variables from the window or session will be
+// overridden by variables defined in the pane configuration if they have the
+// same name.
+type PaneConfig struct {
+ Env map[string]string `yaml:"env"` // Pane environment variables.
+ Path string `yaml:"path"` // Pane directory.
+ Command string `yaml:"command"` // Command to run in the pane.
+ Commands []string `yaml:"commands"` // Commands to run in the pane.
+ Size string `yaml:"size"` // Pane size (cells or percentage)
+ Horizontal bool `yaml:"horizontal"` // Whether the pane should be split horizontally.
+ Panes []PaneConfig `yaml:"panes"` // Pane configurations.
+ Active bool `yaml:"active"` // Whether the pane should be selected.
+}
+
+// FindConfigFile searches for a configuration file starting from the provided
+// directory and going up until the root directory is reached. If no file is
+// found, ErrConfigNotFound is returned.
+//
+// By default, the configuration file name is .tmpl.yaml. This can be changed
+// by setting the TMPL_CONFIG_FILE environment variable.
+func FindConfigFile(dir string) (string, error) {
+ name := ConfigFileName()
+
+ for {
+ cfgPath := filepath.Join(dir, name)
+
+ info, err := os.Stat(cfgPath)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ if dir == "/" {
+ return "", ErrConfigNotFound
+ }
+
+ dir = filepath.Dir(dir)
+
+ continue
+ }
+
+ return "", fmt.Errorf("getting file info: %w", err)
+ }
+
+ if info.IsDir() {
+ return "", fmt.Errorf("path %q is a directory", cfgPath)
+ }
+
+ return cfgPath, nil
+ }
+}
+
+// load reads and decodes a YAML configuration file into a Config struct and
+// sets default values.
+func load(cfgPath string) (*Config, error) {
+ var cfg Config
+
+ f, err := os.Open(cfgPath)
+ if err != nil {
+ return nil, fmt.Errorf("opening configuration file: %w", err)
+ }
+ defer f.Close()
+
+ info, err := f.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("getting configuration file info: %w", err)
+ }
+
+ if info.Size() == 0 {
+ return nil, ErrEmptyConfig
+ }
+
+ decoder := yaml.NewDecoder(f)
+ decoder.KnownFields(true)
+
+ if err := decoder.Decode(&cfg); err != nil {
+ return nil, decodeError(err, cfgPath)
+ }
+
+ cfg.path = cfgPath
+
+ if err := setDefaults(&cfg); err != nil {
+ return nil, fmt.Errorf("setting default values: %w", err)
+ }
+
+ return &cfg, nil
+}
+
+// setDefaults sets default values for blank configuration fields.
+//
+// The following fields are set to default values:
+//
+// - Session.Name: defaults to .
+// - Session.Path: defaults to current working directory.
+// - Window.Path: defaults to Session.Path.
+// - Pane.Path: defaults to Window.Path.
+func setDefaults(cfg *Config) error {
+ wd, err := env.Getwd()
+ if err != nil {
+ return fmt.Errorf("getting current working directory: %w", err)
+ }
+
+ if cfg.Session.Name == "" {
+ name := specialCharsRegexp.ReplaceAllString(filepath.Base(wd), "_")
+ cfg.Session.Name = name
+ }
+
+ if cfg.Session.Path == "" {
+ cfg.Session.Path = wd
+ } else {
+ if cfg.Session.Path, err = env.AbsPath(cfg.Session.Path); err != nil {
+ return fmt.Errorf("expanding session path: %w", err)
+ }
+ }
+
+ for i, w := range cfg.Session.Windows {
+ if w.Path == "" {
+ w.Path = cfg.Session.Path
+ } else {
+ if w.Path, err = env.AbsPath(w.Path); err != nil {
+ return fmt.Errorf("expanding window path: %w", err)
+ }
+ }
+
+ for j, p := range w.Panes {
+ if p.Path == "" {
+ p.Path = w.Path
+ } else {
+ if p.Path, err = env.AbsPath(p.Path); err != nil {
+ return fmt.Errorf("expanding pane path: %w", err)
+ }
+ }
+
+ w.Panes[j] = p
+ }
+
+ cfg.Session.Windows[i] = w
+ }
+
+ return nil
+}
+
+// ConfigFileName returns the name of the configuration that.
+//
+// Returns the value of the TMPL_CONFIG_NAME environment variable if set,
+// otherwise it returns [DefaultConfigFile].
+func ConfigFileName() string {
+ if name := env.Getenv(env.KeyConfigName); name != "" {
+ return name
+ }
+
+ return DefaultConfigFile
+}
diff --git a/config/config_test.go b/config/config_test.go
new file mode 100644
index 0000000..c807fa6
--- /dev/null
+++ b/config/config_test.go
@@ -0,0 +1,93 @@
+package config_test
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/michenriksen/tmpl/config"
+ "github.com/michenriksen/tmpl/internal/testutils"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestFromFile(t *testing.T) {
+ // Stub HOME and current working directory for consistent test results.
+ t.Setenv("HOME", "/Users/johndoe")
+ t.Setenv("TMPL_PWD", "/Users/johndoe/project")
+
+ tt := []struct {
+ name string
+ file string
+ assertErr testutils.ErrorAssertion
+ }{
+ {"full config", "full.yaml", nil},
+ {"minimal config", "minimal.yaml", nil},
+ {"tilde home paths", "tilde.yaml", nil},
+ {"empty config", "empty.yaml", testutils.RequireErrorIs(config.ErrEmptyConfig)},
+ {"broken", "broken.yaml", testutils.RequireErrorContains("decoding error:")},
+ }
+
+ for _, tc := range tt {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg, err := config.FromFile(filepath.Join("testdata", tc.file))
+
+ if tc.assertErr != nil {
+ require.Error(t, err, "expected error")
+ require.Nil(t, cfg, "expected nil config on error")
+
+ tc.assertErr(t, err)
+
+ return
+ }
+
+ require.NoError(t, err)
+ require.NotNil(t, cfg, "expected non-nil config on success")
+
+ testutils.NewGolden(t).RequireMatch(cfg)
+ })
+ }
+}
+
+func TestFindConfigFile_TraverseDirectories(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "subdir", "second subdir", "third subdir"), 0o744))
+
+ wantCfg := filepath.Join(dir, ".tmpl.yaml")
+ testutils.WriteFile(t, []byte("---\nsession:\n name: test\n"), wantCfg)
+
+ cfg, err := config.FindConfigFile(filepath.Join(dir, "subdir", "second subdir", "third subdir"))
+
+ require.NoError(t, err)
+ require.Equal(t, wantCfg, cfg)
+}
+
+func TestFindConfigFile_NotFound(t *testing.T) {
+ dir := t.TempDir()
+
+ cfg, err := config.FindConfigFile(dir)
+
+ require.ErrorIs(t, err, config.ErrConfigNotFound)
+ require.Empty(t, cfg)
+}
+
+func TestFindConfigFile_DirNotExist(t *testing.T) {
+ cfg, err := config.FindConfigFile("/path/to/non-existent/dir")
+
+ require.ErrorIs(t, err, config.ErrConfigNotFound)
+ require.Empty(t, cfg)
+}
+
+func TestFindConfigFile_CustomConfigFilename(t *testing.T) {
+ t.Setenv("TMPL_CONFIG_NAME", "tmpl-test-custom.yaml")
+
+ dir := t.TempDir()
+
+ wantCfg := filepath.Join(dir, "tmpl-test-custom.yaml")
+ testutils.WriteFile(t, []byte("---\nsession:\n name: test\n"), wantCfg)
+
+ cfg, err := config.FindConfigFile(dir)
+
+ require.NoError(t, err)
+ require.Equal(t, wantCfg, cfg)
+}
diff --git a/config/errors.go b/config/errors.go
new file mode 100644
index 0000000..71c68da
--- /dev/null
+++ b/config/errors.go
@@ -0,0 +1,42 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+)
+
+var (
+ // ErrConfigNotFound is returned when a configuration file is not found.
+ ErrConfigNotFound = errors.New("configuration file not found")
+ // ErrEmptyConfig is returned when a configuration file is empty.
+ ErrEmptyConfig = errors.New("configuration file is empty")
+ // ErrInvalidConfig is returned when a configuration file contains invalid
+ // and unparsable YAML.
+ ErrInvalidConfig = errors.New("configuration file is not parsable")
+)
+
+// DecodeError is returned when a configuration file cannot be decoded.
+type DecodeError struct {
+ err error
+ path string
+}
+
+func decodeError(err error, path string) DecodeError {
+ return DecodeError{err: err, path: path}
+}
+
+// Error implements the error interface.
+func (e DecodeError) Error() string {
+ return fmt.Sprintf("decoding error: %s", e.err)
+}
+
+// Unwrap implements the [errors.Wrapper] interface.
+func (e DecodeError) Unwrap() error {
+ return e.err
+}
+
+// Path returns the path to the configuration file that was attempted to be
+// decoded.
+func (e DecodeError) Path() string {
+ return e.path
+}
diff --git a/config/testdata/apply-stubcmds.json b/config/testdata/apply-stubcmds.json
new file mode 100644
index 0000000..b46cb08
--- /dev/null
+++ b/config/testdata/apply-stubcmds.json
@@ -0,0 +1,39 @@
+{
+ "list-sessions -F session_id:#{session_id},session_name:#{session_name},session_path:#{session_path}": {
+ "output": "session_id:$0,session_name:main,session_path:$HOME"
+ },
+ "new-session -d -P -F session_id:#{session_id},session_name:#{session_name},session_path:#{session_path} -s tmpl_test_session": {
+ "output": "session_id:$1,session_name:tmpl_test_session,session_path:$HOME/project"
+ },
+ "send-keys -t tmpl_test_session:code ~/project/scripts/boostrap.sh C-m": {},
+ "send-keys -t tmpl_test_session:code echo 'on_window' C-m": {},
+ "send-keys -t tmpl_test_session:code nvim . C-m": {},
+ "new-window -P -F window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height} -k -t tmpl_test_session:^ -n code -c $HOME/project": {
+ "output": "window_id:@2,window_name:code,window_path:$HOME/project/cmd,window_index:1,window_width:80,window_height:24"
+ },
+ "split-window -d -P -F pane_id:#{pane_id},pane_path:#{pane_path},pane_index:#{pane_index},pane_width:#{pane_width},pane_height:#{pane_height} -t tmpl_test_session:code -e APP_ENV=testing -c $HOME/project -h": {
+ "output": "pane_id:%3,pane_path:$HOME/project/cmd,pane_index:1,pane_width:80,pane_height:12"
+ },
+ "send-keys -t tmpl_test_session:code.1 ~/project/scripts/boostrap.sh C-m": {},
+ "send-keys -t tmpl_test_session:code.1 echo 'on_pane' C-m": {},
+ "send-keys -t tmpl_test_session:code.1 ./scripts/autorun-tests.sh C-m": {},
+ "new-window -P -F window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height} -t tmpl_test_session: -e APP_ENV=development -e PORT=8080 -n server -c $HOME/project/cmd": {
+ "output": "window_id:@3,window_name:server,window_path:$HOME/project/cmd,window_index:2,window_width:80,window_height:24"
+ },
+ "send-keys -t tmpl_test_session:server ~/project/scripts/boostrap.sh C-m": {},
+ "send-keys -t tmpl_test_session:server echo 'on_window' C-m": {},
+ "send-keys -t tmpl_test_session:server ./server C-m": {},
+ "new-window -P -F window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height} -t tmpl_test_session: -n prod_logs -c $HOME/project": {
+ "output": "window_id:@4,window_name:prod_logs,window_path:$HOME/project,window_index:3,window_width:80,window_height:24"
+ },
+ "send-keys -t tmpl_test_session:prod_logs ~/project/scripts/boostrap.sh C-m": {},
+ "send-keys -t tmpl_test_session:prod_logs echo 'on_window' C-m": {},
+ "send-keys -t tmpl_test_session:prod_logs ssh user@host C-m": {},
+ "send-keys -t tmpl_test_session:prod_logs cd /var/logs C-m": {},
+ "send-keys -t tmpl_test_session:prod_logs tail -f app.log C-m": {},
+ "select-window -t tmpl_test_session:code": {},
+ "show-option -gqv pane-base-index": {
+ "output": "0"
+ },
+ "select-pane -t tmpl_test_session:code.0": {}
+}
diff --git a/config/testdata/apply.yaml b/config/testdata/apply.yaml
new file mode 100644
index 0000000..fca0224
--- /dev/null
+++ b/config/testdata/apply.yaml
@@ -0,0 +1,26 @@
+---
+session:
+ name: "tmpl_test_session"
+ path: "~/project"
+ on_any: "~/project/scripts/boostrap.sh"
+ on_window: "echo 'on_window'"
+ on_pane: "echo 'on_pane'"
+ windows:
+ - name: "code"
+ command: "nvim ."
+ panes:
+ - command: "./scripts/autorun-tests.sh"
+ horizontal: true
+ env:
+ APP_ENV: "testing"
+ - name: "server"
+ path: "~/project/cmd"
+ command: "./server"
+ env:
+ APP_ENV: "development"
+ PORT: "8080"
+ - name: "prod_logs"
+ commands:
+ - "ssh user@host"
+ - "cd /var/logs"
+ - "tail -f app.log"
diff --git a/config/testdata/broken.yaml b/config/testdata/broken.yaml
new file mode 100644
index 0000000..080d989
--- /dev/null
+++ b/config/testdata/broken.yaml
@@ -0,0 +1,6 @@
+# yamllint disable-file
+# This file has an intentional syntax error for testing purposes.
+---
+session:
+ name: "test"
+ path: "/Users/johndoe/project"
diff --git a/config/testdata/empty.yaml b/config/testdata/empty.yaml
new file mode 100644
index 0000000..e69de29
diff --git a/config/testdata/full.yaml b/config/testdata/full.yaml
new file mode 100644
index 0000000..483857f
--- /dev/null
+++ b/config/testdata/full.yaml
@@ -0,0 +1,32 @@
+---
+tmux: "/usr/bin/other_tmux"
+tmux_options: ["-f", "/Users/johndoe/other_tmux.conf"]
+session:
+ name: "tmpl_test"
+ path: "/Users/johndoe/project"
+ env:
+ TMPL_TEST_SESS_ENV: "true"
+ windows:
+ - name: "tmpl_test_window_1"
+ command: "echo 'window 1'"
+ env:
+ TMPL_TEST_WIN_1_ENV: "true"
+ panes:
+ - command: "echo 'window 1 pane 1'"
+ horizontal: true
+ size: "20%"
+
+ - name: "tmpl_test_window_2"
+ path: "/Users/johndoe/project/subdir"
+ command: "echo 'window 2'"
+ env:
+ TMPL_TEST_SESS_ENV: "overwrite"
+ TMPL_TEST_WIN_2_ENV: "true"
+ panes:
+ - path: "/Users/johndoe/project/subdir/subdir2"
+ horizontal: true
+ env:
+ TMPL_TEST_WIN_2_PANE_1: "true"
+ TMPL_TEST_WIN_2: "overwrite"
+ - env:
+ TMPL_TEST_SESS_ENV: "overwrite"
diff --git a/config/testdata/golden/TestFromFile/full_config.golden.json b/config/testdata/golden/TestFromFile/full_config.golden.json
new file mode 100644
index 0000000..a83bf0d
--- /dev/null
+++ b/config/testdata/golden/TestFromFile/full_config.golden.json
@@ -0,0 +1,79 @@
+{
+ "Session": {
+ "Name": "tmpl_test",
+ "Path": "/Users/johndoe/project",
+ "OnWindow": "",
+ "OnPane": "",
+ "OnAny": "",
+ "Env": {
+ "TMPL_TEST_SESS_ENV": "true"
+ },
+ "Windows": [
+ {
+ "Name": "tmpl_test_window_1",
+ "Path": "/Users/johndoe/project",
+ "Command": "echo 'window 1'",
+ "Commands": null,
+ "Env": {
+ "TMPL_TEST_WIN_1_ENV": "true"
+ },
+ "Panes": [
+ {
+ "Env": null,
+ "Path": "/Users/johndoe/project",
+ "Command": "echo 'window 1 pane 1'",
+ "Commands": null,
+ "Size": "20%",
+ "Horizontal": true,
+ "Panes": null,
+ "Active": false
+ }
+ ],
+ "Active": false
+ },
+ {
+ "Name": "tmpl_test_window_2",
+ "Path": "/Users/johndoe/project/subdir",
+ "Command": "echo 'window 2'",
+ "Commands": null,
+ "Env": {
+ "TMPL_TEST_SESS_ENV": "overwrite",
+ "TMPL_TEST_WIN_2_ENV": "true"
+ },
+ "Panes": [
+ {
+ "Env": {
+ "TMPL_TEST_WIN_2": "overwrite",
+ "TMPL_TEST_WIN_2_PANE_1": "true"
+ },
+ "Path": "/Users/johndoe/project/subdir/subdir2",
+ "Command": "",
+ "Commands": null,
+ "Size": "",
+ "Horizontal": true,
+ "Panes": null,
+ "Active": false
+ },
+ {
+ "Env": {
+ "TMPL_TEST_SESS_ENV": "overwrite"
+ },
+ "Path": "/Users/johndoe/project/subdir",
+ "Command": "",
+ "Commands": null,
+ "Size": "",
+ "Horizontal": false,
+ "Panes": null,
+ "Active": false
+ }
+ ],
+ "Active": false
+ }
+ ]
+ },
+ "Tmux": "/usr/bin/other_tmux",
+ "TmuxOptions": [
+ "-f",
+ "/Users/johndoe/other_tmux.conf"
+ ]
+}
diff --git a/config/testdata/golden/TestFromFile/minimal_config.golden.json b/config/testdata/golden/TestFromFile/minimal_config.golden.json
new file mode 100644
index 0000000..a5889bc
--- /dev/null
+++ b/config/testdata/golden/TestFromFile/minimal_config.golden.json
@@ -0,0 +1,23 @@
+{
+ "Session": {
+ "Name": "project",
+ "Path": "/Users/johndoe/project",
+ "OnWindow": "",
+ "OnPane": "",
+ "OnAny": "",
+ "Env": null,
+ "Windows": [
+ {
+ "Name": "test",
+ "Path": "/Users/johndoe/project",
+ "Command": "",
+ "Commands": null,
+ "Env": null,
+ "Panes": null,
+ "Active": false
+ }
+ ]
+ },
+ "Tmux": "",
+ "TmuxOptions": null
+}
diff --git a/config/testdata/golden/TestFromFile/tilde_home_paths.golden.json b/config/testdata/golden/TestFromFile/tilde_home_paths.golden.json
new file mode 100644
index 0000000..7654332
--- /dev/null
+++ b/config/testdata/golden/TestFromFile/tilde_home_paths.golden.json
@@ -0,0 +1,34 @@
+{
+ "Session": {
+ "Name": "tmpl_test",
+ "Path": "/Users/johndoe/project",
+ "OnWindow": "",
+ "OnPane": "",
+ "OnAny": "",
+ "Env": null,
+ "Windows": [
+ {
+ "Name": "",
+ "Path": "/Users/johndoe/project/subdir",
+ "Command": "",
+ "Commands": null,
+ "Env": null,
+ "Panes": [
+ {
+ "Env": null,
+ "Path": "/Users/johndoe/project/subdir/subdir2",
+ "Command": "",
+ "Commands": null,
+ "Size": "",
+ "Horizontal": false,
+ "Panes": null,
+ "Active": false
+ }
+ ],
+ "Active": false
+ }
+ ]
+ },
+ "Tmux": "",
+ "TmuxOptions": null
+}
diff --git a/config/testdata/invalid-pane-bad-env.yaml b/config/testdata/invalid-pane-bad-env.yaml
new file mode 100644
index 0000000..1122a26
--- /dev/null
+++ b/config/testdata/invalid-pane-bad-env.yaml
@@ -0,0 +1,8 @@
+# Invalid configuration: Environment variables must only contain uppercase
+# alphanumeric and underscores.
+---
+session:
+ windows:
+ - panes:
+ - env:
+ INVALID-SESSION-NAME: "true"
diff --git a/config/testdata/invalid-pane-path-not-exist.yaml b/config/testdata/invalid-pane-path-not-exist.yaml
new file mode 100644
index 0000000..fb95c12
--- /dev/null
+++ b/config/testdata/invalid-pane-path-not-exist.yaml
@@ -0,0 +1,7 @@
+# Invalid configuration: Pane specifies a path that does not exist.
+---
+session:
+ windows:
+ - name: "window"
+ panes:
+ - path: "/tmp/tmpl/test/invalid-pane-path-notfound"
diff --git a/config/testdata/invalid-session-bad-env.yaml b/config/testdata/invalid-session-bad-env.yaml
new file mode 100644
index 0000000..442bac2
--- /dev/null
+++ b/config/testdata/invalid-session-bad-env.yaml
@@ -0,0 +1,8 @@
+# Invalid configuration: Environment variables must only contain uppercase
+# alphanumeric and underscores.
+---
+session:
+ windows:
+ - name: "window"
+ env:
+ "$INVALID_SESSION_NAME": "true"
diff --git a/config/testdata/invalid-session-bad-name.yaml b/config/testdata/invalid-session-bad-name.yaml
new file mode 100644
index 0000000..d06680a
--- /dev/null
+++ b/config/testdata/invalid-session-bad-name.yaml
@@ -0,0 +1,7 @@
+# Invalid configuration: Session names can only consist of alphanumeric and
+# underscores.
+---
+session:
+ name: "Hello, World!"
+ windows:
+ - name: "window"
diff --git a/config/testdata/invalid-session-path-not-exist.yaml b/config/testdata/invalid-session-path-not-exist.yaml
new file mode 100644
index 0000000..a45a5b3
--- /dev/null
+++ b/config/testdata/invalid-session-path-not-exist.yaml
@@ -0,0 +1,4 @@
+# Invalid configuration: Session specifies a path that does not exist.
+---
+session:
+ path: "/tmp/tmpl/test/invalid-session-path-notfound"
diff --git a/config/testdata/invalid-tmux-not-exist.yaml b/config/testdata/invalid-tmux-not-exist.yaml
new file mode 100644
index 0000000..c5849b5
--- /dev/null
+++ b/config/testdata/invalid-tmux-not-exist.yaml
@@ -0,0 +1,6 @@
+# Invalid configuration: Specified tmux executable does not exist.
+---
+tmux: "/usr/local/bin/tmpl-test-tmux-not-exist"
+session:
+ windows:
+ - name: "window"
diff --git a/config/testdata/invalid-window-bad-env.yaml b/config/testdata/invalid-window-bad-env.yaml
new file mode 100644
index 0000000..f32607e
--- /dev/null
+++ b/config/testdata/invalid-window-bad-env.yaml
@@ -0,0 +1,7 @@
+# Invalid configuration: Environment variables must only contain uppercase
+# alphanumeric and underscores.
+---
+session:
+ windows:
+ - env:
+ invalid_session_name: "true"
diff --git a/config/testdata/invalid-window-bad-name.yaml b/config/testdata/invalid-window-bad-name.yaml
new file mode 100644
index 0000000..32aec0a
--- /dev/null
+++ b/config/testdata/invalid-window-bad-name.yaml
@@ -0,0 +1,6 @@
+# Invalid configuration: Window names can only consist of alphanumeric and
+# underscores.
+---
+session:
+ windows:
+ - name: "Hello, World!"
diff --git a/config/testdata/invalid-window-path-not-exist.yaml b/config/testdata/invalid-window-path-not-exist.yaml
new file mode 100644
index 0000000..9c77f10
--- /dev/null
+++ b/config/testdata/invalid-window-path-not-exist.yaml
@@ -0,0 +1,5 @@
+# Invalid configuration: Window specifies a path that does not exist.
+---
+session:
+ windows:
+ - path: "/tmp/tmpl/test/invalid-window-path-notfound"
diff --git a/config/testdata/minimal.yaml b/config/testdata/minimal.yaml
new file mode 100644
index 0000000..f069cd1
--- /dev/null
+++ b/config/testdata/minimal.yaml
@@ -0,0 +1,4 @@
+---
+session:
+ windows:
+ - name: "test"
diff --git a/config/testdata/tilde.yaml b/config/testdata/tilde.yaml
new file mode 100644
index 0000000..cf51e1f
--- /dev/null
+++ b/config/testdata/tilde.yaml
@@ -0,0 +1,8 @@
+---
+session:
+ name: "tmpl_test"
+ path: "~/project"
+ windows:
+ - path: "~/project/subdir"
+ panes:
+ - path: "~/project/subdir/subdir2"
diff --git a/config/validation.go b/config/validation.go
new file mode 100644
index 0000000..af26cae
--- /dev/null
+++ b/config/validation.go
@@ -0,0 +1,126 @@
+package config
+
+import (
+ "fmt"
+ "regexp"
+
+ "github.com/invopop/validation"
+
+ "github.com/michenriksen/tmpl/internal/rulefuncs"
+)
+
+const errorTag = "yaml"
+
+var envVarRE = regexp.MustCompile(`^[A-Z_][A-Z0-9_]+$`)
+
+var nameMatchRule = validation.Match(regexp.MustCompile(`^[\w._-]+$`)).
+ Error("must only contain alphanumeric characters, underscores, dots, and dashes")
+
+// Validate validates the configuration.
+//
+// It checks that:
+//
+// - tmux executable exists
+// - session is valid (see [SessionConfig.Validate])
+//
+// If any of the above checks fail, an error is returned.
+func (c Config) Validate() error {
+ validation.ErrorTag = errorTag
+
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.Tmux, validation.By(rulefuncs.ExecutableExists)),
+ validation.Field(&c.Session, validation.Required),
+ )
+}
+
+// Validate validates the session configuration.
+//
+// It checks that:
+//
+// - session name only contains alphanumeric characters, underscores, dots,
+// and dashes
+// - session path exists
+// - session environment variable names are valid
+// - windows are valid (see [WindowConfig.Validate])
+//
+// If any of the above checks fail, an error is returned.
+func (s SessionConfig) Validate() error {
+ validation.ErrorTag = errorTag
+
+ return validation.ValidateStruct(&s,
+ validation.Field(&s.Name, nameMatchRule),
+ validation.Field(&s.Path, validation.By(rulefuncs.DirExists)),
+ validation.Field(&s.Env, validation.By(envVarMapRule)),
+ validation.Field(&s.Windows),
+ )
+}
+
+// Validate validates the window configuration.
+//
+// It checks that:
+//
+// - window name only contains alphanumeric characters, underscores, dots,
+// and dashes
+// - window path exists
+// - window environment variable names are valid
+// - panes are valid (see [PaneConfig.Validate])
+//
+// If any of the above checks fail, an error is returned.
+func (w WindowConfig) Validate() error {
+ validation.ErrorTag = errorTag
+
+ return validation.ValidateStruct(&w,
+ validation.Field(&w.Name, nameMatchRule),
+ validation.Field(&w.Path, validation.By(rulefuncs.DirExists)),
+ validation.Field(&w.Env, validation.By(envVarMapRule)),
+ validation.Field(&w.Command, validation.Length(1, 0)),
+ validation.Field(&w.Commands,
+ validation.Each(validation.Length(1, 0)),
+ ),
+ validation.Field(&w.Panes),
+ )
+}
+
+// Validate validates the pane configuration.
+//
+// It checks that:
+//
+// - pane path exists
+// - pane environment variable names are valid
+// - panes are valid
+//
+// If any of the above checks fail, an error is returned.
+func (p PaneConfig) Validate() error {
+ validation.ErrorTag = errorTag
+
+ return validation.ValidateStruct(&p,
+ validation.Field(&p.Path, validation.By(rulefuncs.DirExists)),
+ validation.Field(&p.Env, validation.By(envVarMapRule)),
+ validation.Field(&p.Command, validation.Length(1, 0)),
+ validation.Field(&p.Commands,
+ validation.Each(validation.Length(1, 0)),
+ ),
+ validation.Field(&p.Panes),
+ )
+}
+
+// envVarMapRule validates that all keys in a map are valid environment
+// variable names (i.e. uppercase letters, numbers and underscores).
+func envVarMapRule(val any) error {
+ if val == nil {
+ return nil
+ }
+
+ m, ok := val.(map[string]string)
+ if !ok {
+ return validation.ErrNotMap
+ }
+
+ for k := range m {
+ if !envVarRE.MatchString(k) {
+ return fmt.Errorf("%q is not a valid environment variable name", k)
+ }
+ }
+
+ return nil
+}
diff --git a/config/validation_test.go b/config/validation_test.go
new file mode 100644
index 0000000..96ff829
--- /dev/null
+++ b/config/validation_test.go
@@ -0,0 +1,93 @@
+package config_test
+
+import (
+ "testing"
+
+ "gopkg.in/yaml.v3"
+
+ "github.com/michenriksen/tmpl/config"
+ "github.com/michenriksen/tmpl/internal/testutils"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestConfig_Validate(t *testing.T) {
+ tt := []struct {
+ name string
+ file string
+ assertErr testutils.ErrorAssertion
+ }{
+ {
+ "non-existent tmux executable",
+ "invalid-tmux-not-exist.yaml",
+ testutils.RequireErrorContains("executable file was not found"),
+ },
+ {
+ "session with invalid name",
+ "invalid-session-bad-name.yaml",
+ testutils.RequireErrorContains("must only contain alphanumeric characters, underscores, dots, and dashes"),
+ },
+ {
+ "session with non-existent path",
+ "invalid-session-path-not-exist.yaml",
+ testutils.RequireErrorContains("directory does not exist"),
+ },
+ {
+ "session with invalid env",
+ "invalid-session-bad-env.yaml",
+ testutils.RequireErrorContains("is not a valid environment variable name"),
+ },
+ {
+ "window with invalid name",
+ "invalid-window-bad-name.yaml",
+ testutils.RequireErrorContains("must only contain alphanumeric characters, underscores, dots, and dashes"),
+ },
+ {
+ "window with non-existent path",
+ "invalid-window-path-not-exist.yaml",
+ testutils.RequireErrorContains("directory does not exist"),
+ },
+ {
+ "window with invalid env",
+ "invalid-window-bad-env.yaml",
+ testutils.RequireErrorContains("is not a valid environment variable name"),
+ },
+ {
+ "pane with non-existent path",
+ "invalid-pane-path-not-exist.yaml",
+ testutils.RequireErrorContains("directory does not exist"),
+ },
+ {
+ "pane with invalid env",
+ "invalid-pane-bad-env.yaml",
+ testutils.RequireErrorContains("is not a valid environment variable name"),
+ },
+ }
+
+ for _, tc := range tt {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := loadConfig(t, tc.file)
+ err := cfg.Validate()
+
+ if tc.assertErr != nil {
+ tc.assertErr(t, err)
+ return
+ }
+
+ require.NoError(t, err, "expected valid configuration")
+ })
+ }
+}
+
+func loadConfig(t *testing.T, file string) *config.Config {
+ t.Helper()
+
+ data := testutils.ReadFile(t, "testdata", file)
+
+ var cfg config.Config
+ if err := yaml.Unmarshal(data, &cfg); err != nil {
+ t.Fatalf("error decoding content of %s: %v", file, err)
+ }
+
+ return &cfg
+}
diff --git a/docs/.tmpl.reference.yaml b/docs/.tmpl.reference.yaml
new file mode 100644
index 0000000..0916fa9
--- /dev/null
+++ b/docs/.tmpl.reference.yaml
@@ -0,0 +1,226 @@
+# An annotated reference configuration showing all possible options.
+---
+## tmux executable.
+#
+# The tmux executable to use. Must be an absolute path, or available in $PATH.
+#
+# Default: "tmux"
+tmux: /usr/bin/other_tmux
+
+## tmux command line options.
+#
+# Additional tmux command line options to add to all tmux command invocations.
+#
+# Default: none.
+tmux_options: ["-L", "my_socket"]
+
+## Session configuration.
+#
+# Describes how the tmux session should be created.
+session:
+
+ ## Session name.
+ #
+ # Must only contain alphanumeric characters, underscores, and dashes.
+ #
+ # Default: current working directory base name.
+ name: "my_session"
+
+ ## Session path.
+ #
+ # The directory path used as the working directory for the session.
+ #
+ # The path is passed down to windows and panes but can be overridden at any
+ # level. If the path begins with '~', it will be automatically expanded to
+ # the current user's home directory.
+ #
+ # Default: current working directory.
+ path: "~/projects/my_project"
+
+ ## Session environment variables.
+ #
+ # Environment variables to automatically set up for the session.
+ #
+ # Environment variables are passed down to windows and panes, but can be
+ # overridden at any level.
+ #
+ # Default: none.
+ env:
+ APP_ENV: development
+ DEBUG: true
+ HTTP_PORT: 8080
+
+ ## On-window shell command.
+ #
+ # A shell command to run in every window after creation.
+ #
+ # This is intended for any kind of project setup that should be run before
+ # any other commands. The command is run using the `send-keys` tmux command.
+ #
+ # Default: none.
+ on_window: echo 'on_window'
+
+ ## On-pane shell command.
+ #
+ # A shell command to run in every pane after creation.
+ #
+ # This is intended for any kind of project setup that should be run before
+ # any other commands. The command is run using the `send-keys` tmux command.
+ #
+ # Default: none.
+ on_pane: echo 'on_pane'
+
+ ## On-window/pane shell command.
+ #
+ # A shell command to run in every window and pane after creation.
+ #
+ # This is intended for any kind of project setup that should be run before
+ # any other commands. The command is run using the `send-keys` tmux command.
+ #
+ # If on_window or on_pane commands are also specified, this command will run
+ # first.
+ #
+ # Default: none.
+ on_any: echo 'on_any'
+
+ ## Window configurations.
+ #
+ # A list of configurations for tmux windows to create in the session.
+ #
+ # The first configuration will be used for the default window created when
+ # the session is created.
+ #
+ # Default: A single window using tmux defaults.
+ windows:
+ ## Window name.
+ #
+ # Must only contain alphanumeric characters, underscores, and dashes.
+ #
+ # Default: tmux default window name.
+ - name: my_window
+
+ ## Window path.
+ #
+ # The directory path used as the working directory for the window.
+ #
+ # The path is passed down to panes but can be overridden. If the path
+ # begins with '~', it will be automatically expanded to the current
+ # user's home directory.
+ #
+ # Default: same as session.
+ path: "~/projects/my_project/subdir"
+
+ ## Window shell command.
+ #
+ # A shell command to run in the window after creation. Useful for
+ # starting your editor or a script you want to have running right away.
+ #
+ # Default: none.
+ command: echo 'my_window'
+
+ ## Window shell commands.
+ #
+ # A list of shell commands to run in the window in the order they are
+ # listed.
+ #
+ # Default: none.
+ commands:
+ - echo 'hello'
+ - echo 'from'
+ - echo 'my_window'
+
+ ## Window environment variables.
+ #
+ # Additional environment variables to automatically set up for the window.
+ #
+ # Environment variables are passed down to panes, but can be overridden.
+ #
+ # Default: same as session.
+ env:
+ APP_ENV: testing
+ WARP_CORE: true
+
+ ## Active window.
+ #
+ # Setting active to true will make it the active, selected window.
+ #
+ # If no windows are explicitly set as active, the first window will be
+ # selected
+ #
+ # Default: false
+ active: true
+
+ ## Pane configurations.
+ #
+ # A list of configurations for panes to create in the window.
+ #
+ # Default: none.
+ panes:
+ ## Pane path.
+ #
+ # The directory path used as the working directory for the pane.
+ #
+ # Default: same as window.
+ - path: "~/projects/my_project/other/subdir"
+
+ ## Pane environment variables.
+ #
+ # Additional environment variables to automatically set up for the
+ # pane.
+ #
+ # Default: same as window.
+ env:
+ WARP_CORE: false
+
+ ## Active pane.
+ #
+ # Setting active to true will make it the active, selected pane.
+ #
+ # If no panes are explicitly set as active, the first pane will be
+ # selected
+ #
+ # Default: false
+ active: true
+
+ ## Pane shell command.
+ #
+ # A shell command to run in the pane after creation. Useful for
+ # starting your editor or a script you want to have running right
+ # away.
+ #
+ # Default: none.
+ command: echo 'my_pane'
+
+ ## Pane shell commands.
+ #
+ # A list of shell commands to run in the pane in the order they are
+ # listed.
+ #
+ # Default: none.
+ commands:
+ - echo 'hello'
+ - echo 'from'
+ - echo 'my_pane'
+
+ ## Sub-pane configurations.
+ #
+ # A list of configurations for panes to create inside the pane.
+ #
+ # Nesting of panes can be as deep as you want, but you should probably
+ # stick to a sensible nesting level to keep it maintainable.
+ #
+ # Default: none.
+ panes:
+ - path: "~/projects/my_project/other/subdir"
+ env:
+ WARP_CORE: true
+ active: true
+ command: echo 'my_sub_pane'
+ commands:
+ - echo 'hello'
+ - echo 'from'
+ - echo 'sub_pane'
+
+## These lines configure editors to be more helpful (optional)
+# yaml-language-server: $schema=https://raw.githubusercontent.com/michenriksen/tmpl/main/config.schema.json
+# vim: set ts=2 sw=2 tw=0 fo=cnqoj
diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102.woff b/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102.woff
new file mode 100644
index 0000000..e7f8977
Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102.woff differ
diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102a.woff2 b/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102a.woff2
new file mode 100644
index 0000000..19a58ea
Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102a.woff2 differ
diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102.woff b/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102.woff
new file mode 100644
index 0000000..d6421ac
Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102.woff differ
diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102a.woff2 b/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102a.woff2
new file mode 100644
index 0000000..43f253e
Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102a.woff2 differ
diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102.woff b/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102.woff
new file mode 100644
index 0000000..12d2d8c
Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102.woff differ
diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102a.woff2 b/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102a.woff2
new file mode 100644
index 0000000..d35d3a7
Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102a.woff2 differ
diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102.woff b/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102.woff
new file mode 100644
index 0000000..bbe09c5
Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102.woff differ
diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102a.woff2 b/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102a.woff2
new file mode 100644
index 0000000..99b3c6f
Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102a.woff2 differ
diff --git a/docs/assets/images/banner-dark.png b/docs/assets/images/banner-dark.png
new file mode 100644
index 0000000..9679ccb
Binary files /dev/null and b/docs/assets/images/banner-dark.png differ
diff --git a/docs/assets/images/banner-light.png b/docs/assets/images/banner-light.png
new file mode 100644
index 0000000..03b8454
Binary files /dev/null and b/docs/assets/images/banner-light.png differ
diff --git a/docs/assets/images/launcher-icons.png b/docs/assets/images/launcher-icons.png
new file mode 100644
index 0000000..e00852d
Binary files /dev/null and b/docs/assets/images/launcher-icons.png differ
diff --git a/docs/assets/images/social-preview-github.png b/docs/assets/images/social-preview-github.png
new file mode 100644
index 0000000..bd566c1
Binary files /dev/null and b/docs/assets/images/social-preview-github.png differ
diff --git a/docs/assets/images/social-preview.png b/docs/assets/images/social-preview.png
new file mode 100644
index 0000000..c5a8330
Binary files /dev/null and b/docs/assets/images/social-preview.png differ
diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg
new file mode 100644
index 0000000..447838b
--- /dev/null
+++ b/docs/assets/logo.svg
@@ -0,0 +1,3 @@
+
diff --git a/docs/assets/stylesheets/extra.css b/docs/assets/stylesheets/extra.css
new file mode 100644
index 0000000..d886d7a
--- /dev/null
+++ b/docs/assets/stylesheets/extra.css
@@ -0,0 +1,68 @@
+/* Atkinson Hyperlegible font: https://brailleinstitute.org/freefont. */
+@font-face {
+ font-family: "Atkinson Hyperlegible";
+ src:
+ url("../fonts/Atkinson-Hyperlegible-Regular-102a.woff2") format("woff2"),
+ url("../fonts/Atkinson-Hyperlegible-Regular-102.woff") format("woff");
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Atkinson Hyperlegible";
+ src:
+ url("../fonts/Atkinson-Hyperlegible-Bold-102a.woff2") format("woff2"),
+ url("../fonts/Atkinson-Hyperlegible-Bold-102.woff") format("woff");
+ font-weight: bold;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Atkinson Hyperlegible";
+ src:
+ url("../fonts/Atkinson-Hyperlegible-Italic-102a.woff2") format("woff2"),
+ url("../fonts/Atkinson-Hyperlegible-Italic-102.woff") format("woff");
+ font-weight: normal;
+ font-style: italic;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Atkinson Hyperlegible";
+ src:
+ url("../fonts/Atkinson-Hyperlegible-BoldItalic-102a.woff2") format("woff2"),
+ url("../fonts/Atkinson-Hyperlegible-BoldItalic-102.woff") format("woff");
+ font-weight: bold;
+ font-style: italic;
+ font-display: swap;
+}
+
+:root {
+ --md-text-font: "Atkinson Hyperlegible", sans-serif;
+ --md-code-font: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
+}
+
+.md-typeset video {
+ border-radius: 5px;
+}
+
+.next-cta {
+ margin-top: 3rem;
+ text-align: right;
+}
+
+.footer-banner {
+ width: 200px;
+ display: block;
+ margin: auto .6rem;
+ padding: .7rem 0 .4rem 0;
+}
+
+.footer-banner svg {
+ width: 100%;
+ height: auto;
+ fill: var(--md-footer-fg-color--light);
+ stroke: 0;
+}
diff --git a/docs/assets/videos/demo.mp4 b/docs/assets/videos/demo.mp4
new file mode 100644
index 0000000..00e545a
Binary files /dev/null and b/docs/assets/videos/demo.mp4 differ
diff --git a/docs/assets/videos/demo.webm b/docs/assets/videos/demo.webm
new file mode 100644
index 0000000..47d9b1f
Binary files /dev/null and b/docs/assets/videos/demo.webm differ
diff --git a/docs/assets/videos/launcher.mp4 b/docs/assets/videos/launcher.mp4
new file mode 100644
index 0000000..f84caf5
Binary files /dev/null and b/docs/assets/videos/launcher.mp4 differ
diff --git a/docs/assets/videos/launcher.webm b/docs/assets/videos/launcher.webm
new file mode 100644
index 0000000..a65d6ef
Binary files /dev/null and b/docs/assets/videos/launcher.webm differ
diff --git a/docs/attribution.md b/docs/attribution.md
new file mode 100644
index 0000000..93448b6
--- /dev/null
+++ b/docs/attribution.md
@@ -0,0 +1,23 @@
+# Attribution
+
+Tmpl uses the following graphics in its logo and documentation:
+
+## *Layouts* by [DTDesign] from Noun Project
+
+- **Icon #1521277**
+ - Source:
+ - License: [CC BY 3.0 Deed]
+- **Icon #1521304**
+ - Source:
+ - License: [CC BY 3.0 Deed]
+- **Icon #1521306**
+ - Source:
+ - License: [CC BY 3.0 Deed]
+ - Modifications: Flipped horizontally
+- **Icon #1521308**
+ - Source:
+ - License: [CC BY 3.0 Deed]
+ - Modifications: Rotated 90° counterclockwise
+
+[DTDesign]: https://thenounproject.com/dowt.design/
+[CC BY 3.0 Deed]: https://creativecommons.org/licenses/by/3.0/
diff --git a/docs/cli-usage.txt b/docs/cli-usage.txt
new file mode 100644
index 0000000..b3ee31f
--- /dev/null
+++ b/docs/cli-usage.txt
@@ -0,0 +1,28 @@
+Usage: tmpl [command] [options] [args]
+
+Simple tmux session management.
+
+Available commands:
+
+ apply (default) apply configuration and attach session
+ check validate configuration file
+ init generate a new configuration file
+
+Global options:
+
+ -d, --debug enable debug logging
+ -h, --help show this message and exit
+ -j, --json enable JSON logging
+ -q, --quiet enable quiet logging
+ -v, --version show the version and exit
+
+Examples:
+
+ # apply nearest configuration file and attach/switch client to session:
+ $ tmpl
+
+ # or explicitly:
+ $ tmpl -c /path/to/config.yaml
+
+ # generate a new configuration file in the current working directory:
+ $ tmpl init
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..a23a34e
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,169 @@
+---
+icon: material/cog
+---
+
+# Configuring your session
+
+After you've [installed tmpl](getting-started.md), you can create your session configuration using the `tmpl init`
+command. This creates a basic `.tmpl.yaml` configuration file in the current directory.
+
+```console title="Creating a new configuration file"
+user@host:~/project$ tmpl init
+13:37:00 INF configuration file created path=/home/user/project/.tmpl.yaml
+
+```
+
+The file sets you up with a session named after the current directory with a single window called main:
+
+```yaml title=".tmpl.yaml"
+# Note: the real file has helpful comments which are omitted for brevity.
+---
+session:
+ name: project
+
+ windows:
+ - name: main
+```
+
+This may be all you need for a simple project, but to get the most out of tmpl you'll want to customize your session to
+set up as much of your development environment as possible. The following sections describe how to use the options to
+bootstrap a more interesting session.
+
+## Windows and panes
+
+Tmpl sessions can have as many windows and panes as you need for your workflow. This example configures the session
+with two windows, one named `code` and another named `shell`. The `code` window is also configured to have a pane at the
+bottom with a height of 20% of the available space.
+
+```yaml title=".tmpl.yaml" hl_lines="5 6 7 9"
+session:
+ name: project
+
+ windows:
+ - name: code
+ panes:
+ - size: 20%
+
+ - name: shell
+```
+
+## Commands
+
+It's possible to configure commands to automatically run in each window and pane. This example builds on the previous by
+configuring the `code` window to automatically start Neovim and its bottom pane to run a fictitious test watcher. The
+`shell` window is configured to run git status.
+
+```yaml title=".tmpl.yaml" hl_lines="6 9 12"
+session:
+ name: project
+
+ windows:
+ - name: code
+ command: nvim .
+ panes:
+ - size: 20%
+ command: ./scripts/test-watcher
+
+ - name: shell
+ command: git status
+```
+
+!!! tip "Tip: multiple commands"
+ Tmpl also supports running a sequence of commands if needed. Each command is sent to the window or pane using the
+ [tmux send-keys][send-keys] command. This means that it also works when connecting to remote systems:
+
+ ```yaml title=".tmpl.yaml"
+ session:
+ windows:
+ - name: server-logs
+ commands:
+ - ssh user@remote.host
+ - cd /var/logs
+ - tail -f app.log
+ ```
+
+## Environment variables
+
+Setting up a development environment often involves configuring environment variables. Tmpl allows you to set
+environment variables at different levels, such as session, window, and pane. These variables cascade from session to
+window to pane, making it easy to set a variable once and make changes at any level.
+
+This example builds on the previous by setting up a few environment variables at different levels. `APP_ENV` and `DEBUG`
+are set at the session level and cascade to all windows and panes. The pane overrides `APP_ENV` to `test` to run tests
+in the correct environment. Finally, the shell window overrides `HISTFILE` to maintain a project-specific command
+history file.
+
+```yaml title=".tmpl.yaml" hl_lines="3 4 5 13 14 18 19"
+session:
+ name: project
+ env:
+ APP_ENV: development
+ DEBUG: true
+
+ windows:
+ - name: code
+ command: nvim .
+ panes:
+ - size: 20%
+ command: ./scripts/test-watcher
+ env:
+ APP_ENV: test
+
+ - name: shell
+ command: git status
+ env:
+ HISTFILE: ~/project/command-history
+```
+
+## Hook commands
+
+Another frequent step in setting up a development environment involves executing project-specific initialization
+commands. These commands can range from activating a virtual environment to switching between different language runtime
+versions. Tmpl lets you configure commands that run in every window, pane, or both, when they're created.
+
+This example builds on the previous by setting up a hook command to run a fictitious script in every window and pane.
+
+```yaml title=".tmpl.yaml" hl_lines="7 8"
+session:
+ name: project
+ env:
+ APP_ENV: development
+ DEBUG: true
+
+ # Run the init-env script in every window and pane.
+ on_any: ./scripts/init-env
+
+ windows:
+ - name: code
+ command: nvim .
+ panes:
+ - size: 20%
+ command: ./scripts/test-watcher
+ env:
+ APP_ENV: test
+
+ - name: shell
+ command: git status
+ env:
+ HISTFILE: ~/project/command-history
+```
+
+!!! tip "Tip: on window and on pane hooks"
+ It's also possible to specify hooks that only run in window or pane contexts for more granular control:
+
+ ```yaml title=".tmpl.yaml"
+ session:
+ on_window: ./scripts/init-window
+ on_pane: ./scripts/init-pane
+ ```
+
+## More options
+
+This wraps up the basic configuration options for tmpl. You can find more details on the available options in the
+[configuration reference](reference.md) section if you want to learn more.
+
+
+[Next: launching your session :material-arrow-right-circle:](usage.md){ .md-button .md-button--primary }
+
+
+[send-keys]: https://man.archlinux.org/man/tmux.1#send-keys
diff --git a/docs/env-variables.md b/docs/env-variables.md
new file mode 100644
index 0000000..262609a
--- /dev/null
+++ b/docs/env-variables.md
@@ -0,0 +1,3 @@
+---
+icon: material/application-variable-outline
+---
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..e2f0852
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,62 @@
+---
+icon: material/download
+---
+
+# Getting started with tmpl
+
+This guide walks you through the process of installing tmpl and creating your first session configuration file. Tmpl
+is written in Go and distributed as a single, stand-alone binary with no external dependencies other than tmux, so
+there is no language runtime or package managers to install.
+
+!!! info "Beta software"
+ Tmpl is currently in beta. It's usable, but there are still some rough edges. It's possible that breaking changes
+ are introduced while tmpl is below version 1.0.0. If you find any bugs, please [open an issue][new-issue], and if
+ you have any suggestions or feature requests, please start a new [idea discussion][new-idea].
+
+## Installation
+
+### Binaries
+
+Go to the [releases page] and download the latest version for your operating system. Unpack the archive and move the
+`tmpl` binary to a directory in your PATH, for example, `/usr/local/bin`:
+
+```console title="Installing the binary"
+user@host:~$ tar xzf tmpl_*.tar.gz
+user@host:~$ sudo mv tmpl /usr/local/bin/
+```
+
+
+??? failure "macOS *'cannot be opened'* dialog"
+
+ macOS may prevent you from running the pre-compiled binary due to the built-in security feature called Gatekeeper.
+ This is because the binary isn't signed with an Apple Developer ID certificate.
+
+ **If you get an error message saying that the binary is from an unidentified developer or something similar, you can
+ allow it to run by doing one of the following:**
+
+ 1. :material-apple-finder: **Finder:** right-click the binary and select "Open" from the context menu and confirm
+ that you want to run the binary. Gatekeeper remembers your choice and allows you to run the binary in the
+ future.
+ 2. :material-console: **Terminal:** add the binary to the list of allowed applications by running the following
+ command:
+
+ ```console
+ user@host:~$ spctl --add /path/to/tmpl
+ ```
+
+
+### From source
+
+If you have Go installed, you can also install tmpl from source:
+
+```console title="Installing from source"
+user@host:~$ go install {{ package }}@latest
+```
+
+
+[Next: configuring your session :material-arrow-right-circle:](configuration.md){ .md-button .md-button--primary }
+
+
+[new-issue]: <{{ repo_url }}/issues/new/choose>
+[new-idea]: <{{ repo_url }}/discussions/categories/ideas>
+[releases page]: <{{ repo_url }}/tmpl/releases>
diff --git a/docs/hook-commands.md b/docs/hook-commands.md
new file mode 100644
index 0000000..9f12f03
--- /dev/null
+++ b/docs/hook-commands.md
@@ -0,0 +1,3 @@
+---
+icon: material/application-cog-outline
+---
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..4d94ca6
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,30 @@
+# tmpl - simple tmux session management
+
+Tmpl streamlines your tmux workflow by letting you describe your sessions in simple YAML files and have them
+launched with all the tools your workflow requires set up and ready to go. If you often set up the same windows and
+panes for tasks like coding, running unit tests, tailing logs, and using other tools, tmpl can automate that for you.
+
+## Highlights
+
+- **Simple and versatile configuration:** easily set up your tmux sessions using straightforward YAML files, allowing
+ you to create as many windows and panes as needed. Customize session and window names, working directories, and
+ start-up commands.
+
+- **Inheritable environment variables:** define environment variables for your entire session, a specific window, or a
+ particular pane. These variables cascade from session to window to pane, enabling you to set a variable once and
+ modify it at any level.
+
+- **Custom hook commands:** customize your setup with on-window and on-pane hook commands that run when new windows,
+ panes, or both are created. This feature is useful for initializing a virtual environment or switching between
+ language runtime versions.
+
+- **Non-intrusive workflow:** while there are many excellent session managers out there, some of them tend to be quite
+ opinionated about how you should work with them. Tmpl allows configurations to live anywhere in your filesystem and
+ focuses only on launching your session. It's intended as a secondary companion, and not a full workflow replacement.
+
+- **Stand-alone binary:** Tmpl is a single, stand-alone binary with no external dependencies, except for tmux. It's easy
+ to install and doesn't require you to have a specific language runtime or package manager on your system.
+
+
+[Get started with tmpl :material-arrow-right-circle:](getting-started.md){ .md-button .md-button--primary }
+
diff --git a/docs/jsonschema.md b/docs/jsonschema.md
new file mode 100644
index 0000000..7a37e1a
--- /dev/null
+++ b/docs/jsonschema.md
@@ -0,0 +1,671 @@
+# Tmpl configuration
+
+**Title:** Tmpl configuration
+
+| | |
+| ------------------------- | --------------------------------------------------------- |
+| **Type** | `object` |
+| **Required** | No |
+| **Additional properties** | [\[Not allowed\]](# "Additional Properties not allowed.") |
+
+**Description:** A configuration file describing how a tmux session should be created.
+
+| Property | Pattern | Type | Deprecated | Definition | Title/Description |
+| ------------------------------- | ------- | --------------- | ---------- | ------------------------ | ---------------------------------------------------------------------- |
+| - [tmux](#tmux) | No | string | No | - | tmux executable |
+| - [tmux_options](#tmux_options) | No | array of string | No | - | tmux command line options |
+| - [session](#session) | No | object | No | In #/$defs/SessionConfig | Session configuration describing how a tmux session should be created. |
+
+## 1. Property `Tmpl configuration > tmux`
+
+**Title:** tmux executable
+
+| | |
+| ------------ | -------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Default** | `"tmux"` |
+
+**Description:** The tmux executable to use. Must be an absolute path, or available in $PATH.
+
+## 2. Property `Tmpl configuration > tmux_options`
+
+**Title:** tmux command line options
+
+| | |
+| ------------ | ----------------- |
+| **Type** | `array of string` |
+| **Required** | No |
+
+**Description:** Additional tmux command line options to add to all tmux command invocations.
+
+**Examples:**
+
+```yaml
+['-f', '/path/to/tmux.conf']
+```
+
+```yaml
+['-L', 'MySocket']
+```
+
+| | Array restrictions |
+| -------------------- | ------------------ |
+| **Min items** | N/A |
+| **Max items** | N/A |
+| **Items unicity** | False |
+| **Additional items** | False |
+| **Tuple validation** | See below |
+
+| Each item of this array must be | Description |
+| ----------------------------------------- | ------------------------------------------------------------------------------------- |
+| [tmux_options items](#tmux_options_items) | A tmux command line flag and its value, if any. See 'man tmux' for available options. |
+
+### 2.1. Tmpl configuration > tmux_options > tmux_options items
+
+| | |
+| ------------ | -------- |
+| **Type** | `string` |
+| **Required** | No |
+
+**Description:** A tmux command line flag and its value, if any. See `man tmux` for available options.
+
+## 3. Property `Tmpl configuration > session`
+
+| | |
+| ------------------------- | --------------------------------------------------------- |
+| **Type** | `object` |
+| **Required** | No |
+| **Additional properties** | [\[Not allowed\]](# "Additional Properties not allowed.") |
+| **Defined in** | #/$defs/SessionConfig |
+
+**Description:** Session configuration describing how a tmux session should be created.
+
+| Property | Pattern | Type | Deprecated | Definition | Title/Description |
+| --------------------------------- | ------- | ------ | ---------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| - [name](#session_name) | No | string | No | In #/$defs/name | A name for the tmux session or window. Must only contain alphanumeric characters, underscores, dots, and dashes |
+| - [path](#session_path) | No | string | No | In #/$defs/path | The directory path used as the working directory in a tmux session, window, or pane.The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory. |
+| - [env](#session_env) | No | object | No | In #/$defs/env | A list of environment variables to set in a tmux session, window, or pane.These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores. |
+| - [on_window](#session_on_window) | No | string | No | In #/$defs/command | On-Window shell command |
+| - [on_pane](#session_on_pane) | No | string | No | In #/$defs/command | On-Pane shell command |
+| - [on_any](#session_on_any) | No | string | No | In #/$defs/command | On-Window/Pane shell command |
+| - [windows](#session_windows) | No | array | No | - | Window configurations |
+
+### 3.1. Property `Tmpl configuration > session > name`
+
+| | |
+| -------------- | -------------------------------------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Default** | `"The current working directory base name."` |
+| **Defined in** | #/$defs/name |
+
+**Description:** A name for the tmux session or window. Must only contain alphanumeric characters, underscores, dots, and dashes
+
+| Restrictions | |
+| --------------------------------- | ----------------------------------------------------------------------- |
+| **Must match regular expression** | `^[\w._-]+$` [Test](https://regex101.com/?regex=%5E%5B%5Cw._-%5D%2B%24) |
+
+### 3.2. Property `Tmpl configuration > session > path`
+
+| | |
+| -------------- | ---------------------------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Default** | `"The current working directory."` |
+| **Defined in** | #/$defs/path |
+
+**Description:** The directory path used as the working directory in a tmux session, window, or pane.
+
+The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory.
+
+**Examples:**
+
+```yaml
+/path/to/project
+```
+
+```yaml
+~/path/to/project
+```
+
+```yaml
+relative/path/to/project
+```
+
+### 3.3. Property `Tmpl configuration > session > env`
+
+| | |
+| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| **Type** | `object` |
+| **Required** | No |
+| **Additional properties** | [\[Should-conform\]](#session_env_additionalProperties "Each additional property must conform to the following schema") |
+| **Defined in** | #/$defs/env |
+
+**Description:** A list of environment variables to set in a tmux session, window, or pane.
+
+These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores.
+
+**Example:**
+
+```yaml
+APP_ENV: development
+DEBUG: true
+HTTP_PORT: 8080
+
+```
+
+| Property | Pattern | Type | Deprecated | Definition | Title/Description |
+| --------------------------------------- | ------- | ------------------------- | ---------- | ---------- | ----------------- |
+| - [](#session_env_additionalProperties) | No | string, number or boolean | No | - | - |
+
+#### 3.3.1. Property `Tmpl configuration > session > env > additionalProperties`
+
+| | |
+| ------------ | --------------------------- |
+| **Type** | `string, number or boolean` |
+| **Required** | No |
+
+### 3.4. Property `Tmpl configuration > session > on_window`
+
+**Title:** On-Window shell command
+
+| | |
+| -------------- | --------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Defined in** | #/$defs/command |
+
+**Description:** A shell command to run first in all created windows. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command.
+
+| Restrictions | |
+| -------------- | --- |
+| **Min length** | 1 |
+
+### 3.5. Property `Tmpl configuration > session > on_pane`
+
+**Title:** On-Pane shell command
+
+| | |
+| -------------- | --------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Defined in** | #/$defs/command |
+
+**Description:** A shell command to run first in all created panes. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command.
+
+| Restrictions | |
+| -------------- | --- |
+| **Min length** | 1 |
+
+### 3.6. Property `Tmpl configuration > session > on_any`
+
+**Title:** On-Window/Pane shell command
+
+| | |
+| -------------- | --------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Defined in** | #/$defs/command |
+
+**Description:** A shell command to run first in all created windows and panes. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command.
+
+| Restrictions | |
+| -------------- | --- |
+| **Min length** | 1 |
+
+### 3.7. Property `Tmpl configuration > session > windows`
+
+**Title:** Window configurations
+
+| | |
+| ------------ | ------- |
+| **Type** | `array` |
+| **Required** | No |
+
+**Description:** A list of tmux window configurations to create in the session. The first configuration will be used for the default window.
+
+| | Array restrictions |
+| -------------------- | ------------------ |
+| **Min items** | N/A |
+| **Max items** | N/A |
+| **Items unicity** | False |
+| **Additional items** | False |
+| **Tuple validation** | See below |
+
+| Each item of this array must be | Description |
+| -------------------------------------- | -------------------------------------------------------------------- |
+| [WindowConfig](#session_windows_items) | Window configuration describing how a tmux window should be created. |
+
+#### 3.7.1. Tmpl configuration > session > windows > WindowConfig
+
+| | |
+| ------------------------- | --------------------------------------------------------------------------- |
+| **Type** | `object` |
+| **Required** | No |
+| **Additional properties** | [\[Any type: allowed\]](# "Additional Properties of any type are allowed.") |
+| **Defined in** | #/$defs/WindowConfig |
+
+**Description:** Window configuration describing how a tmux window should be created.
+
+| Property | Pattern | Type | Deprecated | Definition | Title/Description |
+| --------------------------------------------------------------------- | ------- | ------- | ---------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| - [name](#session_windows_items_name) | No | string | No | In #/$defs/name | A name for the tmux session or window. Must only contain alphanumeric characters, underscores, dots, and dashes |
+| - [path](#session_windows_items_path) | No | string | No | In #/$defs/path | The directory path used as the working directory in a tmux session, window, or pane.The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory. |
+| - [command](#session_windows_items_command) | No | string | No | In #/$defs/command | A shell command to run within a tmux window or pane.The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection. |
+| - [commands](#session_windows_items_commands) | No | array | No | In #/$defs/commands | A list of shell commands to run within a tmux window or pane in the order they are listed.If a command is also specified in the 'command' property, it will be run first. |
+| - [env](#session_windows_items_env) | No | object | No | In #/$defs/env | A list of environment variables to set in a tmux session, window, or pane.These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores. |
+| - [active](#session_windows_items_active) | No | boolean | No | In #/$defs/active | Whether a tmux window or pane should be selected after session creation. The first window and pane will be selected by default. |
+| - [panes](#session_windows_items_panes) | No | array | No | - | Pane configurations |
+| - [additionalProperties](#session_windows_items_additionalProperties) | No | object | No | - | - |
+
+##### 3.7.1.1. Property `Tmpl configuration > session > windows > Window configuration > name`
+
+| | |
+| -------------- | ---------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Default** | `"tmux default"` |
+| **Defined in** | #/$defs/name |
+
+**Description:** A name for the tmux session or window. Must only contain alphanumeric characters, underscores, dots, and dashes
+
+| Restrictions | |
+| --------------------------------- | ----------------------------------------------------------------------- |
+| **Must match regular expression** | `^[\w._-]+$` [Test](https://regex101.com/?regex=%5E%5B%5Cw._-%5D%2B%24) |
+
+##### 3.7.1.2. Property `Tmpl configuration > session > windows > Window configuration > path`
+
+| | |
+| -------------- | --------------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Default** | `"The session path."` |
+| **Defined in** | #/$defs/path |
+
+**Description:** The directory path used as the working directory in a tmux session, window, or pane.
+
+The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory.
+
+**Examples:**
+
+```yaml
+/path/to/project
+```
+
+```yaml
+~/path/to/project
+```
+
+```yaml
+relative/path/to/project
+```
+
+##### 3.7.1.3. Property `Tmpl configuration > session > windows > Window configuration > command`
+
+| | |
+| -------------- | --------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Defined in** | #/$defs/command |
+
+**Description:** A shell command to run within a tmux window or pane.
+
+The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection.
+
+| Restrictions | |
+| -------------- | --- |
+| **Min length** | 1 |
+
+##### 3.7.1.4. Property `Tmpl configuration > session > windows > Window configuration > commands`
+
+| | |
+| -------------- | ---------------- |
+| **Type** | `array` |
+| **Required** | No |
+| **Defined in** | #/$defs/commands |
+
+**Description:** A list of shell commands to run within a tmux window or pane in the order they are listed.
+
+If a command is also specified in the 'command' property, it will be run first.
+
+**Example:**
+
+```yaml
+['ssh user@host', 'cd /var/logs', 'tail -f app.log']
+```
+
+| | Array restrictions |
+| -------------------- | ------------------ |
+| **Min items** | N/A |
+| **Max items** | N/A |
+| **Items unicity** | False |
+| **Additional items** | False |
+| **Tuple validation** | See below |
+
+| Each item of this array must be | Description |
+| ------------------------------------------------ | -------------------------------------------------------- |
+| [command](#session_windows_items_commands_items) | A shell command to run within a tmux window or pane. ... |
+
+##### 3.7.1.4.1. Tmpl configuration > session > windows > Window configuration > commands > command
+
+| | |
+| -------------- | --------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Defined in** | #/$defs/command |
+
+**Description:** A shell command to run within a tmux window or pane.
+
+The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection.
+
+| Restrictions | |
+| -------------- | --- |
+| **Min length** | 1 |
+
+##### 3.7.1.5. Property `Tmpl configuration > session > windows > Window configuration > env`
+
+| | |
+| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| **Type** | `object` |
+| **Required** | No |
+| **Additional properties** | [\[Should-conform\]](#session_windows_items_env_additionalProperties "Each additional property must conform to the following schema") |
+| **Default** | `"The session env."` |
+| **Defined in** | #/$defs/env |
+
+**Description:** A list of environment variables to set in a tmux session, window, or pane.
+
+These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores.
+
+**Example:**
+
+```yaml
+APP_ENV: development
+DEBUG: true
+HTTP_PORT: 8080
+
+```
+
+| Property | Pattern | Type | Deprecated | Definition | Title/Description |
+| ----------------------------------------------------- | ------- | ------------------------- | ---------- | ---------- | ----------------- |
+| - [](#session_windows_items_env_additionalProperties) | No | string, number or boolean | No | - | - |
+
+##### 3.7.1.5.1. Property `Tmpl configuration > session > windows > Window configuration > env > additionalProperties`
+
+| | |
+| ------------ | --------------------------- |
+| **Type** | `string, number or boolean` |
+| **Required** | No |
+
+##### 3.7.1.6. Property `Tmpl configuration > session > windows > Window configuration > active`
+
+| | |
+| -------------- | -------------- |
+| **Type** | `boolean` |
+| **Required** | No |
+| **Default** | `false` |
+| **Defined in** | #/$defs/active |
+
+**Description:** Whether a tmux window or pane should be selected after session creation. The first window and pane will be selected by default.
+
+##### 3.7.1.7. Property `Tmpl configuration > session > windows > Window configuration > panes`
+
+**Title:** Pane configurations
+
+| | |
+| ------------ | ------- |
+| **Type** | `array` |
+| **Required** | No |
+
+**Description:** A list of tmux pane configurations to create in the window.
+
+| | Array restrictions |
+| -------------------- | ------------------ |
+| **Min items** | N/A |
+| **Max items** | N/A |
+| **Items unicity** | False |
+| **Additional items** | False |
+| **Tuple validation** | See below |
+
+| Each item of this array must be | Description |
+| ------------------------------------------------ | ---------------------------------------------------------------- |
+| [PaneConfig](#session_windows_items_panes_items) | Pane configuration describing how a tmux pane should be created. |
+
+##### 3.7.1.7.1. Tmpl configuration > session > windows > Window configuration > panes > PaneConfig
+
+| | |
+| ------------------------- | --------------------------------------------------------- |
+| **Type** | `object` |
+| **Required** | No |
+| **Additional properties** | [\[Not allowed\]](# "Additional Properties not allowed.") |
+| **Defined in** | #/$defs/PaneConfig |
+
+**Description:** Pane configuration describing how a tmux pane should be created.
+
+| Property | Pattern | Type | Deprecated | Definition | Title/Description |
+| ------------------------------------------------------------- | ------- | ------- | ---------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| - [path](#session_windows_items_panes_items_path) | No | string | No | In #/$defs/path | The directory path used as the working directory in a tmux session, window, or pane.The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory. |
+| - [command](#session_windows_items_panes_items_command) | No | string | No | In #/$defs/command | A shell command to run within a tmux window or pane.The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection. |
+| - [commands](#session_windows_items_panes_items_commands) | No | array | No | In #/$defs/commands | A list of shell commands to run within a tmux window or pane in the order they are listed.If a command is also specified in the 'command' property, it will be run first. |
+| - [env](#session_windows_items_panes_items_env) | No | object | No | In #/$defs/env | A list of environment variables to set in a tmux session, window, or pane.These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores. |
+| - [active](#session_windows_items_panes_items_active) | No | boolean | No | In #/$defs/active | Whether a tmux window or pane should be selected after session creation. The first window and pane will be selected by default. |
+| - [horizontal](#session_windows_items_panes_items_horizontal) | No | boolean | No | - | Horizontal split |
+| - [size](#session_windows_items_panes_items_size) | No | string | No | - | Size |
+| - [panes](#session_windows_items_panes_items_panes) | No | array | No | - | Pane configurations |
+
+##### 3.7.1.7.1.1. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > path`
+
+| | |
+| -------------- | -------------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Default** | `"The window path."` |
+| **Defined in** | #/$defs/path |
+
+**Description:** The directory path used as the working directory in a tmux session, window, or pane.
+
+The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory.
+
+**Examples:**
+
+```yaml
+/path/to/project
+```
+
+```yaml
+~/path/to/project
+```
+
+```yaml
+relative/path/to/project
+```
+
+##### 3.7.1.7.1.2. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > command`
+
+| | |
+| -------------- | --------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Defined in** | #/$defs/command |
+
+**Description:** A shell command to run within a tmux window or pane.
+
+The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection.
+
+| Restrictions | |
+| -------------- | --- |
+| **Min length** | 1 |
+
+##### 3.7.1.7.1.3. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > commands`
+
+| | |
+| -------------- | ---------------- |
+| **Type** | `array` |
+| **Required** | No |
+| **Defined in** | #/$defs/commands |
+
+**Description:** A list of shell commands to run within a tmux window or pane in the order they are listed.
+
+If a command is also specified in the 'command' property, it will be run first.
+
+**Example:**
+
+```yaml
+['ssh user@host', 'cd /var/logs', 'tail -f app.log']
+```
+
+| | Array restrictions |
+| -------------------- | ------------------ |
+| **Min items** | N/A |
+| **Max items** | N/A |
+| **Items unicity** | False |
+| **Additional items** | False |
+| **Tuple validation** | See below |
+
+| Each item of this array must be | Description |
+| ------------------------------------------------------------ | -------------------------------------------------------- |
+| [command](#session_windows_items_panes_items_commands_items) | A shell command to run within a tmux window or pane. ... |
+
+##### 3.7.1.7.1.3.1. Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > commands > command
+
+| | |
+| -------------- | --------------- |
+| **Type** | `string` |
+| **Required** | No |
+| **Defined in** | #/$defs/command |
+
+**Description:** A shell command to run within a tmux window or pane.
+
+The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection.
+
+| Restrictions | |
+| -------------- | --- |
+| **Min length** | 1 |
+
+##### 3.7.1.7.1.4. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > env`
+
+| | |
+| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Type** | `object` |
+| **Required** | No |
+| **Additional properties** | [\[Should-conform\]](#session_windows_items_panes_items_env_additionalProperties "Each additional property must conform to the following schema") |
+| **Default** | `"The window env."` |
+| **Defined in** | #/$defs/env |
+
+**Description:** A list of environment variables to set in a tmux session, window, or pane.
+
+These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores.
+
+**Example:**
+
+```yaml
+APP_ENV: development
+DEBUG: true
+HTTP_PORT: 8080
+
+```
+
+| Property | Pattern | Type | Deprecated | Definition | Title/Description |
+| ----------------------------------------------------------------- | ------- | ------------------------- | ---------- | ---------- | ----------------- |
+| - [](#session_windows_items_panes_items_env_additionalProperties) | No | string, number or boolean | No | - | - |
+
+##### 3.7.1.7.1.4.1. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > env > additionalProperties`
+
+| | |
+| ------------ | --------------------------- |
+| **Type** | `string, number or boolean` |
+| **Required** | No |
+
+##### 3.7.1.7.1.5. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > active`
+
+| | |
+| -------------- | -------------- |
+| **Type** | `boolean` |
+| **Required** | No |
+| **Default** | `false` |
+| **Defined in** | #/$defs/active |
+
+**Description:** Whether a tmux window or pane should be selected after session creation. The first window and pane will be selected by default.
+
+##### 3.7.1.7.1.6. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > horizontal`
+
+**Title:** Horizontal split
+
+| | |
+| ------------ | --------- |
+| **Type** | `boolean` |
+| **Required** | No |
+| **Default** | `false` |
+
+**Description:** Whether to split the window horizontally. If false, the window will be split vertically.
+
+##### 3.7.1.7.1.7. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > size`
+
+**Title:** Size
+
+| | |
+| ------------ | -------- |
+| **Type** | `string` |
+| **Required** | No |
+
+**Description:** The size of the pane in lines for horizontal panes, or columns for vertical panes. The size can also be specified as a percentage of the available space.
+
+**Examples:**
+
+```yaml
+20%
+```
+
+```yaml
+50
+```
+
+```yaml
+215
+```
+
+##### 3.7.1.7.1.8. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > panes`
+
+**Title:** Pane configurations
+
+| | |
+| ------------ | ------- |
+| **Type** | `array` |
+| **Required** | No |
+
+**Description:** A list of tmux pane configurations to create in the pane.
+
+| | Array restrictions |
+| -------------------- | ------------------ |
+| **Min items** | N/A |
+| **Max items** | N/A |
+| **Items unicity** | False |
+| **Additional items** | False |
+| **Tuple validation** | See below |
+
+| Each item of this array must be | Description |
+| ------------------------------------------------------------ | ---------------------------------------------------------------- |
+| [PaneConfig](#session_windows_items_panes_items_panes_items) | Pane configuration describing how a tmux pane should be created. |
+
+##### 3.7.1.7.1.8.1. Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > panes > PaneConfig
+
+| | |
+| ------------------------- | --------------------------------------------------------- |
+| **Type** | `object` |
+| **Required** | No |
+| **Additional properties** | [\[Not allowed\]](# "Additional Properties not allowed.") |
+| **Same definition as** | [Pane configuration](#session_windows_items_panes_items) |
+
+**Description:** Pane configuration describing how a tmux pane should be created.
+
+##### 3.7.1.8. Property `Tmpl configuration > session > windows > Window configuration > additionalProperties`
+
+| | |
+| ------------------------- | --------------------------------------------------------------------------- |
+| **Type** | `object` |
+| **Required** | No |
+| **Additional properties** | [\[Any type: allowed\]](# "Additional Properties of any type are allowed.") |
+
+______________________________________________________________________
+
+Generated using [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans)
diff --git a/docs/license.md b/docs/license.md
new file mode 100644
index 0000000..0e8c6fc
--- /dev/null
+++ b/docs/license.md
@@ -0,0 +1,3 @@
+# License
+
+--8<-- "LICENSE"
diff --git a/docs/macos-gatekeeper.md b/docs/macos-gatekeeper.md
new file mode 100644
index 0000000..364839f
--- /dev/null
+++ b/docs/macos-gatekeeper.md
@@ -0,0 +1,15 @@
+# macOS Gatekeeper
+
+macOS may prevent you from running the pre-compiled binary due to the built-in security feature called Gatekeeper.
+This is because the binary isn't signed with an Apple Developer ID certificate.
+
+**If you get an error message saying that the binary is from an unidentified developer or something similar, you can
+allow it to run by doing one of the following:**
+
+1. :material-apple-finder: **Finder:** right-click the binary and select "Open" from the context menu and confirm that
+ you want to run the binary. Gatekeeper remembers your choice and allows you to run the binary in the future.
+2. :material-console: **Terminal:** add the binary to the list of allowed applications by running the following command:
+
+```console
+user@host:~$ spctl --add /path/to/tmpl
+```
diff --git a/docs/overrides/main.html b/docs/overrides/main.html
new file mode 100644
index 0000000..3f1cc62
--- /dev/null
+++ b/docs/overrides/main.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block site_meta %}
+ {{ super() }}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
diff --git a/docs/overrides/partials/social.html b/docs/overrides/partials/social.html
new file mode 100644
index 0000000..0c4057d
--- /dev/null
+++ b/docs/overrides/partials/social.html
@@ -0,0 +1,5 @@
+
diff --git a/docs/recipes/project-launcher.md b/docs/recipes/project-launcher.md
new file mode 100644
index 0000000..83ee5b2
--- /dev/null
+++ b/docs/recipes/project-launcher.md
@@ -0,0 +1,197 @@
+---
+icon: material/rocket-launch
+---
+
+# Project launcher
+
+This is a recipe for a shell script that combines tmpl with a few other command-line tools to create a spiffy project
+launcher that presents your projects in a selection menu with fuzzy search support. Pressing Enter launches a
+tmpl session for the selected project:
+
+
+
+## Prerequisites
+
+This recipe assumes you have gone through the [getting started](../getting-started.md) guide and that you have a basic
+understanding of shell scripting. It also assumes that your projects are located under a single directory and that
+you are using a shell such as Bash or Zsh.
+
+## Dependencies
+
+The launcher makes use of a few other command-line tools, so you'll need to install those first. The tools are:
+
+- [fd] - Fast and user-friendly alternative to `find`. `fd` is used to find projects to launch.
+- [fzf] - General-purpose command-line fuzzy finder. `fzf` is used for the selection menu.
+
+
+=== ":material-apple: Homebrew"
+
+ ```console title=""
+ user@host:~$ brew install fd fzf
+ ```
+
+=== ":material-ubuntu: APT"
+
+ ```console title=""
+ user@host:~$ sudo apt install fd-find fzf
+ ```
+
+=== ":material-arch: Pacman"
+
+ ```console title=""
+ user@host:~$ sudo pacman -S fd fzf
+ ```
+
+=== ":material-fedora: DNF"
+
+ ```console title=""
+ user@host:~$ sudo dnf install fd-find fzf
+ ```
+
+=== ":material-gentoo: Portage"
+
+ ```console title=""
+ user@host:~$ emerge --ask sys-apps/fd app-shells/fzf
+ ```
+
+
+If your package manager isn't listed, refer to the installation instructions for [fd][fd-install] and
+[fzf][fzf-install].
+
+## The script
+
+This is the project launcher script. The script is heavily commented to explain how it works and key configuration
+options are highlighted to make it easier to modify the script to your needs.
+
+??? info "Installation instructions"
+
+ 1. Copy the script and save it as a file named `tmpo` in a directory that is included in your `$PATH`. For example,
+ you could save it as `/usr/local/bin/tmpo` (may require `sudo` to write to that location).
+ 2. Modify `$projects_dir` to the directory where your projects are located.
+ 3. Make the script executable by running `chmod +x path/to/tmpo`.
+ 4. Verify that the script works by running `tmpo` in a terminal.
+
+``` { .bash .copy title="Project launcher" hl_lines="14-17 19-23 25-31 45-58 60-63" }
+--8<-- "recipes/project-launcher.sh"
+```
+
+## Tips and tricks
+
+These are some optional tips and tricks to make the launcher even more useful.
+
+### Key binding shortcut
+
+Use the [bind-key tmux command][tmux-bind-key] to assign the launcher to a key for super quick access. Add the following
+to your `tmux.conf` file to bring up the launcher with your prefix key (default Ctrl+b) followed
+by f:
+
+``` { .bash .copy title="tmux.conf" }
+bind-key f run-shell "/path/to/project-launcher"
+```
+
+### Default tmpl configuration
+
+The launcher script changes the working directory to the selected project root before launching tmpl. Because tmpl
+traverses the directory tree upwards when looking for a configuration file, you can easily set up a default
+configuration for all your projects by adding a `.tmpl.yaml` file in a shared parent directory. If some projects need
+special configuration, you can override the default configuration by adding a `.tmpl.yaml` file in the project root.
+
+### fzf preview window
+
+A feature of fzf that's left out in the launcher script for simplicity, is the [preview window feature][fzf-preview]
+which makes it possible to dynamically show the output of a command for the currently selected project. As an example,
+modifying the fzf options to the following shows `git status` for the selected project:
+
+``` { .bash .copy title="Project launcher with preview window" hl_lines="11-13" }
+selected_project="$(
+ get_project_data |
+ fzf \
+ --delimiter="\t" \
+ --nth=1 \
+ --with-nth=2 \
+ --scheme="path" \
+ --no-info \
+ --no-scrollbar \
+ --ansi \
+ --preview="git -c color.status=always -C {1} status" \
+ --preview-window="right:40%:wrap" \
+ --preview-label="GIT STATUS" |
+ cut -d $'\t' -f 1
+)"
+```
+
+Experiment with this and find the command that works best for you.
+
+### Project icons
+
+If you use a [Nerd Font] in your terminal, you can add helpful icons to the list of projects. The following example
+modifies the `get_project_data` function to add a GitHub icon for projects with "github.com" in their path, and a GitLab
+icon for projects with "gitlab.com" in their paths:
+
+``` { .bash .copy title="Project launcher with icons" hl_lines="11-20" }
+function get_project_data() {
+
+ ... # NOTE: lines removed for brevity
+
+ while IFS= read -r path; do
+ pretty_path="${path#"$projects_dir"/}"
+
+ name="$style_boldblue$(basename "$pretty_path")$style_reset"
+ pretty_path="$(dirname "$pretty_path")/$name"
+
+ if [[ "$path" == *"github.com"* ]]; then
+ # Prepend white GitHub logo
+ pretty_path=" \e[38;5;15m\uf113$style_reset $pretty_path"
+ elif [[ "$path" == *"gitlab.com"* ]]; then
+ # Prepend orange GitLab logo
+ pretty_path=" \e[38;5;214m\uf296$style_reset $pretty_path"
+ else
+ # Prepend red Git logo for anything else
+ pretty_path=" \e[38;5;124m\uf1d3$style_reset $pretty_path"
+ fi
+
+ project_data+="$path\t$pretty_path\n"
+ done <<< "$projects"
+
+ ... # NOTE: lines removed for brevity
+
+}
+```
+
+
+
+Other ideas for icon usage:
+
+- :material-office-building: and :material-home: for work and personal projects
+- :material-language-go: :material-language-javascript: :material-language-ruby: :material-language-rust: etc. for
+ project languages
+- :material-star: or :material-heart: for important or favorite projects
+- :material-account-hard-hat: for projects with uncommitted changes
+
+Have a look at the [Nerd Font cheat sheet][nf-cheat-sheet] for a complete list of available icons.
+
+!!! tip "Tip: emoji icons"
+ It's also possible to use emoji icons if you don't want to use Nerd Fonts, however, the selection of emoji icons is
+ quite limited compared to Nerd Fonts.
+
+[fd]: https://github.com/sharkdp/fd
+[fzf]: https://github.com/junegunn/fzf
+[fd-install]: https://github.com/sharkdp/fd#installation
+[fzf-install]: https://github.com/junegunn/fzf#installation
+[fzf-preview]: https://github.com/junegunn/fzf#preview-window
+[tmux-bind-key]: https://man.archlinux.org/man/tmux.1#bind-key
+[Nerd Font]: https://www.nerdfonts.com/
+[nf-cheat-sheet]: https://www.nerdfonts.com/cheat-sheet
diff --git a/docs/recipes/project-launcher.sh b/docs/recipes/project-launcher.sh
new file mode 100644
index 0000000..c6d3958
--- /dev/null
+++ b/docs/recipes/project-launcher.sh
@@ -0,0 +1,136 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+style_boldblue="\033[1;34m"
+style_reset="\033[0m"
+
+# if $NO_COLOR is set, set the style variables to empty strings.
+if [ "${NO_COLOR:-}" != "" ]; then
+ style_boldblue=""
+ style_reset=""
+fi
+
+## Absolute path to directory where projects are stored.
+#
+# Change this to point to the directory where you store your projects.
+projects_dir="$HOME/projects"
+
+## Match pattern for identifying project root directories.
+#
+# This pattern finds directories that contain a .git directory. You can change
+# this to match something else if you don't use git for your projects.
+match_pattern='^\.git$'
+
+## Path patterns to exclude from the search.
+#
+# Add any directories that you want to exclude from the search to this array.
+exclude_patterns=(
+ "node_modules"
+ "vendor"
+)
+
+## Arguments to pass to fd.
+#
+# See `man fd` for more information.
+fd_args=(
+ "-H" # Include hidden files and directories. This is needed to match .git
+ "-t" "d" # Only match directories. Change to "-t" "f" to match files instead.
+ "--prune" # Don't traverse matching directories.
+)
+for pattern in "${exclude_patterns[@]}"; do
+ fd_args+=("-E" "$pattern")
+done
+
+## Use caching for project directories.
+#
+# Caches project directories to a file for faster startup time. Set to false if
+# you don't want to use caching.
+#
+# You can clear the cache by setting $CLEAR_CACHE to any value when running
+# the project launcher.
+#
+# You can also temporarily disable the cache by setting $NO_CACHE to any value
+# when running the project launcher.
+use_cache=true
+if [ "${NO_CACHE:-}" != "" ]; then
+ use_cache=false
+fi
+
+## Cache file location.
+#
+# Where the cache file is stored. Only used if $use_cache is true.
+cache_file="${XDG_CACHE_HOME:-$HOME/Library/Caches}/tmpo/projects.cache"
+
+## Get project data.
+#
+# Function returns project data to be used by fzf for the project selection
+# menu.
+#
+# Caching of data is also managed by this function.
+function get_project_data() {
+ # Clear the cache if $CLEAR_CACHE is set.
+ if [ "${CLEAR_CACHE:-}" != "" ] && [ -f "$cache_file" ]; then
+ rm "$cache_file"
+ fi
+
+ # Return the cached data if caching is enabled and the cache file exists.
+ if "$use_cache" && [[ -f "$cache_file" ]]; then
+ cat "$cache_file"
+ return
+ fi
+
+ project_data=""
+ projects="$(fd "${fd_args[@]}" "$match_pattern" "$projects_dir" | xargs dirname)"
+
+ while IFS= read -r path; do
+ # Remove the repeditive projects directory prefix from the path to make it
+ # more concise and readable.
+ pretty_path="${path#"$projects_dir"/}"
+
+ # To make the project selection more user-friendly, the project name is
+ # extracted using the basename command and styled in bold and blue to make
+ # it stand out.
+ name="$style_boldblue$(basename "$pretty_path")$style_reset"
+ pretty_path="$(dirname "$pretty_path")/$name"
+
+ # The project path and its pretty path are appended to the data, separated
+ # by a tab character ("\t"). This data will be used by fzf for the project
+ # selection menu.
+ project_data+="$path\t$pretty_path\n"
+ done <<< "$projects"
+
+ # Save the data to the cache file if caching is enabled.
+ if "$use_cache"; then
+ mkdir -p "$(dirname "$cache_file")"
+ echo -e "$project_data" > "$cache_file"
+ fi
+
+ echo -e "$project_data"
+}
+
+# Present the selection menu using fzf and store the selected project path.
+#
+# Fzf is configured to split each line of project data by \t and use the first
+# column for fuzzy matching and the second column for display.
+selected_project="$(
+ get_project_data |
+ fzf \
+ --delimiter="\t" \
+ --nth=1 \
+ --with-nth=2 \
+ --scheme="path" \
+ --no-info \
+ --no-scrollbar \
+ --ansi |
+ cut -d $'\t' -f 1
+)"
+
+if [ "$selected_project" = "" ]; then
+ # If no project was selected, exit the script.
+ exit 0
+fi
+
+# Change directory to selected project and run tmpl.
+(cd "$selected_project" || exit 1; tmpl)
+
diff --git a/docs/reference.md b/docs/reference.md
new file mode 100644
index 0000000..0fd5a79
--- /dev/null
+++ b/docs/reference.md
@@ -0,0 +1,11 @@
+# Configuration reference
+
+Below is an annotated reference configuration showing all the available options
+with example values.
+
+!!! tip
+ You can find more detailed documentation on each option in the [JSON schema reference](schema-reference.md).
+
+```yaml title=".tmpl.yaml"
+--8<-- ".tmpl.reference.yaml"
+```
diff --git a/docs/schema-reference.md b/docs/schema-reference.md
new file mode 100644
index 0000000..97e3a28
--- /dev/null
+++ b/docs/schema-reference.md
@@ -0,0 +1,12 @@
+# JSON schema
+
+Tmpl's configuration is defined by a [JSON schema](https://json-schema.org/) which describes all the available
+configuration options, their types, default values, validation rules, and more.
+
+The following is a detailed reference generated from [config.schema.json].
+
+---
+
+--8<-- "jsonschema.md"
+
+[config.schema.json]: {{ repo_url }}/blob/main/config.schema.json
diff --git a/docs/usage.md b/docs/usage.md
new file mode 100644
index 0000000..3efb765
--- /dev/null
+++ b/docs/usage.md
@@ -0,0 +1,119 @@
+---
+icon: material/play-circle-outline
+---
+
+# Launching your session
+
+After you've [configured your session](configuration.md), you can spin it up with the `tmpl` command:
+
+```console title="Launching a session"
+user@host:~/project$ tmpl
+13:37:00 INF configuration file loaded path=/home/user/project/.tmpl.yaml
+13:37:00 INF session created session=project
+13:37:00 INF window created session=project window=project:code
+13:37:00 INF window send-keys cmd=./scripts/init-env session=project window=project:code
+13:37:00 INF window send-keys cmd="nvim ." session=project window=project:code
+13:37:00 INF pane created session=project window=project:code pane=project:code.1 pane_width=192 pane_height=11
+13:37:00 INF pane send-keys cmd=./scripts/init-env session=project window=project:code pane=project:code.1 pane_width=192 pane_height=11
+13:37:00 INF pane send-keys cmd=./scripts/test-watcher session=project window=project:code pane=project:code.1 pane_width=192 pane_height=11
+13:37:00 INF window created session=project window=project:shell
+13:37:00 INF window send-keys cmd=./scripts/init-env session=project window=project:shell
+13:37:00 INF window send-keys cmd="git status" session=project window=project:shell
+13:37:00 INF window selected session=project window=project:code
+13:37:00 INF switching client to session windows=2 panes=1 session=project
+```
+
+Tmpl attaches to the new session quite quickly, so you likely won't see the output. This video shows how it looks when
+running the command:
+
+
+
+## Shared and global configurations
+
+When tmpl searches for a configuration file, it scans the directory tree upward until it locates one or reaches the root
+directory. This allows you to position configurations at different directory levels, serving as shared configurations if
+needed.
+
+``` title="Example directory structure" hl_lines="2 6 13"
+home/user/
+├── .tmpl.yaml (catch-all/default configuration)
+└── projects/
+ ├── work/
+ │ ├── project_group/
+ │ │ ├── .tmpl.yaml (used by projects in this group)
+ │ │ ├── project_a/
+ │ │ ├── project_b/
+ │ │ └── project_c/
+ │ ├── project_d/
+ │ └── project_e/
+ └── private/
+ ├── .tmpl.yaml (used by private projects)
+ ├── project_f/
+ ├── project_g/
+ └── project_h/
+```
+
+## Testing and verifying configurations
+
+When creating a new configuration, it can be useful to ensure that it functions correctly without actually creating and
+attaching a new session. Tmpl offers a dry-run mode for this purpose.
+
+```console title="Launching a session in dry-run mode" hl_lines="3"
+user@host:~/project$ tmpl --dry-run
+13:37:00 INF configuration file loaded path=/home/user/project/.tmpl.yaml
+13:37:00 INF DRY-RUN MODE ENABLED: no tmux commands will be executed and output is simulated
+13:37:00 INF session created session=project dry_run=true
+13:37:00 INF window created session=project window=project:code dry_run=true
+13:37:00 INF window send-keys cmd=./scripts/init-env session=project window=project:code dry_run=true
+13:37:00 INF window send-keys cmd="nvim ." session=project window=project:code dry_run=true
+13:37:00 INF pane created session=project window=project:code pane=project:code.1 pane_width=40 pane_height=12 dry_run=true
+13:37:00 INF pane send-keys cmd=./scripts/init-env session=project window=project:code pane=project:code.1 pane_width=40 pane_height=12 dry_run=true
+13:37:00 INF pane send-keys cmd=./scripts/test-watcher session=project window=project:code pane=project:code.1 pane_width=40 pane_height=12 dry_run=true
+13:37:00 INF window created session=project window=project:shell dry_run=true
+13:37:00 INF window send-keys cmd=./scripts/init-env session=project window=project:shell dry_run=true
+13:37:00 INF window send-keys cmd="git status" session=project window=project:shell dry_run=true
+13:37:00 INF window selected session=project window=project:code dry_run=true
+13:37:00 INF switching client to session windows=2 panes=1 session=project dry_run=true
+```
+
+!!! tip "Tip: debug mode"
+ To see even more information about what tmpl is doing, including exact tmux commands being run, use the `--debug`
+ flag. This also works in dry-run mode.
+
+If you just want to verify that your configuration is valid, you can use the `check` sub-command:
+
+```console title="Checking a configuration for errors"
+user@host:~/project$ tmpl check
+13:37:00 INF configuration file loaded path=/home/user/project/.tmpl.yaml
+13:37:00 ERR configuration file is invalid errors=1
+13:37:00 WRN session.name must only contain alphanumeric characters, underscores, dots, and dashes field=session.name
+13:37:00 WRN session.windows.0.panes.0.env "my-env" is not a valid environment variable name field=session.windows.0.panes.0.env
+13:37:00 WRN session.windows.1.path directory does not exist field=session.windows.1.path
+```
+
+## Command usage help
+
+To see available commands, options, and usage examples for tmpl, you can use the `-h/--help` flag. This can also be used
+on sub-commands.
+
+```console title="Getting usage information for tmpl"
+user@host:~$ tmpl --help
+--8<-- "cli-usage.txt"
+```
+
+## That's it
+
+This wraps up the getting started guide. Check out the recipe on [making a project launcher] for an example of how to
+use tmpl in combination with other command-line tools to further streamline your workflow.
+
+[making a project launcher]:
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..9e9eaa3
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,16 @@
+module github.com/michenriksen/tmpl
+
+go 1.21.3
+
+require (
+ github.com/invopop/validation v0.3.0
+ github.com/lmittmann/tint v1.0.3
+ github.com/stretchr/testify v1.8.4
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/stretchr/objx v0.5.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..d5765bd
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,25 @@
+github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
+github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/invopop/validation v0.3.0 h1:o260kbjXzoBO/ypXDSSrCLL7SxEFUXBsX09YTE9AxZw=
+github.com/invopop/validation v0.3.0/go.mod h1:qIBG6APYLp2Wu3/96p3idYjP8ffTKVmQBfKiZbw0Hts=
+github.com/lmittmann/tint v1.0.3 h1:W5PHeA2D8bBJVvabNfQD/XW9HPLZK1XoPZH0cq8NouQ=
+github.com/lmittmann/tint v1.0.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/cli/app.go b/internal/cli/app.go
new file mode 100644
index 0000000..59755d7
--- /dev/null
+++ b/internal/cli/app.go
@@ -0,0 +1,179 @@
+// Package cli is the application entry point.
+package cli
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+
+ "github.com/michenriksen/tmpl/config"
+ "github.com/michenriksen/tmpl/internal/env"
+ "github.com/michenriksen/tmpl/tmux"
+)
+
+const (
+ cmdInit = "init"
+ cmdCheck = "check"
+)
+
+// ErrInvalidConfig is returned when a configuration is invalid.
+var ErrInvalidConfig = fmt.Errorf("invalid configuration")
+
+// App is the main command-line application.
+//
+// The application orchestrates the loading of options and configuration, and
+// applying the configuration to a new tmux session.
+//
+// The application can be configured with options to facilitate testing, such as
+// providing a writer for output instead of os.Stdout, or providing a pre-loaded
+// configuration instead of loading it from a configuration file.
+type App struct {
+ opts *options
+ cfg *config.Config
+ tmux tmux.Runner
+ sess *tmux.Session
+ logger *slog.Logger
+ attrReplacer func([]string, slog.Attr) slog.Attr
+ out io.Writer
+}
+
+// NewApp creates a new command-line application.
+func NewApp(opts ...AppOption) (*App, error) {
+ app := &App{out: os.Stdout}
+
+ for _, opt := range opts {
+ if err := opt(app); err != nil {
+ return nil, fmt.Errorf("applying application option: %w", err)
+ }
+ }
+
+ return app, nil
+}
+
+// Run runs the command-line application.
+//
+// Different logic is performed dependening on the sub-command provided as the
+// first command-line argument.
+func (a *App) Run(ctx context.Context, args ...string) error {
+ var (
+ cmd string
+ err error
+ )
+
+ if len(args) > 0 {
+ cmd = args[0]
+ }
+
+ switch cmd {
+ case cmdCheck:
+ if a.opts == nil {
+ if a.opts, err = parseCheckOptions(args[1:], a.out); err != nil {
+ return a.handleErr(err)
+ }
+ }
+
+ return a.handleErr(a.runCheck(ctx))
+ case cmdInit:
+ if a.opts == nil {
+ if a.opts, err = parseInitOptions(args[1:], a.out); err != nil {
+ return a.handleErr(err)
+ }
+ }
+
+ return a.handleErr(a.runInit(ctx))
+ default:
+ if a.opts == nil {
+ if a.opts, err = parseApplyOptions(args, a.out); err != nil {
+ return a.handleErr(err)
+ }
+ }
+
+ return a.handleErr(a.runApply(ctx))
+ }
+}
+
+func (a *App) loadConfig() (err error) {
+ if a.cfg != nil {
+ return nil
+ }
+
+ wd, err := env.Getwd()
+ if err != nil {
+ return fmt.Errorf("getting current working directory: %w", err)
+ }
+
+ if a.opts.ConfigPath == "" {
+ a.opts.ConfigPath, err = config.FindConfigFile(wd)
+ if err != nil {
+ return fmt.Errorf("finding configuration file: %w", err)
+ }
+ }
+
+ if a.cfg, err = config.FromFile(a.opts.ConfigPath); err != nil {
+ return err //nolint:wrapcheck // Wrapping is done by caller.
+ }
+
+ a.logger.Info("configuration file loaded", "path", a.opts.ConfigPath)
+
+ return nil
+}
+
+// AppOptions configures an [App].
+type AppOption func(*App) error
+
+// WithOptions configures the [App] to use provided options instead of
+// parsing command-line flags.
+//
+// This option is intended for testing purposes only.
+func WithOptions(opts *options) AppOption {
+ return func(a *App) error {
+ a.opts = opts
+ return nil
+ }
+}
+
+// WithConfig configures the [App] to use provided configuration instead of
+// loading it from a configuration file.
+//
+// This option is intended for testing purposes only.
+func WithConfig(cfg *config.Config) AppOption {
+ return func(a *App) error {
+ a.cfg = cfg
+ return nil
+ }
+}
+
+// WithOutputWriter configures the [App] to use provided writer for output
+// instead of os.Stdout.
+//
+// This option is intended for testing purposes only.
+func WithOutputWriter(w io.Writer) AppOption {
+ return func(a *App) error {
+ a.out = w
+ return nil
+ }
+}
+
+// WithTmux configures the [App] to use provided tmux runner instead of the
+// constructing a new one from configuration.
+//
+// This option is intended for testing purposes only.
+func WithTmux(tm tmux.Runner) AppOption {
+ return func(a *App) error {
+ a.tmux = tm
+ return nil
+ }
+}
+
+// WithSlogAttrReplacer configures the [App] to use provided function for
+// replacing slog attributes.
+//
+// This option is intended for testing purposes only.
+func WithSlogAttrReplacer(f func([]string, slog.Attr) slog.Attr) AppOption {
+ return func(a *App) error {
+ a.attrReplacer = f
+ return nil
+ }
+}
diff --git a/internal/cli/build.go b/internal/cli/build.go
new file mode 100644
index 0000000..5be35d9
--- /dev/null
+++ b/internal/cli/build.go
@@ -0,0 +1,49 @@
+package cli
+
+import (
+ "runtime"
+ "time"
+)
+
+// AppName is the name of the CLI application.
+const AppName = "tmpl"
+
+// Build information set by the compiler.
+var (
+ buildVersion = "0.0.0-dev"
+ buildCommit = "HEAD"
+ buildTime = ""
+ buildGoVersion = runtime.Version()
+)
+
+// Version of tmpl.
+//
+// Returns `0.0.0-dev` if no version is set.
+func Version() string {
+ return buildVersion
+}
+
+// BuildCommit returns the git commit hash tmpl was built from.
+//
+// Returns `HEAD` if no build commit is set.
+func BuildCommit() string {
+ return buildCommit
+}
+
+// BuildTime returns the UTC time tmpl was built.
+//
+// Returns current time in UTC if not set.
+func BuildTime() string {
+ if buildTime == "" {
+ return time.Now().UTC().Format(time.RFC3339)
+ }
+
+ return buildTime
+}
+
+// BuildGoVersion returns the go version tmpl was built with.
+//
+// Returns version from [runtime.Version] if not set.
+func BuildGoVersion() string {
+ return buildGoVersion
+}
diff --git a/internal/cli/cmd_apply.go b/internal/cli/cmd_apply.go
new file mode 100644
index 0000000..cab707b
--- /dev/null
+++ b/internal/cli/cmd_apply.go
@@ -0,0 +1,64 @@
+package cli
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/michenriksen/tmpl/config"
+ "github.com/michenriksen/tmpl/tmux"
+)
+
+// runApply loads the configuration, applies it to a new tmux session and
+// attaches it.
+func (a *App) runApply(ctx context.Context) error {
+ a.initLogger()
+
+ if err := a.loadConfig(); err != nil {
+ return fmt.Errorf("loading configuration: %w", err)
+ }
+
+ runner, err := a.newTmux()
+ if err != nil {
+ return fmt.Errorf("creating tmux runner: %w", err)
+ }
+
+ a.sess, err = config.Apply(ctx, a.cfg, runner)
+ if err != nil {
+ return fmt.Errorf("applying configuration: %w", err)
+ }
+
+ if err := a.sess.Attach(ctx); err != nil {
+ return fmt.Errorf("attaching session: %w", err)
+ }
+
+ return nil
+}
+
+func (a *App) newTmux() (tmux.Runner, error) {
+ if a.tmux != nil {
+ return a.tmux, nil
+ }
+
+ cmdOpts := []tmux.RunnerOption{tmux.WithLogger(a.logger)}
+
+ if a.cfg.Tmux != "" {
+ cmdOpts = append(cmdOpts, tmux.WithTmux(a.cfg.Tmux))
+ }
+
+ if len(a.cfg.TmuxOptions) > 0 {
+ cmdOpts = append(cmdOpts, tmux.WithTmuxOptions(a.cfg.TmuxOptions...))
+ }
+
+ if a.opts.DryRun {
+ a.logger.Info("DRY-RUN MODE ENABLED: no tmux commands will be executed and output is simulated")
+
+ cmdOpts = append(cmdOpts, tmux.WithDryRunMode(true))
+ }
+
+ cmd, err := tmux.NewRunner(cmdOpts...)
+ if err != nil {
+ return nil, err //nolint:wrapcheck // Wrapping is done by caller.
+ }
+
+ return cmd, nil
+}
diff --git a/internal/cli/cmd_apply_test.go b/internal/cli/cmd_apply_test.go
new file mode 100644
index 0000000..c0f22e6
--- /dev/null
+++ b/internal/cli/cmd_apply_test.go
@@ -0,0 +1,310 @@
+package cli_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+
+ "github.com/michenriksen/tmpl/internal/cli"
+ "github.com/michenriksen/tmpl/internal/mock"
+ "github.com/michenriksen/tmpl/internal/testutils"
+ "github.com/michenriksen/tmpl/tmux"
+)
+
+func TestApp_Run_Apply(t *testing.T) {
+ stubHome := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(stubHome, "project", "scripts"), 0o744))
+
+ t.Setenv("NO_COLOR", "1")
+
+ // Stub HOME and current working directory for consistent test results.
+ t.Setenv("HOME", stubHome)
+ t.Setenv("TMPL_PWD", stubHome)
+
+ // Stub the following environment variables used to determine if the app is
+ // running in a tmux session for consistent test results.
+ t.Setenv("TMUX", "")
+ t.Setenv("TERM_PROGRAM", "")
+ t.Setenv("TERM", "xterm-256color")
+
+ dataDir, err := filepath.Abs("testdata")
+ require.NoError(t, err)
+
+ // Always include the --debug flag in tests to ensure that the output is
+ // included in the golden files.
+ alwaysArgs := []string{"--debug"}
+
+ runner, err := tmux.NewRunner()
+ require.NoError(t, err)
+
+ stubs := loadTmuxStubs(t)
+
+ tt := []struct {
+ name string
+ args []string
+ setupMocks func(*testing.T, *mock.TmuxRunner)
+ assertErr testutils.ErrorAssertion
+ }{
+ {
+ "create new session",
+ []string{"-c", filepath.Join(dataDir, "tmpl.yaml")},
+ func(_ *testing.T, r *mock.TmuxRunner) {
+ // App gets the current sessions to check if the session already exists.
+ stub := stubs["ListSessions"]
+ listSess := r.On("Run", stub.Args).Return(stub.Output(), nil).Once()
+
+ // App creates a new session, as it does not exist.
+ stub = stubs["NewSession"]
+ newSess := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(listSess)
+
+ // App creates the first window named "code".
+ stub = stubs["NewWindowCode"]
+ newWinCode := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newSess)
+
+ // App runs on_any hook command in the code window.
+ stub = stubs["SendKeysCodeOnAny"]
+ codeOnAny := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinCode)
+
+ // App runs on_window hook command in the code window.
+ stub = stubs["SendKeysCodeOnWindow"]
+ codeOnWindow := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(codeOnAny)
+
+ // App starts Neovim in the "code" window.
+ stub = stubs["SendKeysCode"]
+ sendKeysCode := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(codeOnWindow)
+
+ // App creates a horizontal pane in the "code" window.
+ stub = stubs["NewPaneCode"]
+ newPaneCode := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinCode)
+
+ // App runs on_any hook command in the code pane.
+ stub = stubs["SendKeysCodePaneOnAny"]
+ codePaneOnAny := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newPaneCode)
+
+ // App runs on_pane hook command in the code pane.
+ stub = stubs["SendKeysCodePaneOnPane"]
+ codePaneOnPane := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(codePaneOnAny)
+
+ // App starts automatic test run script in the code pane.
+ stub = stubs["SendKeysCodePane"]
+ r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(codePaneOnPane)
+
+ // App creates the second window named "shell".
+ stub = stubs["NewWindowShell"]
+ newWinShell := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinCode)
+
+ // App runs on_any hook command in the shell window.
+ stub = stubs["SendKeysShellOnAny"]
+ shellOnAny := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinShell)
+
+ // App runs on_window hook command in the shell window.
+ stub = stubs["SendKeysShellOnWindow"]
+ shellOnWindow := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(shellOnAny)
+
+ // App runs `git status` in the shell window.
+ stub = stubs["SendKeysShell"]
+ r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(shellOnWindow)
+
+ // App creates the third window named "server".
+ stub = stubs["NewWindowServer"]
+ newWinServer := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinShell)
+
+ // App runs on_any hook command in the server window.
+ stub = stubs["SendKeysServerOnAny"]
+ serverOnAny := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinServer)
+
+ // App runs on_window hook command in the server window.
+ stub = stubs["SendKeysServerOnWindow"]
+ serverOnWindow := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(serverOnAny)
+
+ // App starts development server script in the server window.
+ stub = stubs["SendKeysServer"]
+ r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(serverOnWindow)
+
+ // App creates the fourth window named "prod_logs".
+ stub = stubs["NewWindowProdLogs"]
+ newWinProdLogs := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinServer)
+
+ // App runs on_any hook command in the prod_logs window.
+ stub = stubs["SendKeysProdLogsOnAny"]
+ prodLogsOnAny := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinProdLogs)
+
+ // App runs on_window hook command in the prod_logs window.
+ stub = stubs["SendKeysProdLogsOnWindow"]
+ prodLogsOnWindow := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(prodLogsOnAny)
+
+ // App connects to production host via SSH in the prod_logs window.
+ stub = stubs["SendKeysProdLogsSSH"]
+ prodLogsSSH := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(prodLogsOnWindow)
+
+ // App navigates to the logs directory in the prod_logs window.
+ stub = stubs["SendKeysProdLogsCdLogs"]
+ prodLogsCdLogs := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(prodLogsSSH)
+
+ // App tails the application log file in the prod_logs window.
+ stub = stubs["SendKeysProdLogsTail"]
+ r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(prodLogsCdLogs)
+
+ // App selects the code window.
+ stub = stubs["SelectWindowCode"]
+ selectWinCode := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(sendKeysCode)
+
+ // App determines the pane base index to select the initial pane.
+ stub = stubs["PaneBaseIndexOpt"]
+ paneBaseIndexOpt := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(selectWinCode)
+
+ // App selects the initial code pane running Neovim.
+ stub = stubs["SelectPaneCode"]
+ selectPaneCode := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(paneBaseIndexOpt)
+
+ // Finally, App attaches the new session.
+ stub = stubs["AttachSession"]
+ r.On("Execve", stub.Args).Return(nil).Once().NotBefore(selectPaneCode)
+ },
+ nil,
+ },
+ {
+ "session exists",
+ []string{"-c", filepath.Join(dataDir, "tmpl.yaml")},
+ func(_ *testing.T, r *mock.TmuxRunner) {
+ // App gets the current sessions to check if the session already exists.
+ stub := stubs["ListSessionsExists"]
+ r.On("Run", stub.Args).Return(stub.Output(), nil).Once()
+
+ // Since the session already exists, it attaches it.
+ stub = stubs["AttachSession"]
+ r.On("Execve", stub.Args).Return(nil).Once()
+ },
+ nil,
+ },
+ {
+ "new session fails",
+ []string{"-c", filepath.Join(dataDir, "tmpl.yaml")},
+ func(_ *testing.T, r *mock.TmuxRunner) {
+ // App gets the current sessions to check if the session already exists.
+ stub := stubs["ListSessions"]
+ listSess := r.On("Run", stub.Args).Return(stub.Output(), nil).Once()
+
+ // App creates a new session but it fails.
+ stub = stubs["NewSession"]
+ r.On("Run", stub.Args).
+ Return([]byte("failed to connect to server: Connection refused"), errors.New("exit status 1")).Once().NotBefore(listSess)
+ },
+ testutils.RequireErrorContains("running new-session command: exit status 1"),
+ },
+ {
+ "new window fails",
+ []string{"-c", filepath.Join(dataDir, "tmpl.yaml")},
+ func(_ *testing.T, r *mock.TmuxRunner) {
+ // App gets the current sessions to check if the session already exists.
+ stub := stubs["ListSessions"]
+ listSess := r.On("Run", stub.Args).Return(stub.Output(), nil).Once()
+
+ // App creates a new session, as it does not exist.
+ stub = stubs["NewSession"]
+ newSess := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(listSess)
+
+ // App creates the first window named "code" but it fails.
+ stub = stubs["NewWindowCode"]
+ newWinCode := r.On("Run", stub.Args).
+ Return([]byte("failed to connect to server: Connection refused"), errors.New("exit status 1")).Once().NotBefore(newSess)
+
+ // App closes the failed session.
+ stub = stubs["CloseSession"]
+ r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinCode)
+ },
+ testutils.RequireErrorContains("running new-window command: exit status 1"),
+ },
+ {
+ "broken config file",
+ []string{"-c", filepath.Join(dataDir, "tmpl-broken.yaml")},
+ nil,
+ testutils.RequireErrorContains("invalid configuration"),
+ },
+ {
+ "invalid config file",
+ []string{"-c", filepath.Join(dataDir, "tmpl-invalid.yaml")},
+ nil,
+ testutils.RequireErrorContains("invalid configuration"),
+ },
+ {
+ "show help",
+ []string{"-h"},
+ nil,
+ testutils.RequireErrorIs(cli.ErrHelp),
+ },
+ {
+ "show version",
+ []string{"-v"},
+ nil,
+ testutils.RequireErrorIs(cli.ErrVersion),
+ },
+ }
+
+ for _, tc := range tt {
+ t.Run(tc.name, func(t *testing.T) {
+ out := new(bytes.Buffer)
+ mockRunner := mock.NewTmuxRunner(t, runner)
+
+ if tc.setupMocks != nil {
+ tc.setupMocks(t, mockRunner)
+ }
+
+ app, err := cli.NewApp(
+ cli.WithOutputWriter(out),
+ cli.WithTmux(mockRunner),
+ cli.WithSlogAttrReplacer(testutils.NewSlogStabilizer(t)),
+ )
+ require.NoError(t, err)
+
+ args := append(alwaysArgs, tc.args...)
+
+ err = app.Run(context.Background(), args...)
+
+ if tc.assertErr != nil {
+ require.Error(t, err)
+ tc.assertErr(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ if !mockRunner.AssertExpectations(t) {
+ t.FailNow()
+ }
+
+ testutils.NewGolden(t).RequireMatch(testutils.Stabilize(t, out.Bytes()))
+ })
+ }
+}
+
+// loadTmuxStubs loads the expected tmux command arguments and stub output from
+// the tmux-stubs.yaml file in the testdata directory.
+func loadTmuxStubs(t *testing.T) map[string]tmuxStub {
+ t.Helper()
+
+ data := testutils.ReadFile(t, "testdata", "tmux-stubs.yaml")
+
+ stubs := make(map[string]tmuxStub)
+
+ require.NoError(t, yaml.Unmarshal(data, stubs), "expected tmux-stubs.yaml to contain valid YAML")
+
+ return stubs
+}
+
+// tmuxStub contains expected tmux command arguments and stub output to use in
+// tests.
+type tmuxStub struct {
+ OutputString string `yaml:"output"`
+ Args []string `yaml:"args"`
+}
+
+// Output returns the stub output as a byte slice.
+func (s tmuxStub) Output() []byte {
+ return []byte(s.OutputString)
+}
diff --git a/internal/cli/cmd_check.go b/internal/cli/cmd_check.go
new file mode 100644
index 0000000..6032e81
--- /dev/null
+++ b/internal/cli/cmd_check.go
@@ -0,0 +1,25 @@
+package cli
+
+import (
+ "context"
+ "fmt"
+)
+
+// runCheck loads the configuration and validates it, logging any validation
+// errors and returning [ErrInvalidConfig] if any are found.
+func (a *App) runCheck(_ context.Context) error {
+ a.initLogger()
+
+ if err := a.loadConfig(); err != nil {
+ return fmt.Errorf("loading configuration: %w", err)
+ }
+
+ err := a.cfg.Validate()
+ if err != nil {
+ return err
+ }
+
+ a.logger.Info("configuration file is valid")
+
+ return nil
+}
diff --git a/internal/cli/cmd_check_test.go b/internal/cli/cmd_check_test.go
new file mode 100644
index 0000000..07a81ec
--- /dev/null
+++ b/internal/cli/cmd_check_test.go
@@ -0,0 +1,95 @@
+package cli_test
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/michenriksen/tmpl/config"
+ "github.com/michenriksen/tmpl/internal/cli"
+ "github.com/michenriksen/tmpl/internal/testutils"
+)
+
+func TestApp_Run_Check(t *testing.T) {
+ t.Setenv("NO_COLOR", "1")
+
+ stubHome := t.TempDir()
+ t.Setenv("HOME", stubHome)
+ t.Setenv("TMPL_PWD", stubHome)
+
+ require.NoError(t, os.MkdirAll(filepath.Join(stubHome, "project", "scripts"), 0o744))
+
+ testutils.WriteFile(t,
+ testutils.ReadFile(t, "testdata", "tmpl.yaml"),
+ stubHome, config.ConfigFileName(),
+ )
+
+ testutils.WriteFile(t,
+ testutils.ReadFile(t, "testdata", "tmpl.yaml"),
+ stubHome, "project", config.ConfigFileName(),
+ )
+
+ testutils.WriteFile(t,
+ testutils.ReadFile(t, "testdata", "tmpl-broken.yaml"),
+ stubHome, ".tmpl.broken.yaml",
+ )
+
+ testutils.WriteFile(t,
+ testutils.ReadFile(t, "testdata", "tmpl-invalid.yaml"),
+ stubHome, ".tmpl.invalid.yaml",
+ )
+
+ tt := []struct {
+ name string
+ args []string
+ assertErr testutils.ErrorAssertion
+ }{
+ {
+ "check current config",
+ []string{"check"},
+ nil,
+ },
+ {
+ "check specific config",
+ []string{"check", "-c", filepath.Join(stubHome, "project", config.ConfigFileName())},
+ nil,
+ },
+ {
+ "unparsable config",
+ []string{"check", "-c", filepath.Join(stubHome, ".tmpl.broken.yaml")},
+ testutils.RequireErrorIs(cli.ErrInvalidConfig),
+ },
+ {
+ "invalid config",
+ []string{"check", "-c", filepath.Join(stubHome, ".tmpl.invalid.yaml")},
+ testutils.RequireErrorIs(cli.ErrInvalidConfig),
+ },
+ }
+
+ for _, tc := range tt {
+ t.Run(tc.name, func(t *testing.T) {
+ out := new(bytes.Buffer)
+
+ app, err := cli.NewApp(
+ cli.WithOutputWriter(out),
+ cli.WithSlogAttrReplacer(testutils.NewSlogStabilizer(t)),
+ )
+ require.NoError(t, err)
+
+ err = app.Run(context.Background(), tc.args...)
+
+ if tc.assertErr != nil {
+ require.Error(t, err)
+ tc.assertErr(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ testutils.NewGolden(t).RequireMatch(out.Bytes())
+ })
+ }
+}
diff --git a/internal/cli/cmd_init.go b/internal/cli/cmd_init.go
new file mode 100644
index 0000000..54db933
--- /dev/null
+++ b/internal/cli/cmd_init.go
@@ -0,0 +1,118 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "text/template"
+ "time"
+
+ "github.com/michenriksen/tmpl/config"
+ "github.com/michenriksen/tmpl/internal/env"
+ "github.com/michenriksen/tmpl/internal/static"
+)
+
+var cleanSessNameRE = regexp.MustCompile(`[^\w._-]+`)
+
+func (a *App) runInit(_ context.Context) error {
+ a.initLogger()
+
+ dst := ""
+ if len(a.opts.args) != 0 {
+ dst = a.opts.args[0]
+ }
+
+ if dst == "" {
+ wd, err := env.Getwd()
+ if err != nil {
+ return fmt.Errorf("getting current working directory: %w", err)
+ }
+
+ dst = filepath.Join(wd, config.ConfigFileName())
+ }
+
+ info, err := os.Stat(dst)
+ if err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("getting file info for destination path: %w", err)
+ }
+ }
+
+ if info != nil {
+ if !info.IsDir() {
+ a.logger.Info("file already exists, skipping",
+ "path", info.Name(), "size", info.Size(), "modified", info.ModTime(),
+ )
+
+ return nil
+ }
+
+ dst = filepath.Join(dst, config.ConfigFileName())
+ }
+
+ text := static.ConfigTemplate
+ if a.opts.Plain {
+ text = stripCfgComments(text)
+ }
+
+ cfgTmpl, err := template.New(config.DefaultConfigFile).Parse(text)
+ if err != nil {
+ return fmt.Errorf("parsing embedded configuration template: %w", err)
+ }
+
+ data := templateData{
+ AppName: AppName,
+ Version: Version(),
+ Name: cleanSessionName(filepath.Base(filepath.Dir(dst))),
+ Time: time.Now(),
+ DocsURL: "https://github.com/michenriksen/tmpl",
+ }
+
+ cfgFile, err := os.Create(dst)
+ if err != nil {
+ return fmt.Errorf("creating configuration file: %w", err)
+ }
+ defer cfgFile.Close()
+
+ if err := cfgTmpl.Execute(cfgFile, data); err != nil {
+ return fmt.Errorf("writing configuration file: %w", err)
+ }
+
+ a.logger.Info("configuration file created", "path", cfgFile.Name())
+
+ return nil
+}
+
+func cleanSessionName(name string) string {
+ name = cleanSessNameRE.ReplaceAllString(strings.TrimSpace(name), "_")
+ return strings.Trim(name, "._-")
+}
+
+func stripCfgComments(text string) string {
+ lines := strings.Split(text, "\n")
+ b := strings.Builder{}
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if len(trimmed) == 0 || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+
+ b.WriteString(line + "\n")
+ }
+
+ return strings.TrimSpace(b.String())
+}
+
+type templateData struct {
+ AppName string
+ Time time.Time
+ DocsURL string
+ Name string
+ Version string
+}
diff --git a/internal/cli/cmd_init_test.go b/internal/cli/cmd_init_test.go
new file mode 100644
index 0000000..59c7cba
--- /dev/null
+++ b/internal/cli/cmd_init_test.go
@@ -0,0 +1,164 @@
+package cli_test
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+
+ "github.com/michenriksen/tmpl/config"
+ "github.com/michenriksen/tmpl/internal/cli"
+ "github.com/michenriksen/tmpl/internal/testutils"
+)
+
+func TestApp_Run_Init(t *testing.T) {
+ t.Setenv("NO_COLOR", "1")
+
+ stubHome := t.TempDir()
+ stubwd := filepath.Join(stubHome, "test project (1)")
+ require.NoError(t, os.MkdirAll(stubwd, 0o744))
+
+ t.Setenv("HOME", stubHome)
+ t.Setenv("TMPL_PWD", stubwd)
+
+ tt := []struct {
+ name string
+ args []string
+ wantCfgPath string
+ wantCfg *config.Config
+ }{
+ {
+ "init current directory",
+ []string{"init"},
+ filepath.Join(stubwd, config.ConfigFileName()),
+ &config.Config{
+ Session: config.SessionConfig{
+ Name: "test_project_1",
+ Windows: []config.WindowConfig{
+ {Name: "main"},
+ },
+ },
+ },
+ },
+ {
+ "init specific directory",
+ []string{"init", filepath.Join(stubHome, "test.project")},
+ filepath.Join(stubHome, "test.project", config.ConfigFileName()),
+ &config.Config{
+ Session: config.SessionConfig{
+ Name: "test.project",
+ Windows: []config.WindowConfig{
+ {Name: "main"},
+ },
+ },
+ },
+ },
+ {
+ "init specific file",
+ []string{"init", filepath.Join(stubHome, "test-project", ".tmpl_config.yml")},
+ filepath.Join(stubHome, "test-project", ".tmpl_config.yml"),
+ &config.Config{
+ Session: config.SessionConfig{
+ Name: "test-project",
+ Windows: []config.WindowConfig{
+ {Name: "main"},
+ },
+ },
+ },
+ },
+ }
+
+ for _, tc := range tt {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := context.Background()
+ out := new(bytes.Buffer)
+
+ if tc.wantCfgPath != "" {
+ require.NoError(t, os.MkdirAll(filepath.Dir(tc.wantCfgPath), 0o744))
+ }
+
+ app, err := cli.NewApp(
+ cli.WithOutputWriter(out),
+ cli.WithSlogAttrReplacer(testutils.NewSlogStabilizer(t)),
+ )
+ require.NoError(t, err)
+
+ require.NoError(t, app.Run(ctx, tc.args...))
+
+ testutils.NewGolden(t).RequireMatch(out.Bytes())
+
+ cfg := unmarshalCfg(t, testutils.ReadFile(t, tc.wantCfgPath))
+
+ if tc.wantCfg != nil {
+ require.Equal(t, *tc.wantCfg, cfg)
+ }
+ })
+ }
+}
+
+func TestApp_Run_InitPlain(t *testing.T) {
+ t.Setenv("NO_COLOR", "1")
+
+ stubHome := filepath.Join(t.TempDir(), "Zer0_c00l")
+ require.NoError(t, os.MkdirAll(stubHome, 0o744))
+
+ t.Setenv("HOME", stubHome)
+ t.Setenv("TMPL_PWD", stubHome)
+
+ app, err := cli.NewApp()
+ require.NoError(t, err)
+
+ require.NoError(t, app.Run(context.Background(), "init", "-p"))
+
+ wantCfgPath := filepath.Join(stubHome, config.ConfigFileName())
+ want := config.Config{
+ Session: config.SessionConfig{
+ Name: "Zer0_c00l",
+ Windows: []config.WindowConfig{
+ {Name: "main"},
+ },
+ },
+ }
+ got := unmarshalCfg(t, testutils.ReadFile(t, wantCfgPath))
+
+ require.Equal(t, want, got)
+ require.NotContains(t, string(testutils.ReadFile(t, wantCfgPath)), "# ",
+ "expected configuration to contain no comments",
+ )
+}
+
+func TestApp_Run_InitFileExists(t *testing.T) {
+ t.Setenv("NO_COLOR", "1")
+
+ stubHome := t.TempDir()
+
+ t.Setenv("HOME", stubHome)
+ t.Setenv("TMPL_PWD", stubHome)
+
+ cfgPath := filepath.Join(stubHome, config.ConfigFileName())
+ testutils.WriteFile(t, []byte("don't overwrite me"), cfgPath)
+
+ app, err := cli.NewApp()
+ require.NoError(t, err)
+
+ require.NoError(t, app.Run(context.Background(), "init"))
+
+ require.Equal(t, "don't overwrite me", string(testutils.ReadFile(t, cfgPath)),
+ "expected configuration file to not be overwritten",
+ )
+}
+
+func unmarshalCfg(t *testing.T, data []byte) config.Config {
+ t.Helper()
+
+ var cfg config.Config
+
+ require.NoError(t, yaml.Unmarshal(data, &cfg), "expected configuration to be parsable YAML")
+ require.NoError(t, cfg.Validate(), "expected configuration to be valid")
+
+ return cfg
+}
diff --git a/internal/cli/logging.go b/internal/cli/logging.go
new file mode 100644
index 0000000..c829ed4
--- /dev/null
+++ b/internal/cli/logging.go
@@ -0,0 +1,155 @@
+package cli
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+
+ "github.com/invopop/validation"
+ "github.com/lmittmann/tint"
+
+ "github.com/michenriksen/tmpl/config"
+ "github.com/michenriksen/tmpl/internal/env"
+)
+
+// skipLogErrors contains errors that should not be logged.
+var skipLogErrors = []error{ErrVersion, ErrHelp}
+
+func (a *App) initLogger() {
+ a.logger = a.newLogger()
+
+ if a.tmux != nil {
+ a.tmux.SetLogger(a.logger)
+ }
+}
+
+// newLogger creates a new structured logger that writes to the provided writer
+// configured with the provided options.
+func (a *App) newLogger() *slog.Logger {
+ hOpts := &slog.HandlerOptions{
+ Level: slog.LevelInfo,
+ ReplaceAttr: a.attrReplacer,
+ }
+
+ if a.opts != nil {
+ if a.opts.Debug {
+ hOpts.Level = slog.LevelDebug
+ }
+
+ if a.opts.Quiet {
+ hOpts.Level = slog.LevelWarn
+ }
+ }
+
+ var handler slog.Handler
+
+ if a.opts != nil && a.opts.JSON {
+ handler = a.newJSONHandler(hOpts)
+ } else {
+ handler = a.newTextHandler(hOpts)
+ }
+
+ return slog.New(handler)
+}
+
+func (a *App) newTextHandler(hOpts *slog.HandlerOptions) slog.Handler {
+ tOpts := &tint.Options{
+ Level: hOpts.Level,
+ ReplaceAttr: hOpts.ReplaceAttr,
+ TimeFormat: "15:04:05",
+ NoColor: envNoColor(),
+ }
+
+ handler := tint.NewHandler(a.out, tOpts)
+
+ return handler
+}
+
+func (a *App) newJSONHandler(hOpts *slog.HandlerOptions) *slog.JSONHandler {
+ return slog.NewJSONHandler(a.out, hOpts)
+}
+
+// handleErr logs and returns the provided error.
+//
+// If err is nil, this method is a no-op.
+// If err is in [skipLogErrors], logging is skipped.
+func (a *App) handleErr(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ for _, skipErr := range skipLogErrors {
+ if errors.Is(err, skipErr) {
+ return err
+ }
+ }
+
+ logger := a.logger
+ if logger == nil {
+ logger = a.newLogger()
+ }
+
+ var decodeErr config.DecodeError
+ if errors.As(err, &decodeErr) {
+ logger.Error("configuration file cannot be decoded", "path", decodeErr.Path())
+
+ if wrapped := decodeErr.Unwrap(); wrapped != nil {
+ logger.Warn(wrapped.Error())
+ }
+
+ return ErrInvalidConfig
+ }
+
+ var verrs validation.Errors
+ if errors.As(err, &verrs) {
+ logger.Error("configuration file is invalid", "errors", len(verrs))
+ logValidationErrs(logger, verrs, "")
+
+ return ErrInvalidConfig
+ }
+
+ logger.Error(err.Error())
+
+ return err
+}
+
+// logValidationErrs logs validation errors recursively.
+func logValidationErrs(logger *slog.Logger, verrs validation.Errors, fieldPrfx string) {
+ for field, err := range verrs {
+ field = fieldPrfx + field
+
+ if verr, ok := err.(validation.Errors); ok {
+ logValidationErrs(logger, verr, field+".")
+ continue
+ }
+
+ logger.Warn(fmt.Sprintf("%s %v", field, err.Error()), "field", field)
+ }
+}
+
+func envNoColor() bool {
+ // See https://no-color.org/
+ if _, ok := os.LookupEnv("NO_COLOR"); ok {
+ return true
+ }
+
+ // Check application specific environment variable.
+ if _, ok := env.LookupEnv("NO_COLOR"); ok {
+ return true
+ }
+
+ // See https://bixense.com/clicolors/
+ if _, ok := os.LookupEnv("CLICOLOR_FORCE"); ok {
+ return false
+ }
+
+ // $TERM is often set to `dumb` to indicate that the terminal is very basic
+ // and sometimes if the current command output is redirected to a file or
+ // piped to another command.
+ if os.Getenv("TERM") == "dumb" {
+ return true
+ }
+
+ return false
+}
diff --git a/internal/cli/options.go b/internal/cli/options.go
new file mode 100644
index 0000000..5f27816
--- /dev/null
+++ b/internal/cli/options.go
@@ -0,0 +1,310 @@
+package cli
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "strings"
+ "text/template"
+)
+
+const globalOpts = `Global options:
+
+ -d, --debug enable debug logging
+ -h, --help show this message and exit
+ -j, --json enable JSON logging
+ -q, --quiet enable quiet logging
+ -v, --version show the version and exit`
+
+const subCmds = `Available commands:
+
+ apply (default) apply configuration and attach session
+ check validate configuration file
+ init generate a new configuration file`
+
+const usageTmpl = `Usage: {{ .AppName }} [command] [options] [args]
+
+Simple tmux session management.
+
+{{ .Commands }}
+
+{{ .GlobalOptions }}
+
+Examples:
+
+ # apply nearest configuration file and attach/switch client to session:
+ $ {{ .AppName }}
+
+ # or explicitly:
+ $ {{ .AppName }} -c /path/to/config.yaml
+
+ # generate a new configuration file in the current working directory:
+ $ {{ .AppName }} init
+`
+
+const applyUsageTmpl = `Usage: {{ .AppName }} apply [options]
+
+Creates a new tmux session from a {{ .AppName }} configuration file and then
+connects to it.
+
+If the session already exists, the configuration process is skipped.
+
+
+Options:
+
+ -c, --config PATH configuration file path (default: find nearest)
+ -n, --dry-run enable dry-run mode
+
+{{ .GlobalOptions }}
+
+Examples:
+
+ # apply nearest configuration file and attach/switch client to session:
+ $ {{ .AppName }} apply
+
+ # or explicitly:
+ $ {{ .AppName }} apply -c /path/to/config.yaml
+
+ # simulate applying configuration file. No tmux commands are executed:
+ $ {{ .AppName }} apply --dry-run
+`
+
+const initUsageTmpl = `Usage: {{ .AppName }} init [options] [path]
+
+Generates a skeleton {{ .AppName }} configuration file to get you started.
+
+
+Options:
+ -p, --plain make plain configuration with no comments
+
+{{ .GlobalOptions }}
+
+Examples:
+
+ # create a configuration file in the current working directory:
+ $ {{ .AppName }} init
+
+ # or at a specific location:
+ $ {{ .AppName }} init /path/to/config.yaml
+`
+
+const checkUsageTmpl = `Usage: {{ .AppName }} check [options] [path]
+
+Performs validation of a {{ .AppName }} configuration file and reports whether
+it is valid or not.
+
+
+Options:
+
+ -c, --config PATH configuration file path (default: find nearest)
+
+{{ .GlobalOptions }}
+
+Examples:
+
+ # validate configuration file in the current working directory:
+ $ {{ .AppName }} check
+
+ # or at a specific location:
+ $ {{ .AppName }} check -c /path/to/config.yaml
+`
+
+const versionTmpl = `{{ .AppName }}:
+ Version: {{ .Version }}
+ Go version: {{ .GoVersion }}
+ Git commit: {{ .Commit }}
+ Released: {{ .BuildTime }}
+`
+
+var (
+ ErrHelp = errors.New("help requested")
+ ErrVersion = errors.New("version requested")
+)
+
+// options represents the command-line options for the CLI application.
+type options struct {
+ args []string
+ version bool
+ help bool
+
+ // Global options.
+ Debug bool
+ Quiet bool
+ JSON bool
+
+ // Options for apply sub-command.
+ ConfigPath string
+ DryRun bool
+
+ // Options for init sub-command.
+ Plain bool
+}
+
+// parseApplyOptions parses the command-line options for the apply sub-command.
+func parseApplyOptions(args []string, output io.Writer) (*options, error) {
+ flagSet := flag.NewFlagSet("apply", flag.ContinueOnError)
+ isSubCmd := len(args) != 0 && args[0] == "apply"
+
+ flagSet.SetOutput(output)
+ flagSet.Usage = func() {
+ var (
+ usage string
+ err error
+ )
+
+ if isSubCmd {
+ usage, err = renderOptsTemplate(applyUsageTmpl)
+ } else {
+ usage, err = renderOptsTemplate(usageTmpl)
+ }
+
+ if err != nil {
+ panic(err)
+ }
+
+ fmt.Fprint(output, usage)
+ }
+
+ opts := &options{}
+ initGlobalOpts(flagSet, opts)
+
+ flagSet.StringVar(&opts.ConfigPath, "config", "", "path to the configuration file")
+ flagSet.StringVar(&opts.ConfigPath, "c", "", "path to the configuration file")
+ flagSet.BoolVar(&opts.DryRun, "dry-run", false, "enable dry-run mode")
+ flagSet.BoolVar(&opts.DryRun, "n", false, "enable dry-run mode")
+
+ if isSubCmd {
+ args = args[1:]
+ }
+
+ opts, err := parseFlagSet(args, flagSet, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(opts.args) != 0 {
+ return nil, fmt.Errorf("unknown command: %s", opts.args[0])
+ }
+
+ return opts, nil
+}
+
+// parseInitOptions parses the command-line options for the init sub-command.
+func parseInitOptions(args []string, output io.Writer) (*options, error) {
+ flagSet := flag.NewFlagSet("init", flag.ContinueOnError)
+
+ flagSet.SetOutput(output)
+ flagSet.Usage = func() {
+ usage, err := renderOptsTemplate(initUsageTmpl)
+ if err != nil {
+ panic(err)
+ }
+
+ fmt.Fprint(output, usage)
+ }
+
+ opts := &options{}
+ initGlobalOpts(flagSet, opts)
+
+ flagSet.BoolVar(&opts.Plain, "plain", false, "make plain configuration with no comments")
+ flagSet.BoolVar(&opts.Plain, "p", false, "make plain configuration with no comments")
+
+ return parseFlagSet(args, flagSet, opts)
+}
+
+func parseCheckOptions(args []string, output io.Writer) (*options, error) {
+ flagSet := flag.NewFlagSet("check", flag.ContinueOnError)
+
+ flagSet.SetOutput(output)
+ flagSet.Usage = func() {
+ usage, err := renderOptsTemplate(checkUsageTmpl)
+ if err != nil {
+ panic(err)
+ }
+
+ fmt.Fprint(output, usage)
+ }
+
+ opts := &options{}
+ initGlobalOpts(flagSet, opts)
+
+ flagSet.StringVar(&opts.ConfigPath, "config", "", "path to the configuration file")
+ flagSet.StringVar(&opts.ConfigPath, "c", "", "path to the configuration file")
+
+ return parseFlagSet(args, flagSet, opts)
+}
+
+func initGlobalOpts(flagSet *flag.FlagSet, opts *options) {
+ flagSet.BoolVar(&opts.Debug, "debug", false, "enable debug logging")
+ flagSet.BoolVar(&opts.Debug, "d", false, "enable debug logging")
+ flagSet.BoolVar(&opts.Quiet, "quiet", false, "enable quiet logging")
+ flagSet.BoolVar(&opts.Quiet, "q", false, "enable quiet logging")
+ flagSet.BoolVar(&opts.JSON, "json", false, "enable JSON logging")
+ flagSet.BoolVar(&opts.JSON, "j", false, "enable JSON logging")
+ flagSet.BoolVar(&opts.version, "version", false, "show the version and exit")
+ flagSet.BoolVar(&opts.version, "v", false, "show the version and exit")
+ flagSet.BoolVar(&opts.help, "help", false, "show this message and exit")
+ flagSet.BoolVar(&opts.help, "h", false, "show this message and exit")
+}
+
+func parseFlagSet(args []string, flagSet *flag.FlagSet, opts *options) (*options, error) {
+ if err := flagSet.Parse(args); err != nil {
+ return nil, fmt.Errorf("parsing flags: %w", err)
+ }
+
+ if opts.help {
+ flagSet.Usage()
+ return nil, ErrHelp
+ }
+
+ if opts.version {
+ info, err := renderOptsTemplate(versionTmpl)
+ if err != nil {
+ return nil, err
+ }
+
+ fmt.Fprint(flagSet.Output(), info)
+
+ return nil, ErrVersion
+ }
+
+ opts.args = flagSet.Args()
+
+ return opts, nil
+}
+
+func renderOptsTemplate(s string) (string, error) {
+ data := optsTemplateData{
+ AppName: AppName,
+ BuildTime: BuildTime(),
+ Commands: subCmds,
+ Commit: BuildCommit(),
+ GlobalOptions: globalOpts,
+ GoVersion: BuildGoVersion(),
+ Version: Version(),
+ }
+
+ tmpl, err := template.New("usage").Parse(s)
+ if err != nil {
+ return "", fmt.Errorf("parsing options template: %w", err)
+ }
+
+ b := strings.Builder{}
+
+ if err := tmpl.Execute(&b, data); err != nil {
+ return "", fmt.Errorf("rendering options template: %w", err)
+ }
+
+ return b.String(), nil
+}
+
+type optsTemplateData struct {
+ AppName string
+ BuildTime string
+ Commands string
+ Commit string
+ GlobalOptions string
+ GoVersion string
+ Version string
+}
diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/broken_config_file.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/broken_config_file.golden.json
new file mode 100644
index 0000000..2eb583b
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Apply/broken_config_file.golden.json
@@ -0,0 +1,5 @@
+[
+ "00:00:00 ERR configuration file cannot be decoded path=/stabilized/path/tmpl-broken.yaml",
+ "00:00:00 WRN yaml: line 4: did not find expected key",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/create_new_session.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/create_new_session.golden.json
new file mode 100644
index 0000000..6005c2a
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Apply/create_new_session.golden.json
@@ -0,0 +1,29 @@
+[
+ "00:00:00 INF configuration file loaded path=/stabilized/path/tmpl.yaml",
+ "00:00:00 INF session created session=my_project mock=true",
+ "00:00:00 INF window created session=my_project window=my_project:code mock=true",
+ "00:00:00 INF window send-keys cmd=~/project/scripts/bootstrap.sh\u003ccr\u003e session=my_project window=my_project:code mock=true",
+ "00:00:00 INF window send-keys cmd=\"echo 'on_window'\u003ccr\u003e\" session=my_project window=my_project:code mock=true",
+ "00:00:00 INF window send-keys cmd=\"nvim .\u003ccr\u003e\" session=my_project window=my_project:code mock=true",
+ "00:00:00 INF pane created session=my_project window=my_project:code pane=my_project:code.1 pane_width=80 pane_height=5 mock=true",
+ "00:00:00 INF pane send-keys cmd=~/project/scripts/bootstrap.sh\u003ccr\u003e session=my_project window=my_project:code pane=my_project:code.1 pane_width=80 pane_height=5 mock=true",
+ "00:00:00 INF pane send-keys cmd=\"echo 'on_pane'\u003ccr\u003e\" session=my_project window=my_project:code pane=my_project:code.1 pane_width=80 pane_height=5 mock=true",
+ "00:00:00 INF pane send-keys cmd=./autorun-tests.sh\u003ccr\u003e session=my_project window=my_project:code pane=my_project:code.1 pane_width=80 pane_height=5 mock=true",
+ "00:00:00 INF window created session=my_project window=my_project:shell mock=true",
+ "00:00:00 INF window send-keys cmd=~/project/scripts/bootstrap.sh\u003ccr\u003e session=my_project window=my_project:shell mock=true",
+ "00:00:00 INF window send-keys cmd=\"echo 'on_window'\u003ccr\u003e\" session=my_project window=my_project:shell mock=true",
+ "00:00:00 INF window send-keys cmd=\"git status\u003ccr\u003e\" session=my_project window=my_project:shell mock=true",
+ "00:00:00 INF window created session=my_project window=my_project:server mock=true",
+ "00:00:00 INF window send-keys cmd=~/project/scripts/bootstrap.sh\u003ccr\u003e session=my_project window=my_project:server mock=true",
+ "00:00:00 INF window send-keys cmd=\"echo 'on_window'\u003ccr\u003e\" session=my_project window=my_project:server mock=true",
+ "00:00:00 INF window send-keys cmd=./run-dev-server.sh\u003ccr\u003e session=my_project window=my_project:server mock=true",
+ "00:00:00 INF window created session=my_project window=my_project:prod_logs mock=true",
+ "00:00:00 INF window send-keys cmd=~/project/scripts/bootstrap.sh\u003ccr\u003e session=my_project window=my_project:prod_logs mock=true",
+ "00:00:00 INF window send-keys cmd=\"echo 'on_window'\u003ccr\u003e\" session=my_project window=my_project:prod_logs mock=true",
+ "00:00:00 INF window send-keys cmd=\"ssh user@host\u003ccr\u003e\" session=my_project window=my_project:prod_logs mock=true",
+ "00:00:00 INF window send-keys cmd=\"cd /var/logs\u003ccr\u003e\" session=my_project window=my_project:prod_logs mock=true",
+ "00:00:00 INF window send-keys cmd=\"tail -f app.log\u003ccr\u003e\" session=my_project window=my_project:prod_logs mock=true",
+ "00:00:00 INF window selected session=my_project window=my_project:code mock=true",
+ "00:00:00 INF attaching client to session windows=4 panes=1 session=my_project mock=true",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/invalid_config_file.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/invalid_config_file.golden.json
new file mode 100644
index 0000000..6b3e482
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Apply/invalid_config_file.golden.json
@@ -0,0 +1,6 @@
+[
+ "00:00:00 INF configuration file loaded path=/stabilized/path/tmpl-invalid.yaml",
+ "00:00:00 ERR configuration file is invalid errors=1",
+ "00:00:00 WRN session.windows.0.env \"invalid_session_name\" is not a valid environment variable name field=session.windows.0.env",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/new_session_fails.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/new_session_fails.golden.json
new file mode 100644
index 0000000..4958f14
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Apply/new_session_fails.golden.json
@@ -0,0 +1,5 @@
+[
+ "00:00:00 INF configuration file loaded path=/stabilized/path/tmpl.yaml",
+ "00:00:00 ERR applying configuration: applying session my_project: running new-session command: exit status 1",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/new_window_fails.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/new_window_fails.golden.json
new file mode 100644
index 0000000..eace841
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Apply/new_window_fails.golden.json
@@ -0,0 +1,7 @@
+[
+ "00:00:00 INF configuration file loaded path=/stabilized/path/tmpl.yaml",
+ "00:00:00 INF session created session=my_project mock=true",
+ "00:00:00 DBG session closed session=my_project mock=true",
+ "00:00:00 ERR applying configuration: applying window configuration: applying window my_project:code: running new-window command: exit status 1",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/session_exists.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/session_exists.golden.json
new file mode 100644
index 0000000..6ec9a5b
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Apply/session_exists.golden.json
@@ -0,0 +1,5 @@
+[
+ "00:00:00 INF configuration file loaded path=/stabilized/path/tmpl.yaml",
+ "00:00:00 INF attaching client to session session=my_project mock=true",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/show_help.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/show_help.golden.json
new file mode 100644
index 0000000..f03eae0
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Apply/show_help.golden.json
@@ -0,0 +1,31 @@
+[
+ "Usage: tmpl [command] [options] [args]",
+ "",
+ "Simple tmux session management.",
+ "",
+ "Available commands:",
+ "",
+ " apply (default) apply configuration and attach session",
+ " check validate configuration file",
+ " init generate a new configuration file",
+ "",
+ "Global options:",
+ "",
+ " -d, --debug enable debug logging",
+ " -h, --help show this message and exit",
+ " -j, --json enable JSON logging",
+ " -q, --quiet enable quiet logging",
+ " -v, --version show the version and exit",
+ "",
+ "Examples:",
+ "",
+ " # apply nearest configuration file and attach/switch client to session:",
+ " $ tmpl",
+ "",
+ " # or explicitly:",
+ " $ tmpl -c /path/to/config.yaml",
+ "",
+ " # generate a new configuration file in the current working directory:",
+ " $ tmpl init",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/show_version.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/show_version.golden.json
new file mode 100644
index 0000000..f3e7dbe
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Apply/show_version.golden.json
@@ -0,0 +1,8 @@
+[
+ "tmpl:",
+ " Version: 0.0.0-dev",
+ " Go version: go0.0.0",
+ " Git commit: HEAD",
+ " Released: 0001-01-01T00:00:00Z",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Check/check_current_config.golden.json b/internal/cli/testdata/golden/TestApp_Run_Check/check_current_config.golden.json
new file mode 100644
index 0000000..3bf867d
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Check/check_current_config.golden.json
@@ -0,0 +1,5 @@
+[
+ "00:00:00 INF configuration file loaded path=/stabilized/path/.tmpl.yaml",
+ "00:00:00 INF configuration file is valid",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Check/check_specific_config.golden.json b/internal/cli/testdata/golden/TestApp_Run_Check/check_specific_config.golden.json
new file mode 100644
index 0000000..3bf867d
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Check/check_specific_config.golden.json
@@ -0,0 +1,5 @@
+[
+ "00:00:00 INF configuration file loaded path=/stabilized/path/.tmpl.yaml",
+ "00:00:00 INF configuration file is valid",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Check/invalid_config.golden.json b/internal/cli/testdata/golden/TestApp_Run_Check/invalid_config.golden.json
new file mode 100644
index 0000000..e7ba064
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Check/invalid_config.golden.json
@@ -0,0 +1,6 @@
+[
+ "00:00:00 INF configuration file loaded path=/stabilized/path/.tmpl.invalid.yaml",
+ "00:00:00 ERR configuration file is invalid errors=1",
+ "00:00:00 WRN session.windows.0.env \"invalid_session_name\" is not a valid environment variable name field=session.windows.0.env",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Check/unparsable_config.golden.json b/internal/cli/testdata/golden/TestApp_Run_Check/unparsable_config.golden.json
new file mode 100644
index 0000000..7eb3cf3
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Check/unparsable_config.golden.json
@@ -0,0 +1,5 @@
+[
+ "00:00:00 ERR configuration file cannot be decoded path=/stabilized/path/.tmpl.broken.yaml",
+ "00:00:00 WRN yaml: line 4: did not find expected key",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Init/init_current_directory.golden.json b/internal/cli/testdata/golden/TestApp_Run_Init/init_current_directory.golden.json
new file mode 100644
index 0000000..9e21c31
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Init/init_current_directory.golden.json
@@ -0,0 +1,4 @@
+[
+ "00:00:00 INF configuration file created path=/stabilized/path/.tmpl.yaml",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_directory.golden.json b/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_directory.golden.json
new file mode 100644
index 0000000..9e21c31
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_directory.golden.json
@@ -0,0 +1,4 @@
+[
+ "00:00:00 INF configuration file created path=/stabilized/path/.tmpl.yaml",
+ ""
+]
diff --git a/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_file.golden.json b/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_file.golden.json
new file mode 100644
index 0000000..19de892
--- /dev/null
+++ b/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_file.golden.json
@@ -0,0 +1,4 @@
+[
+ "00:00:00 INF configuration file created path=/stabilized/path/.tmpl_config.yml",
+ ""
+]
diff --git a/internal/cli/testdata/tmpl-broken.yaml b/internal/cli/testdata/tmpl-broken.yaml
new file mode 100644
index 0000000..1f7e946
--- /dev/null
+++ b/internal/cli/testdata/tmpl-broken.yaml
@@ -0,0 +1,6 @@
+# yamllint disable-file
+# This file has an intentional syntax error for testing purposes.
+---
+session:
+ name: "invalid"
+ path: "/home/user"
diff --git a/internal/cli/testdata/tmpl-invalid.yaml b/internal/cli/testdata/tmpl-invalid.yaml
new file mode 100644
index 0000000..f32607e
--- /dev/null
+++ b/internal/cli/testdata/tmpl-invalid.yaml
@@ -0,0 +1,7 @@
+# Invalid configuration: Environment variables must only contain uppercase
+# alphanumeric and underscores.
+---
+session:
+ windows:
+ - env:
+ invalid_session_name: "true"
diff --git a/internal/cli/testdata/tmpl.yaml b/internal/cli/testdata/tmpl.yaml
new file mode 100644
index 0000000..6289c4c
--- /dev/null
+++ b/internal/cli/testdata/tmpl.yaml
@@ -0,0 +1,33 @@
+---
+session:
+ name: my_project
+ path: ~/project
+ on_any: ~/project/scripts/bootstrap.sh
+ on_window: echo 'on_window'
+ on_pane: echo 'on_pane'
+ env:
+ APP_ENV: development
+ DEBUG: true
+ windows:
+ - name: code
+ command: nvim .
+ active: true
+ panes:
+ - command: ./autorun-tests.sh
+ path: ~/project/scripts
+ horizontal: true
+ size: 20%
+ env:
+ APP_ENV: test
+ - name: shell
+ command: git status
+ - name: server
+ path: ~/project/scripts
+ command: ./run-dev-server.sh
+ env:
+ HTTP_PORT: 8080
+ - name: prod_logs
+ commands:
+ - ssh user@host
+ - cd /var/logs
+ - tail -f app.log
diff --git a/internal/cli/testdata/tmux-stubs.yaml b/internal/cli/testdata/tmux-stubs.yaml
new file mode 100644
index 0000000..49fb9e4
--- /dev/null
+++ b/internal/cli/testdata/tmux-stubs.yaml
@@ -0,0 +1,118 @@
+# This file contains tmux command arguments expected to be given to the tmux
+# runner, with optional stub command output to be returned on match.
+#
+# yamllint disable rule:line-length
+---
+ListSessions:
+ args: &ListSessionArgs ["list-sessions", "-F", "session_id:#{session_id},session_name:#{session_name},session_path:#{session_path}"]
+ output: |-
+ session_id:$0,session_name:main,session_path:/home/user
+ session_id:$1,session_name:other,session_path:/home/user/other
+ session_id:$2,session_name:prod,session_path:/home/user
+
+ListSessionsExists:
+ args: *ListSessionArgs
+ output: |-
+ session_id:$0,session_name:main,session_path:/home/user
+ session_id:$1,session_name:my_project,session_path:/home/user/project
+ session_id:$2,session_name:prod,session_path:/home/user
+
+NewSession:
+ args: ["new-session", "-d", "-P", "-F", "session_id:#{session_id},session_name:#{session_name},session_path:#{session_path}", "-s", "my_project"]
+ output: |-
+ session_id:$3,session_name:my_project,session_path:/home/user/project
+
+NewWindowCode:
+ args: ["new-window", "-P", "-F", "window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height}", "-k", "-t", "my_project:^", "-e", "APP_ENV=development", "-e", "DEBUG=true", "-n", "code", "-c", "/tmp/path"]
+ output: |-
+ window_id:@5,window_name:code,window_path:/home/user/project,window_index:1,window_width:80,window_height:24
+
+NewWindowShell:
+ args: ["new-window", "-P", "-F", "window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height}", "-t", "my_project:", "-e", "APP_ENV=development", "-e", "DEBUG=true", "-n", "shell", "-c", "/tmp/path"]
+ output: |-
+ window_id:@6,window_name:shell,window_path:/home/user/project/scripts,window_index:2,window_width:80,window_height:24
+
+NewWindowServer:
+ args: ["new-window", "-P", "-F", "window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height}", "-t", "my_project:", "-e", "APP_ENV=development", "-e", "DEBUG=true", "-e", "HTTP_PORT=8080", "-n", "server", "-c", "/tmp/path"]
+ output: |-
+ window_id:@7,window_name:server,window_path:/home/user/project/scripts,window_index:3,window_width:80,window_height:24
+
+NewWindowProdLogs:
+ args: ["new-window", "-P", "-F", "window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height}", "-t", "my_project:", "-e", "APP_ENV=development", "-e", "DEBUG=true", "-n", "prod_logs", "-c", "/tmp/path"]
+ output: |-
+ window_id:@8,window_name:prod_logs,window_path:/home/user/project,window_index:4,window_width:80,window_height:24
+
+NewPaneCode:
+ args: ["split-window", "-d", "-P", "-F", "pane_id:#{pane_id},pane_path:#{pane_path},pane_index:#{pane_index},pane_width:#{pane_width},pane_height:#{pane_height}", "-t", "my_project:code", "-e", "APP_ENV=test", "-e", "DEBUG=true", "-c", "/tmp/path", "-l", "20%", "-h"]
+ output: |-
+ pane_id:@4,pane_path:/home/user/project,pane_index:1,pane_width:80,pane_height:5
+
+SendKeysCodeOnAny:
+ args: ["send-keys", "-t", "my_project:code", "~/project/scripts/bootstrap.sh", "C-m"]
+
+SendKeysCodeOnWindow:
+ args: ["send-keys", "-t", "my_project:code", "echo 'on_window'", "C-m"]
+
+SendKeysCode:
+ args: ["send-keys", "-t", "my_project:code", "nvim .", "C-m"]
+
+SendKeysCodePaneOnAny:
+ args: ["send-keys", "-t", "my_project:code.1", "~/project/scripts/bootstrap.sh", "C-m"]
+
+SendKeysCodePaneOnPane:
+ args: ["send-keys", "-t", "my_project:code.1", "echo 'on_pane'", "C-m"]
+
+SendKeysCodePane:
+ args: ["send-keys", "-t", "my_project:code.1", "./autorun-tests.sh", "C-m"]
+
+SendKeysShellOnAny:
+ args: ["send-keys", "-t", "my_project:shell", "~/project/scripts/bootstrap.sh", "C-m"]
+
+SendKeysShellOnWindow:
+ args: ["send-keys", "-t", "my_project:shell", "echo 'on_window'", "C-m"]
+
+SendKeysShell:
+ args: ["send-keys", "-t", "my_project:shell", "git status", "C-m"]
+
+SendKeysServerOnAny:
+ args: ["send-keys", "-t", "my_project:server", "~/project/scripts/bootstrap.sh", "C-m"]
+
+SendKeysServerOnWindow:
+ args: ["send-keys", "-t", "my_project:server", "echo 'on_window'", "C-m"]
+
+SendKeysServer:
+ args: ["send-keys", "-t", "my_project:server", "./run-dev-server.sh", "C-m"]
+
+SendKeysProdLogsOnAny:
+ args: ["send-keys", "-t", "my_project:prod_logs", "~/project/scripts/bootstrap.sh", "C-m"]
+
+SendKeysProdLogsOnWindow:
+ args: ["send-keys", "-t", "my_project:prod_logs", "echo 'on_window'", "C-m"]
+
+SendKeysProdLogsSSH:
+ args: ["send-keys", "-t", "my_project:prod_logs", "ssh user@host", "C-m"]
+
+SendKeysProdLogsCdLogs:
+ args: ["send-keys", "-t", "my_project:prod_logs", "cd /var/logs", "C-m"]
+
+SendKeysProdLogsTail:
+ args: ["send-keys", "-t", "my_project:prod_logs", "tail -f app.log", "C-m"]
+
+SelectWindowCode:
+ args: ["select-window", "-t", "my_project:code"]
+
+PaneBaseIndexOpt:
+ args: ["show-option", "-gqv", "pane-base-index"]
+ output: "0"
+
+SelectPaneCode:
+ args: ["select-pane", "-t", "my_project:code.0"]
+
+SwitchClient:
+ args: ["switch-client", "-t", "my_project"]
+
+AttachSession:
+ args: ["attach-session", "-t", "my_project"]
+
+CloseSession:
+ args: ["kill-session", "-t", "my_project"]
diff --git a/internal/env/env.go b/internal/env/env.go
new file mode 100644
index 0000000..1751457
--- /dev/null
+++ b/internal/env/env.go
@@ -0,0 +1,36 @@
+// Package env provides functionality for getting information and data from the
+// environment.
+package env
+
+import "os"
+
+const keyPrefix = "TMPL_"
+
+const (
+ // KeyConfigName is the environment variable key for specifying a different
+ // configuration file name instead of the default.
+ KeyConfigName = "CONFIG_NAME"
+ // KeyPwd is the environment variable key for a stubbed working directory
+ // used by tests.
+ KeyPwd = "PWD"
+)
+
+// Getenv retrieves the value of the environment variable named by the key.
+//
+// Works like [os.Getenv] except that it will prefix the key with an application
+// specific prefix to avoid conflicts with other environment variables.
+func Getenv(key string) string {
+ return os.Getenv(makeKey(key))
+}
+
+// LookupEnv retrieves the value of the environment variable named by the key.
+//
+// Works like [os.LookupEnv] except that it will prefix the key with an
+// application specific prefix to avoid conflicts with other environment.
+func LookupEnv(key string) (string, bool) {
+ return os.LookupEnv(makeKey(key))
+}
+
+func makeKey(key string) string {
+ return keyPrefix + key
+}
diff --git a/internal/env/env_test.go b/internal/env/env_test.go
new file mode 100644
index 0000000..9f686fa
--- /dev/null
+++ b/internal/env/env_test.go
@@ -0,0 +1,25 @@
+package env_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/michenriksen/tmpl/internal/env"
+)
+
+func TestGetenv(t *testing.T) {
+ t.Setenv("TMPL_TEST_GET_ENV", "good")
+ t.Setenv("TEST_GET_ENV", "bad")
+
+ require.Equal(t, "good", env.Getenv("TEST_GET_ENV"))
+}
+
+func TestLookupEnv(t *testing.T) {
+ t.Setenv("TMPL_TEST_GET_ENV", "good")
+ t.Setenv("TEST_GET_ENV", "bad")
+
+ val, ok := env.LookupEnv("TEST_GET_ENV")
+ require.True(t, ok)
+ require.Equal(t, "good", val)
+}
diff --git a/internal/env/fs.go b/internal/env/fs.go
new file mode 100644
index 0000000..4cc2271
--- /dev/null
+++ b/internal/env/fs.go
@@ -0,0 +1,55 @@
+package env
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// Getwd returns the current working directory.
+//
+// Works like [os.Getwd] except that it will check the environment variable for
+// stubbing the working directory used by tests, and return the value if it's
+// defined. This is intended for testing purposes, and should not be used as a
+// feature.
+func Getwd() (string, error) {
+ if dir, ok := LookupEnv(KeyPwd); ok && filepath.IsAbs(dir) {
+ return dir, nil
+ }
+
+ dir, err := os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("getting current working directory: %w", err)
+ }
+
+ return dir, nil
+}
+
+// AbsPath returns the absolute path for the given path.
+//
+// Works like [filepath.Abs] except that it will expand the home directory if
+// the path starts with "~".
+//
+// If the path is already absolute, it will be returned as-is.
+func AbsPath(path string) (string, error) {
+ if filepath.IsAbs(path) {
+ return path, nil
+ }
+
+ if strings.HasPrefix(path, "~") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("getting user home directory: %w", err)
+ }
+
+ return filepath.Join(home, filepath.Clean(path[1:])), nil
+ }
+
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return "", fmt.Errorf("getting absolute path: %w", err)
+ }
+
+ return abs, nil
+}
diff --git a/internal/env/fs_test.go b/internal/env/fs_test.go
new file mode 100644
index 0000000..e3203f8
--- /dev/null
+++ b/internal/env/fs_test.go
@@ -0,0 +1,86 @@
+package env_test
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/michenriksen/tmpl/internal/env"
+)
+
+func TestGetwd(t *testing.T) {
+ t.Run("no env stub", func(t *testing.T) {
+ want, err := os.Getwd()
+ require.NoError(t, err)
+
+ got, err := env.Getwd()
+ require.NoError(t, err)
+
+ require.Equal(t, want, got)
+ })
+
+ t.Run("with env stub", func(t *testing.T) {
+ want := t.TempDir()
+ t.Setenv("TMPL_PWD", want)
+
+ got, err := env.Getwd()
+ require.NoError(t, err)
+
+ require.Equal(t, want, got)
+ })
+}
+
+func TestAbsPath(t *testing.T) {
+ wd, err := os.Getwd()
+ require.NoError(t, err)
+
+ t.Setenv("HOME", "/home/user")
+
+ tt := []struct {
+ name string
+ path string
+ want string
+ }{
+ {
+ "absolute path",
+ "/home/user/project",
+ "/home/user/project",
+ },
+ {
+ "relative path",
+ "project/scripts",
+ filepath.Join(wd, "project/scripts"),
+ },
+ {
+ "relative path traversal",
+ "project/scripts/../tests",
+ filepath.Join(wd, "project/tests"),
+ },
+ {
+ "relative path dot",
+ "./project/scripts",
+ filepath.Join(wd, "project/scripts"),
+ },
+ {
+ "tilde path",
+ "~/project/scripts",
+ "/home/user/project/scripts",
+ },
+ {
+ "tilde path traversal",
+ "~/../project/scripts",
+ "/home/user/project/scripts",
+ },
+ }
+
+ for _, tc := range tt {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := env.AbsPath(tc.path)
+ require.NoError(t, err)
+
+ require.Equal(t, tc.want, got)
+ })
+ }
+}
diff --git a/internal/gen/docs/.tmpl.example.yaml b/internal/gen/docs/.tmpl.example.yaml
new file mode 100644
index 0000000..d9d1753
--- /dev/null
+++ b/internal/gen/docs/.tmpl.example.yaml
@@ -0,0 +1,27 @@
+---
+session:
+ name: my-project
+
+ env:
+ APP_ENV: development
+ DEBUG: true
+
+ windows:
+
+ # main window running Neovim with a horizontal bottom pane with 20% height,
+ # running tests.
+ - name: code
+ command: nvim .
+ panes:
+ - command: scripts/autorun-tests.sh
+ size: 20%
+ horizontal: true
+ env:
+ APP_ENV: testing
+
+ # secondary window for arbitrary use.
+ - name: shell
+
+## These lines configure editors to be more helpful (optional)
+# yaml-language-server: $schema=https://github.com/michenriksen/tmpl/blob/main/config.schema.json
+# vim: ft=yaml syn=yaml ts=2 sts=2 sw=2 et
diff --git a/internal/gen/docs/README.md.tmpl b/internal/gen/docs/README.md.tmpl
new file mode 100644
index 0000000..1c670c6
--- /dev/null
+++ b/internal/gen/docs/README.md.tmpl
@@ -0,0 +1,61 @@
+
+