├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── go.yml │ ├── homebrew.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── .smug.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── commander.go ├── commander_test.go ├── completion ├── smug.bash └── smug.fish ├── config.go ├── config_test.go ├── context.go ├── context_test.go ├── go.mod ├── go.sum ├── main.go ├── man └── man1 │ └── smug.1 ├── options.go ├── options_test.go ├── smug.go ├── smug_test.go └── tmux.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: iillexial 2 | open_collective: smug 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Smug config** 14 | 15 | **Expected behavior** 16 | A clear and concise description of what you expected to happen, or an image of what you want to see 17 | 18 | **Output of** `cat ~/.config/smug/smug.log` 19 | You should run smug with `-d` flag to output debug information 20 | 21 | **Smug version** 22 | You can get it from the `$ smug --help` output 23 | 24 | **OS you're using** 25 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Set up Go 1.x 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: ^1.16 20 | 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v2 23 | 24 | - name: Get dependencies 25 | run: | 26 | go get -v -t -d ./... 27 | if [ -f Gopkg.toml ]; then 28 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 29 | dep ensure 30 | fi 31 | 32 | - name: Build 33 | run: go build -v ./... 34 | 35 | - name: Test 36 | run: go test -v ./... 37 | -------------------------------------------------------------------------------- /.github/workflows/homebrew.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | tags: 5 | - "*" 6 | 7 | jobs: 8 | update-formulae: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Update Homebrew formulae 12 | uses: dawidd6/action-homebrew-bump-formula@master 13 | with: 14 | token: "${{ secrets.GITHUB_TOKEN }}" 15 | formula: smug 16 | force: true 17 | livecheck: false 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: stable 23 | - name: Run GoReleaser 24 | uses: goreleaser/goreleaser-action@v6 25 | with: 26 | distribution: goreleaser 27 | version: "~> v2" 28 | args: release --clean 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.out 2 | dist/ 3 | smug 4 | test_configs/ 5 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: smug 3 | 4 | before: 5 | hooks: 6 | - go mod download 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - linux 12 | - windows 13 | - darwin 14 | archives: 15 | - format: tar.gz 16 | # this name template makes the OS and Arch compatible with the results of `uname`. 17 | name_template: >- 18 | smug_{{ .Version }}_ 19 | {{- title .Os }}_ 20 | {{- if eq .Arch "amd64" }}x86_64 21 | {{- else if eq .Arch "386" }}i386 22 | {{- else }}{{ .Arch }}{{ end }} 23 | {{- if .Arm }}v{{ .Arm }}{{ end }} 24 | # use zip for windows archives 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | 29 | checksum: 30 | name_template: 'checksums.txt' 31 | snapshot: 32 | version_template: "{{ .Tag }}-next" 33 | changelog: 34 | sort: asc 35 | filters: 36 | exclude: 37 | - test 38 | - README 39 | 40 | nfpms: 41 | - license: MIT 42 | maintainer: 'Ivan Klymenchenko hello@iillexial.me' 43 | homepage: https://github.com/ivaaaan/smug 44 | dependencies: 45 | - git 46 | description: Session manager and task runner for tmux. Start your development environment within one command. 47 | formats: 48 | - deb 49 | - rpm 50 | contents: 51 | - src: ./completion/smug.bash 52 | dst: /usr/share/bash-completion/completions/smug 53 | file_info: 54 | mode: 0644 55 | - src: ./completion/smug.fish 56 | dst: /usr/share/fish/vendor_completions.d/smug.fish 57 | file_info: 58 | mode: 0644 59 | 60 | -------------------------------------------------------------------------------- /.smug.yml: -------------------------------------------------------------------------------- 1 | session: smug 2 | 3 | root: . 4 | 5 | windows: 6 | - name: code 7 | commands: 8 | - go test ./... 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ivan Klymenchenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION = $(shell git describe --tags --abbrev=0) 2 | 3 | version: 4 | @echo $(VERSION) 5 | 6 | build: 7 | go build -ldflags "-X=main.version=$(VERSION)" -gcflags "all=-trimpath=$(GOPATH)" 8 | 9 | test: 10 | go test 11 | 12 | coverage: 13 | go test -coverprofile=coverage.out 14 | go tool cover -html=coverage.out 15 | 16 | release: 17 | ifndef GITHUB_TOKEN 18 | $(error GITHUB_TOKEN is not defined) 19 | endif 20 | git tag -a $(version) -m '$(version)' 21 | git push origin $(version) 22 | VERSION=$(version) goreleaser --rm-dist 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Smug - tmux session manager 2 | 3 | [![Actions Status](https://github.com/ivaaaan/smug/workflows/Go/badge.svg)](https://github.com/ivaaaan/smug/actions) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/ivaaaan/smug)](https://goreportcard.com/report/github.com/ivaaaan/smug) 5 | 6 | Inspired by [tmuxinator](https://github.com/tmuxinator/tmuxinator) and [tmuxp](https://github.com/tmux-python/tmuxp). 7 | 8 | Smug automates your [tmux](https://github.com/tmux/tmux) workflow. You can create a single configuration file, and Smug will create all the required windows and panes from it. 9 | 10 | ![gif](https://raw.githubusercontent.com/ivaaaan/gifs/master/smug.gif) 11 | 12 | The configuration used in this GIF can be found [here](#example-2). 13 | 14 | ## Installation 15 | 16 | ### Download from the releases page 17 | 18 | Download the latest version of Smug from the [releases page](https://github.com/ivaaaan/smug/releases) and then run: 19 | 20 | ```bash 21 | mkdir smug && tar -xzf smug_0.1.0_Darwin_x86_64.tar.gz -C ./smug && sudo mv smug/smug /usr/local/bin && rm -rf smug 22 | ``` 23 | 24 | Don't forget to replace `smug_0.1.0_Darwin_x86_64.tar.gz` with the archive that you've downloaded. 25 | 26 | ### Go (recommended) 27 | 28 | #### Prerequisite Tools 29 | 30 | - [Git](https://git-scm.com/) 31 | - [Go (we test it with the last 2 major versions)](https://golang.org/dl/) 32 | 33 | #### Fetch from GitHub 34 | 35 | The easiest way is to clone Smug from GitHub and install it using `go-cli`: 36 | 37 | ```bash 38 | go install github.com/ivaaaan/smug@latest 39 | ``` 40 | 41 | ### macOS 42 | 43 | On macOS, you can install Smug using [MacPorts](https://www.macports.org) or [Homebrew](https://brew.sh). 44 | 45 | #### Homebrew 46 | 47 | ```bash 48 | brew install smug 49 | ``` 50 | 51 | #### MacPorts 52 | 53 | ```bash 54 | sudo port selfupdate 55 | sudo port install smug 56 | ``` 57 | 58 | ### Linux 59 | 60 | #### Arch 61 | 62 | There's [AUR](https://aur.archlinux.org/packages/smug) with smug. 63 | 64 | ```bash 65 | git clone https://aur.archlinux.org/smug.git 66 | cd smug 67 | makepkg -si 68 | ``` 69 | 70 | ## Usage 71 | 72 | ``` 73 | smug [] [-f, --file ] [-w, --windows ]... [-a, --attach] [-d, --debug] 74 | ``` 75 | 76 | ### Options: 77 | 78 | ``` 79 | -f, --file A custom path to a config file 80 | -w, --windows List of windows to start. If session exists, those windows will be attached to current session. 81 | -a, --attach Force switch client for a session 82 | -i, --inside-current-session Create all windows inside current session 83 | -d, --debug Print all commands to ~/.config/smug/smug.log 84 | --detach Detach session. The same as `-d` flag in the tmux 85 | ``` 86 | 87 | ### Custom settings 88 | 89 | You can pass custom settings into your configuration file. Use `${variable_name}` syntax in your config and then pass key-value args: 90 | 91 | ```console 92 | xyz@localhost:~$ smug start project variable_name=value 93 | ``` 94 | 95 | ### Examples 96 | 97 | To create a new project, or edit an existing one in the `$EDITOR`: 98 | 99 | ```console 100 | xyz@localhost:~$ smug new project 101 | 102 | xyz@localhost:~$ smug edit project 103 | ``` 104 | 105 | To start/stop a project and all windows, run: 106 | 107 | ```console 108 | xyz@localhost:~$ smug start project 109 | 110 | xyz@localhost:~$ smug stop project 111 | ``` 112 | 113 | Also, smug has aliases to the most of the commands: 114 | 115 | ```console 116 | xyz@localhost:~$ smug project # the same as "smug start project" 117 | 118 | xyz@localhost:~$ smug st project # the same as "smug stop project" 119 | 120 | xyz@localhost:~$ smug p ses # the same as "smug print ses" 121 | ``` 122 | 123 | When you already have a running session, and you want only to create some windows from the configuration file, you can do something like this: 124 | 125 | ```console 126 | xyz@localhost:~$ smug start project:window1 127 | 128 | xyz@localhost:~$ smug start project:window1,window2 129 | 130 | xyz@localhost:~$ smug start project -w window1 131 | 132 | xyz@localhost:~$ smug start project -w window1 -w window2 133 | 134 | xyz@localhost:~$ smug stop project:window1 135 | 136 | xyz@localhost:~$ smug stop project -w window1 -w window2 137 | ``` 138 | 139 | Also, you are not obliged to put your files in the `~/.config/smug` directory. You can use a custom path in the `-f` flag: 140 | 141 | ```console 142 | xyz@localhost:~$ smug start -f ./project.yml 143 | 144 | xyz@localhost:~$ smug stop -f ./project.yml 145 | 146 | xyz@localhost:~$ smug start -f ./project.yml -w window1 -w window2 147 | ``` 148 | 149 | ## Configuration 150 | 151 | Configuration files can stored in the `~/.config/smug` directory in the `YAML` format, e.g `~/.config/smug/your_project.yml`. 152 | You may also create a file named `.smug.yml` in the current working directory, which will be used by default. 153 | 154 | ### Examples 155 | 156 | #### Example 1 157 | 158 | ```yaml 159 | session: blog 160 | 161 | root: ~/Developer/blog 162 | 163 | before_start: 164 | - docker-compose -f my-microservices/docker-compose.yml up -d # my-microservices/docker-compose.yml is a relative to `root` 165 | 166 | env: 167 | FOO: BAR 168 | 169 | stop: 170 | - docker stop $(docker ps -q) 171 | 172 | windows: 173 | - name: code 174 | root: blog # a relative path to root 175 | manual: true # you can start this window only manually, using the -w arg 176 | layout: main-vertical 177 | commands: 178 | - docker-compose start 179 | panes: 180 | - type: horizontal 181 | root: . 182 | commands: 183 | - docker-compose exec php /bin/sh 184 | - clear 185 | 186 | - name: infrastructure 187 | root: ~/Developer/blog/my-microservices 188 | layout: tiled 189 | panes: 190 | - type: horizontal 191 | root: . 192 | commands: 193 | - docker-compose up -d 194 | - docker-compose exec php /bin/sh 195 | - clear 196 | ``` 197 | 198 | #### Example 2 199 | 200 | ```yaml 201 | session: blog 202 | 203 | root: ~/Code/blog 204 | 205 | before_start: 206 | - docker-compose up -d 207 | 208 | stop: 209 | - docker-compose stop 210 | 211 | windows: 212 | - name: code 213 | selected: true # Selects this window at the start of the session 214 | layout: main-horizontal 215 | commands: 216 | - $EDITOR app/dependencies.php 217 | panes: 218 | - type: horizontal 219 | commands: 220 | - make run-tests 221 | - name: ssh 222 | commands: 223 | - ssh -i ~/keys/blog.pem ubuntu@127.0.0.1 224 | ``` 225 | -------------------------------------------------------------------------------- /commander.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | type ShellError struct { 11 | Command string 12 | Err error 13 | } 14 | 15 | func (e *ShellError) Error() string { 16 | return fmt.Sprintf("Cannot run %q. Error %v", e.Command, e.Err) 17 | } 18 | 19 | type Commander interface { 20 | Exec(cmd *exec.Cmd) (string, error) 21 | ExecSilently(cmd *exec.Cmd) error 22 | } 23 | 24 | type DefaultCommander struct { 25 | logger *log.Logger 26 | } 27 | 28 | func (c DefaultCommander) Exec(cmd *exec.Cmd) (string, error) { 29 | if c.logger != nil { 30 | c.logger.Println(strings.Join(cmd.Args, " ")) 31 | } 32 | 33 | output, err := cmd.CombinedOutput() 34 | if err != nil { 35 | if c.logger != nil { 36 | c.logger.Println(err, string(output)) 37 | } 38 | return "", &ShellError{strings.Join(cmd.Args, " "), err} 39 | } 40 | 41 | return strings.TrimSuffix(string(output), "\n"), nil 42 | } 43 | 44 | func (c DefaultCommander) ExecSilently(cmd *exec.Cmd) error { 45 | if c.logger != nil { 46 | c.logger.Println(strings.Join(cmd.Args, " ")) 47 | } 48 | 49 | err := cmd.Run() 50 | if err != nil { 51 | if c.logger != nil { 52 | c.logger.Println(err) 53 | } 54 | return &ShellError{strings.Join(cmd.Args, " "), err} 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /commander_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestMain(m *testing.M) { 14 | switch os.Getenv("TEST_MAIN") { 15 | case "": 16 | os.Exit(m.Run()) 17 | case "echo": 18 | fmt.Println(strings.Join(os.Args[1:], " ")) 19 | case "exit": 20 | os.Exit(42) 21 | } 22 | } 23 | 24 | func TestExec(t *testing.T) { 25 | logger := log.New(bytes.NewBuffer([]byte{}), "", 0) 26 | commander := DefaultCommander{logger} 27 | 28 | cmd := exec.Command(os.Args[0], "42") 29 | cmd.Env = append(os.Environ(), "TEST_MAIN=echo") 30 | 31 | output, err := commander.Exec(cmd) 32 | if err != nil { 33 | t.Fatalf("unexpected error %v", err) 34 | } 35 | 36 | if output != "42" { 37 | t.Errorf("expected 42, got %q", output) 38 | } 39 | } 40 | 41 | func TestExecError(t *testing.T) { 42 | logger := log.New(bytes.NewBuffer([]byte{}), "", 0) 43 | commander := DefaultCommander{logger} 44 | 45 | cmd := exec.Command(os.Args[0], "42") 46 | cmd.Env = append(os.Environ(), "TEST_MAIN=exit") 47 | 48 | _, err := commander.Exec(cmd) 49 | if err == nil { 50 | t.Errorf("expected error") 51 | } 52 | 53 | got := cmd.ProcessState.ExitCode() 54 | if got != 42 { 55 | t.Errorf("expected %d, got %d", 42, got) 56 | } 57 | } 58 | 59 | func TestExecSilently(t *testing.T) { 60 | logger := log.New(bytes.NewBuffer([]byte{}), "", 0) 61 | commander := DefaultCommander{logger} 62 | 63 | cmd := exec.Command(os.Args[0], "42") 64 | cmd.Env = append(os.Environ(), "TEST_MAIN=echo") 65 | 66 | err := commander.ExecSilently(cmd) 67 | if err != nil { 68 | t.Fatalf("unexpected error %v", err) 69 | } 70 | } 71 | 72 | func TestExecSilentlyError(t *testing.T) { 73 | logger := log.New(bytes.NewBuffer([]byte{}), "", 0) 74 | commander := DefaultCommander{logger} 75 | 76 | cmd := exec.Command(os.Args[0], "42") 77 | cmd.Env = append(os.Environ(), "TEST_MAIN=exit") 78 | 79 | err := commander.ExecSilently(cmd) 80 | if err == nil { 81 | t.Errorf("expected error") 82 | } 83 | 84 | got := cmd.ProcessState.ExitCode() 85 | if got != 42 { 86 | t.Errorf("expected %d, got %d", 42, got) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /completion/smug.bash: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | _smug() { 3 | local ISF=$'\n' 4 | local reply 5 | 6 | local cur="${COMP_WORDS[COMP_CWORD]}" 7 | local prev="${COMP_WORDS[COMP_CWORD-1]}" 8 | 9 | # if command is 'list' or 'print' do not suggest more 10 | for word in ${COMP_WORDS[@]}; do 11 | case $word in 12 | list|print) return 13 | esac 14 | done 15 | 16 | # commands 17 | if (( "${#COMP_WORDS[@]}" == 2 )); then 18 | reply=($(compgen -W "list print start stop" -- "${cur}")) 19 | fi 20 | 21 | # projects 22 | if (( "${#COMP_WORDS[@]}" == 3 )); then 23 | case ${prev} in 24 | start|stop) 25 | reply=($(compgen -W "$(smug list | grep -F -v smug)" -- "${cur}")) 26 | esac 27 | fi 28 | 29 | # options 30 | if (( "${#COMP_WORDS[@]}" > 3 )); then 31 | local options=( "--file" "--windows" "--attach" "--debug" ) 32 | 33 | # --windows waits for a list 34 | case $prev in 35 | -w|--windows) return 36 | esac 37 | 38 | # suggest options that were not specified already 39 | for word in "${COMP_WORDS[@]}"; do 40 | case $word in 41 | -f|--file) options=( "${options[@]/--file}" ) ;; 42 | -w|--windows) options=( "${options[@]/--windows}" ) ;; 43 | -a|--attach) options=( "${options[@]/--attach}" ) ;; 44 | -d|--debug) options=( "${options[@]/--debug}" ) ;; 45 | esac 46 | done 47 | 48 | # array to string 49 | local options="$(echo ${options[@]})" 50 | 51 | reply=($(compgen -W "${options}" -- "${cur}")) 52 | fi 53 | 54 | 55 | # if only one match proceed with autocompletion 56 | if (( "${#reply[@]}" == 1 )); then 57 | COMPREPLY=( "${reply[0]}" ) 58 | else 59 | # when 'TAB TAB' is pressed 60 | if (( COMP_TYPE == 63 )); then 61 | # print suggestions as list with padding 62 | for i in "${!reply[@]}"; do 63 | reply[$i]="$(printf '%*s' "-$COLUMNS" "${reply[$i]}")" 64 | done 65 | fi 66 | # print suggestions 67 | COMPREPLY=( "${reply[@]}" ) 68 | fi 69 | } 70 | 71 | complete -F _smug smug 72 | -------------------------------------------------------------------------------- /completion/smug.fish: -------------------------------------------------------------------------------- 1 | complete -x -c smug -a "(ls ~/.config/smug | grep -v \"smug\.log\" | sed -e 's/\..*//')" 2 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "os/user" 9 | "path" 10 | "path/filepath" 11 | "strings" 12 | 13 | "gopkg.in/yaml.v2" 14 | ) 15 | 16 | type ConfigNotFoundError struct { 17 | Project string 18 | } 19 | 20 | func (e ConfigNotFoundError) Error() string { 21 | return fmt.Sprintf("config not found for project %s", e.Project) 22 | } 23 | 24 | type Pane struct { 25 | Root string `yaml:"root,omitempty"` 26 | Type string `yaml:"type,omitempty"` 27 | Commands []string `yaml:"commands"` 28 | } 29 | 30 | type Window struct { 31 | Selected bool `yaml:"selected"` 32 | Name string `yaml:"name"` 33 | Root string `yaml:"root,omitempty"` 34 | BeforeStart []string `yaml:"before_start"` 35 | Panes []Pane `yaml:"panes"` 36 | Commands []string `yaml:"commands"` 37 | Layout string `yaml:"layout"` 38 | Manual bool `yaml:"manual,omitempty"` 39 | } 40 | 41 | type Config struct { 42 | SendKeysTimeout int `yaml:"sendkeys_timeout"` 43 | Session string `yaml:"session"` 44 | TmuxOptions `yaml:"tmux_options"` 45 | Env map[string]string `yaml:"env"` 46 | Root string `yaml:"root"` 47 | BeforeStart []string `yaml:"before_start"` 48 | Stop []string `yaml:"stop"` 49 | Windows []Window `yaml:"windows"` 50 | } 51 | 52 | func addDefaultEnvs(c *Config, path string) { 53 | c.Env["SMUG_SESSION"] = c.Session 54 | c.Env["SMUG_SESSION_CONFIG_PATH"] = path 55 | } 56 | 57 | func EditConfig(path string) error { 58 | editor := os.Getenv("EDITOR") 59 | if editor == "" { 60 | editor = "vim" 61 | } 62 | 63 | cmd := exec.Command(editor, path) 64 | cmd.Stdin = os.Stdin 65 | cmd.Stdout = os.Stdout 66 | cmd.Stderr = os.Stderr 67 | 68 | return cmd.Run() 69 | } 70 | 71 | func setTmuxOptions(tmuxOpts *TmuxOptions, c Config) { 72 | tmuxOpts.SocketName = c.SocketName 73 | tmuxOpts.SocketPath = c.SocketPath 74 | 75 | if c.ConfigFile != "" { 76 | usr, err := user.Current() 77 | if err != nil { 78 | log.Fatalf("cannot expand user home dir: %s", err) 79 | } 80 | path := c.ConfigFile 81 | if strings.HasPrefix(path, "~") { 82 | path = filepath.Join(usr.HomeDir, path[1:]) 83 | } 84 | 85 | tmuxOpts.ConfigFile = path 86 | } 87 | } 88 | 89 | func GetConfig(path string, settings map[string]string, tmuxOpts *TmuxOptions) (*Config, error) { 90 | f, err := os.ReadFile(path) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | config := string(f) 96 | 97 | c, err := ParseConfig(config, settings) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | addDefaultEnvs(&c, path) 103 | setTmuxOptions(tmuxOpts, c) 104 | 105 | return &c, err 106 | } 107 | 108 | func ParseConfig(data string, settings map[string]string) (Config, error) { 109 | data = os.Expand(data, func(v string) string { 110 | if val, ok := settings[v]; ok { 111 | return val 112 | } 113 | 114 | if val, ok := os.LookupEnv(v); ok { 115 | return val 116 | } 117 | 118 | return v 119 | }) 120 | 121 | c := Config{ 122 | Env: make(map[string]string), 123 | } 124 | 125 | err := yaml.Unmarshal([]byte(data), &c) 126 | if err != nil { 127 | return Config{}, err 128 | } 129 | 130 | return c, nil 131 | } 132 | 133 | func ListConfigs(dir string, includeDirs bool) ([]string, error) { 134 | var result []string 135 | files, err := os.ReadDir(dir) 136 | if err != nil { 137 | return result, err 138 | } 139 | 140 | for _, file := range files { 141 | fileExt := path.Ext(file.Name()) 142 | dirCheck := true 143 | if includeDirs { 144 | dirCheck = !file.IsDir() 145 | } 146 | if fileExt != ".yml" && fileExt != ".yaml" && dirCheck { 147 | continue 148 | } 149 | result = append(result, file.Name()) 150 | } 151 | 152 | return result, nil 153 | } 154 | 155 | func FindConfig(dir, project string) (string, error) { 156 | configs, err := ListConfigs(dir, false) 157 | if err != nil { 158 | return "", err 159 | } 160 | 161 | for _, config := range configs { 162 | fileExt := path.Ext(config) 163 | if strings.TrimSuffix(config, fileExt) == project { 164 | return config, nil 165 | } 166 | } 167 | 168 | return "", ConfigNotFoundError{Project: project} 169 | } 170 | 171 | func FindConfigs(dir, project string) ([]string, error) { 172 | isDir, _ := IsDirectory(dir + "/" + project) 173 | 174 | if isDir { 175 | configs, err := ListConfigs(dir+"/"+project, false) 176 | if err != nil { 177 | return configs, err 178 | } 179 | 180 | for configIndex, configName := range configs { 181 | configs[configIndex] = dir + "/" + project + "/" + configName 182 | } 183 | 184 | return nil, err 185 | } 186 | 187 | configs, err := ListConfigs(dir, false) 188 | if err != nil { 189 | return nil, fmt.Errorf("list configs %w", err) 190 | } 191 | 192 | if len(configs) == 0 { 193 | return nil, ConfigNotFoundError{Project: project} 194 | } 195 | 196 | for _, config := range configs { 197 | fileExt := path.Ext(config) 198 | if strings.TrimSuffix(config, fileExt) == project { 199 | return []string{dir + "/" + config}, nil 200 | } 201 | } 202 | 203 | return nil, ConfigNotFoundError{Project: project} 204 | } 205 | 206 | func IsDirectory(path string) (bool, error) { 207 | fileInfo, err := os.Stat(path) 208 | if os.IsNotExist(err) { 209 | return false, err 210 | } 211 | return fileInfo.IsDir(), err 212 | } 213 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestParseConfig(t *testing.T) { 10 | yaml := ` 11 | session: ${session} 12 | sendkeys_timeout: 200 13 | tmux_options: 14 | socket_name: foo 15 | socket_path: /path/to/socket 16 | config_file: /path/to/tmux_config 17 | windows: 18 | - layout: tiled 19 | commands: 20 | - echo 1 21 | panes: 22 | - commands: 23 | - echo 2 24 | - echo ${HOME} 25 | type: horizontal` 26 | 27 | config, err := ParseConfig(yaml, map[string]string{ 28 | "session": "test", 29 | }) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | expected := Config{ 35 | Session: "test", 36 | SendKeysTimeout: 200, 37 | Env: make(map[string]string), 38 | TmuxOptions: TmuxOptions{ 39 | SocketName: "foo", 40 | SocketPath: "/path/to/socket", 41 | ConfigFile: "/path/to/tmux_config", 42 | }, 43 | Windows: []Window{ 44 | { 45 | Layout: "tiled", 46 | Commands: []string{"echo 1"}, 47 | Panes: []Pane{ 48 | { 49 | Type: "horizontal", 50 | Commands: []string{"echo 2", "echo " + os.Getenv("HOME")}, 51 | }, 52 | }, 53 | }, 54 | }, 55 | } 56 | 57 | if !reflect.DeepEqual(expected, config) { 58 | t.Fatalf("expected %v, got %v", expected, config) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "os" 4 | 5 | type Context struct { 6 | InsideTmuxSession bool 7 | } 8 | 9 | func CreateContext() Context { 10 | _, tmux := os.LookupEnv("TMUX") 11 | insideTmuxSession := os.Getenv("TERM") == "screen" || tmux 12 | return Context{insideTmuxSession} 13 | } 14 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | var environmentTestTable = []struct { 10 | environment map[string]string 11 | context Context 12 | }{ 13 | { 14 | map[string]string{}, 15 | Context{InsideTmuxSession: false}, 16 | }, 17 | { 18 | map[string]string{ 19 | "TMUX": "", 20 | }, 21 | Context{InsideTmuxSession: true}, 22 | }, 23 | { 24 | map[string]string{ 25 | "TERM": "screen", 26 | }, 27 | Context{InsideTmuxSession: true}, 28 | }, 29 | { 30 | map[string]string{ 31 | "TERM": "xterm", 32 | "TMUX": "", 33 | }, 34 | Context{InsideTmuxSession: true}, 35 | }, 36 | } 37 | 38 | func TestCreateContext(t *testing.T) { 39 | os.Clearenv() 40 | for _, v := range environmentTestTable { 41 | for key, value := range v.environment { 42 | os.Setenv(key, value) 43 | } 44 | 45 | context := CreateContext() 46 | 47 | if !reflect.DeepEqual(v.context, context) { 48 | t.Errorf("expected context %v, got %v", v.context, context) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ivaaaan/smug 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/spf13/pflag v1.0.5 7 | gopkg.in/yaml.v2 v2.4.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 2 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 3 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 4 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 5 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 6 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | 12 | "gopkg.in/yaml.v2" 13 | ) 14 | 15 | var version = "[dev build]" 16 | 17 | var usage = fmt.Sprintf(`Smug - tmux session manager. Version %s 18 | 19 | 20 | Usage: 21 | smug [] [-f, --file ] [-w, --windows ]... [-a, --attach] [-d, --debug] [--detach] [-i, --inside-current-session] [=]... 22 | 23 | Options: 24 | -f, --file %s 25 | -w, --windows %s 26 | -a, --attach %s 27 | -i, --inside-current-session %s 28 | -d, --debug %s 29 | --detach %s 30 | 31 | Commands: 32 | list list available project configurations 33 | edit edit project configuration 34 | new new project configuration 35 | start start project session 36 | stop stop project session 37 | print session configuration to stdout 38 | 39 | Examples: 40 | $ smug list 41 | $ smug edit blog 42 | $ smug new blog 43 | $ smug start blog 44 | $ smug start blog:win1 45 | $ smug start blog -w win1 46 | $ smug start blog:win1,win2 47 | $ smug stop blog 48 | $ smug start blog --attach 49 | $ smug print > ~/.config/smug/blog.yml 50 | `, version, FileUsage, WindowsUsage, AttachUsage, InsideCurrentSessionUsage, DebugUsage, DetachUsage) 51 | 52 | const defaultConfigFile = ".smug.yml" 53 | 54 | func newLogger(path string) *log.Logger { 55 | logFile, err := os.Create(filepath.Join(path, "smug.log")) 56 | if err != nil { 57 | fmt.Fprintln(os.Stderr, err.Error()) 58 | os.Exit(1) 59 | } 60 | return log.New(logFile, "", 0) 61 | } 62 | 63 | func main() { 64 | options, err := ParseOptions(os.Args[1:]) 65 | if errors.Is(err, ErrHelp) { 66 | fmt.Fprint(os.Stdout, usage) 67 | os.Exit(0) 68 | } 69 | 70 | if err != nil { 71 | fmt.Fprintf( 72 | os.Stderr, 73 | "Cannot parse command line options: %q", 74 | err.Error(), 75 | ) 76 | os.Exit(1) 77 | } 78 | 79 | userConfigDir := filepath.Join(ExpandPath("~/"), ".config/smug") 80 | 81 | var logger *log.Logger 82 | if options.Debug { 83 | logger = newLogger(userConfigDir) 84 | } 85 | 86 | commander := DefaultCommander{logger} 87 | tmux := Tmux{commander, &TmuxOptions{}} 88 | smug := Smug{tmux, commander} 89 | context := CreateContext() 90 | configs := []string{} 91 | 92 | var configPath string 93 | if options.Config != "" { 94 | configPath = options.Config 95 | } else if options.Project != "" { 96 | 97 | config, err := FindConfig(userConfigDir, options.Project) 98 | 99 | if err != nil && options.Command != CommandNew && options.Command != CommandStart && options.Command != CommandStop { 100 | fmt.Fprintln(os.Stderr, err.Error()) 101 | os.Exit(1) 102 | } 103 | if options.Command == CommandNew { 104 | config = fmt.Sprintf("%s.yml", options.Project) 105 | } 106 | 107 | configPath = filepath.Join(userConfigDir, config) 108 | configs = append(configs, configPath) 109 | } else { 110 | path, err := os.Getwd() 111 | if err != nil { 112 | fmt.Fprintln(os.Stderr, err.Error()) 113 | os.Exit(1) 114 | } 115 | 116 | configPath = filepath.Join(path, defaultConfigFile) 117 | configs = append(configs, configPath) 118 | } 119 | 120 | switch options.Command { 121 | case CommandStart: 122 | if len(options.Windows) == 0 { 123 | fmt.Println("Starting a new session...") 124 | } else { 125 | fmt.Println("Starting new windows...") 126 | } 127 | 128 | if options.Project != "" { 129 | projectConfigs, err := FindConfigs(userConfigDir, options.Project) 130 | if err != nil { 131 | fmt.Fprint(os.Stderr, err.Error()) 132 | os.Exit(1) 133 | } 134 | 135 | configs = append(configs, projectConfigs...) 136 | } 137 | 138 | for configIndex, configPath := range configs { 139 | config, err := GetConfig(configPath, options.Settings, smug.tmux.TmuxOptions) 140 | if err != nil { 141 | fmt.Fprint(os.Stderr, err.Error()) 142 | os.Exit(1) 143 | } 144 | 145 | options.Detach = configIndex != len(configs)-1 146 | 147 | err = smug.Start(config, options, context) 148 | if err != nil { 149 | fmt.Println("Oops, an error occurred! Rolling back...") 150 | smug.Stop(config, options, context) 151 | os.Exit(1) 152 | } 153 | } 154 | case CommandStop: 155 | if len(options.Windows) == 0 { 156 | fmt.Println("Terminating session...") 157 | } else { 158 | fmt.Println("Killing windows...") 159 | } 160 | configs, err := FindConfigs(userConfigDir, options.Project) 161 | if err != nil { 162 | fmt.Fprint(os.Stderr, err.Error()) 163 | os.Exit(1) 164 | } 165 | for _, configPath := range configs { 166 | config, err := GetConfig(configPath, options.Settings, smug.tmux.TmuxOptions) 167 | if err != nil { 168 | fmt.Fprint(os.Stderr, err.Error()) 169 | os.Exit(1) 170 | } 171 | 172 | err = smug.Stop(config, options, context) 173 | if err != nil { 174 | fmt.Fprint(os.Stderr, err.Error()) 175 | os.Exit(1) 176 | } 177 | } 178 | case CommandNew, CommandEdit: 179 | err := EditConfig(configPath) 180 | if err != nil { 181 | fmt.Fprint(os.Stderr, err.Error()) 182 | os.Exit(1) 183 | } 184 | case CommandList: 185 | configs, err := ListConfigs(userConfigDir, true) 186 | if err != nil { 187 | fmt.Fprint(os.Stderr, err.Error()) 188 | os.Exit(1) 189 | } 190 | 191 | for _, config := range configs { 192 | fileExt := path.Ext(config) 193 | fmt.Println(strings.TrimSuffix(config, fileExt)) 194 | isDir, err := IsDirectory(userConfigDir + "/" + config) 195 | if err != nil { 196 | continue 197 | } 198 | if isDir { 199 | 200 | subConfigs, err := ListConfigs(userConfigDir+"/"+config, false) 201 | if err != nil { 202 | fmt.Fprint(os.Stderr, err.Error()) 203 | os.Exit(1) 204 | } 205 | for _, subConfig := range subConfigs { 206 | fileExt := path.Ext(subConfig) 207 | fmt.Println("|--" + strings.TrimSuffix(subConfig, fileExt)) 208 | } 209 | 210 | } 211 | 212 | } 213 | 214 | case CommandPrint: 215 | config, err := smug.GetConfigFromSession(options, context) 216 | if err != nil { 217 | fmt.Fprint(os.Stderr, err.Error()) 218 | os.Exit(1) 219 | } 220 | 221 | d, err := yaml.Marshal(&config) 222 | if err != nil { 223 | fmt.Fprint(os.Stderr, err.Error()) 224 | os.Exit(1) 225 | } 226 | 227 | fmt.Println(string(d)) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /man/man1/smug.1: -------------------------------------------------------------------------------- 1 | .ig 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2020-2021 Ivan Klymenchenko 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | .. 24 | .TH smug 1 "Mar 2021" "smug 0.2.0" "smug - a session manager for tmux written in go" 25 | 26 | .SH NAME 27 | smug - a session manager for tmux written in go 28 | 29 | .SH SYNOPSIS 30 | smug [options] [command] 31 | 32 | .SH DESCRIPTION 33 | smug is a session manager for tmux. Configuration is inspired by tmuxinator and tmuxp. 34 | 35 | .SH GLOBAL OPTIONS 36 | .TP 37 | .B "-d, --debug" 38 | Print all commands to ~/.config/smug/smug.log 39 | 40 | .SH COMMANDS 41 | .TP 42 | .B "list" 43 | Display all smug project configurations. 44 | .TP 45 | .B "start []" 46 | Start a tmux project session. 47 | .br 48 | 49 | .B COMMAND OPTIONS 50 | .TP 51 | .IP 52 | .B "-f, --file" 53 | A custom path to a config file. 54 | .TP 55 | .IP 56 | .B "-w, --windows" 57 | List of windows to start. If session exists, those windows will be attached to current session. 58 | .TP 59 | .IP 60 | .B "-a, --attach" 61 | Force switch client for a session. 62 | 63 | .TP 64 | .B "stop []" 65 | Stop tmux project session 66 | 67 | .TP 68 | .B "print" 69 | Print current session configuration as yaml to stdout 70 | 71 | .SH EXAMPLES 72 | $ smug list 73 | .br 74 | $ smug start blog 75 | .br 76 | $ smug start blog:win1 77 | .br 78 | $ smug start blog -w win1 79 | .br 80 | $ smug start blog:win1,win2 81 | .br 82 | $ smug stop blog 83 | .br 84 | $ smug start blog --attach 85 | .br 86 | $ smug print > ~/.config/smug/new_project.yml 87 | 88 | .SH AUTHOR 89 | Ivan Klymenchenko 90 | 91 | .SH SEE ALSO 92 | .B Project homepage: 93 | .RS 94 | .I https://github.com/ivaaaan/smug 95 | .RE 96 | .br 97 | 98 | .SH LICENSE 99 | MIT 100 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/pflag" 9 | ) 10 | 11 | const ( 12 | CommandStart = "start" 13 | CommandStop = "stop" 14 | CommandNew = "new" 15 | CommandEdit = "edit" 16 | CommandList = "list" 17 | CommandPrint = "print" 18 | ) 19 | 20 | type command struct { 21 | Name string 22 | Aliases []string 23 | } 24 | 25 | type commands []command 26 | 27 | var Commands = commands{ 28 | { 29 | Name: CommandStart, 30 | Aliases: []string{}, 31 | }, 32 | { 33 | Name: CommandStop, 34 | Aliases: []string{"s", "st"}, 35 | }, 36 | { 37 | Name: CommandNew, 38 | Aliases: []string{"n"}, 39 | }, 40 | { 41 | Name: CommandEdit, 42 | Aliases: []string{"e"}, 43 | }, 44 | { 45 | Name: CommandList, 46 | Aliases: []string{"l"}, 47 | }, 48 | { 49 | Name: CommandPrint, 50 | Aliases: []string{"p"}, 51 | }, 52 | } 53 | 54 | func (c *commands) Resolve(v string) (*command, error) { 55 | for _, cmd := range *c { 56 | if cmd.Name == v || Contains(cmd.Aliases, v) { 57 | return &cmd, nil 58 | } 59 | } 60 | 61 | return nil, ErrCommandNotFound 62 | } 63 | 64 | func (c *commands) FindByName(n string) *command { 65 | for _, cmd := range *c { 66 | if cmd.Name == n { 67 | return &cmd 68 | } 69 | } 70 | 71 | return nil 72 | } 73 | 74 | type Options struct { 75 | Command string 76 | Project string 77 | Config string 78 | Windows []string 79 | Settings map[string]string 80 | Attach bool 81 | Detach bool 82 | Debug bool 83 | InsideCurrentSession bool 84 | } 85 | 86 | var ( 87 | ErrHelp = errors.New("help requested") 88 | ErrCommandNotFound = errors.New("command not found") 89 | ) 90 | 91 | const ( 92 | WindowsUsage = "List of windows to start. If session exists, those windows will be attached to current session" 93 | AttachUsage = "Force switch client for a session" 94 | DetachUsage = "Detach tmux session. The same as -d flag in the tmux" 95 | DebugUsage = "Print all commands to ~/.config/smug/smug.log" 96 | FileUsage = "A custom path to a config file" 97 | InsideCurrentSessionUsage = "Create all windows inside current session" 98 | ) 99 | 100 | func parseUserSettings(args []string) map[string]string { 101 | settings := make(map[string]string) 102 | for _, kv := range args { 103 | s := strings.Split(kv, "=") 104 | if len(s) < 2 { 105 | continue 106 | } 107 | settings[s[0]] = s[1] 108 | } 109 | 110 | return settings 111 | } 112 | 113 | func ParseOptions(argv []string) (*Options, error) { 114 | if len(argv) == 0 || argv[0] == "--help" || argv[0] == "-h" { 115 | return nil, ErrHelp 116 | } 117 | 118 | cmd, cmdErr := Commands.Resolve(argv[0]) 119 | if errors.Is(cmdErr, ErrCommandNotFound) { 120 | cmd = Commands.FindByName(CommandStart) 121 | } 122 | 123 | flags := pflag.NewFlagSet(cmd.Name, pflag.ContinueOnError) 124 | 125 | config := flags.StringP("file", "f", "", FileUsage) 126 | windows := flags.StringArrayP("windows", "w", []string{}, WindowsUsage) 127 | attach := flags.BoolP("attach", "a", false, AttachUsage) 128 | detach := flags.Bool("detach", false, DetachUsage) 129 | debug := flags.BoolP("debug", "d", false, DebugUsage) 130 | insideCurrentSession := flags.BoolP("inside-current-session", "i", false, InsideCurrentSessionUsage) 131 | 132 | err := flags.Parse(argv) 133 | if err == pflag.ErrHelp { 134 | return nil, ErrHelp 135 | } 136 | 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | var project string 142 | if *config == "" { 143 | if errors.Is(cmdErr, ErrCommandNotFound) { 144 | project = argv[0] 145 | } else if len(argv) > 1 { 146 | project = argv[1] 147 | } 148 | } 149 | 150 | // If config file flag is not set, and env is, use the env 151 | val, ok := os.LookupEnv("SMUG_SESSION_CONFIG_PATH") 152 | if *config == "" && project == "" && ok { 153 | *config = val 154 | } 155 | 156 | if strings.Contains(project, ":") { 157 | parts := strings.Split(project, ":") 158 | project = parts[0] 159 | wl := strings.Split(parts[1], ",") 160 | windows = &wl 161 | } 162 | 163 | settings := parseUserSettings(flags.Args()[1:]) 164 | 165 | return &Options{ 166 | Project: project, 167 | Config: *config, 168 | Command: cmd.Name, 169 | Settings: settings, 170 | Windows: *windows, 171 | Attach: *attach, 172 | Detach: *detach, 173 | Debug: *debug, 174 | InsideCurrentSession: *insideCurrentSession, 175 | }, nil 176 | } 177 | -------------------------------------------------------------------------------- /options_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | var usageTestTable = []struct { 11 | argv []string 12 | opts Options 13 | err error 14 | env map[string]string 15 | }{ 16 | { 17 | []string{"start", "smug"}, 18 | Options{ 19 | Command: "start", 20 | Project: "smug", 21 | Config: "", 22 | Windows: []string{}, 23 | Attach: false, 24 | Detach: false, 25 | Debug: false, 26 | Settings: map[string]string{}, 27 | }, 28 | nil, 29 | nil, 30 | }, 31 | { 32 | []string{"start", "smug", "-w", "foo"}, 33 | Options{ 34 | Command: "start", 35 | Project: "smug", 36 | Config: "", 37 | Windows: []string{"foo"}, 38 | Attach: false, 39 | Detach: false, 40 | Debug: false, 41 | Settings: map[string]string{}, 42 | }, 43 | nil, 44 | nil, 45 | }, 46 | { 47 | []string{"start", "smug:foo,bar"}, 48 | Options{ 49 | Command: "start", 50 | Project: "smug", 51 | Config: "", 52 | Windows: []string{"foo", "bar"}, 53 | Attach: false, 54 | Detach: false, 55 | Debug: false, 56 | Settings: map[string]string{}, 57 | }, 58 | nil, 59 | nil, 60 | }, 61 | { 62 | []string{"start", "smug", "--attach", "--debug", "--detach"}, 63 | Options{ 64 | Command: "start", 65 | Project: "smug", 66 | Config: "", 67 | Windows: []string{}, 68 | Attach: true, 69 | Detach: true, 70 | Debug: true, 71 | Settings: map[string]string{}, 72 | }, 73 | nil, 74 | nil, 75 | }, 76 | { 77 | []string{"start", "smug", "-ad"}, 78 | Options{ 79 | Command: "start", 80 | Project: "smug", 81 | Config: "", 82 | Windows: []string{}, 83 | Attach: true, 84 | Detach: false, 85 | Debug: true, 86 | Settings: map[string]string{}, 87 | }, 88 | nil, 89 | nil, 90 | }, 91 | { 92 | []string{"start", "-f", "test.yml"}, 93 | Options{ 94 | Command: "start", 95 | Project: "", 96 | Config: "test.yml", 97 | Windows: []string{}, 98 | Attach: false, 99 | Detach: false, 100 | Debug: false, 101 | Settings: map[string]string{}, 102 | }, 103 | nil, 104 | nil, 105 | }, 106 | { 107 | []string{"start", "-f", "test.yml", "-w", "win1", "-w", "win2"}, 108 | Options{ 109 | Command: "start", 110 | Project: "", 111 | Config: "test.yml", 112 | Windows: []string{"win1", "win2"}, 113 | Attach: false, 114 | Detach: false, 115 | Debug: false, 116 | Settings: map[string]string{}, 117 | }, 118 | nil, 119 | nil, 120 | }, 121 | { 122 | []string{"start", "project", "a=b", "x=y"}, 123 | Options{ 124 | Command: "start", 125 | Project: "project", 126 | Config: "", 127 | Windows: []string{}, 128 | Attach: false, 129 | Detach: false, 130 | Debug: false, 131 | Settings: map[string]string{ 132 | "a": "b", 133 | "x": "y", 134 | }, 135 | }, 136 | nil, 137 | nil, 138 | }, 139 | { 140 | []string{"start", "-f", "test.yml", "a=b", "x=y"}, 141 | Options{ 142 | Command: "start", 143 | Project: "", 144 | Config: "test.yml", 145 | Windows: []string{}, 146 | Attach: false, 147 | Detach: false, 148 | Debug: false, 149 | Settings: map[string]string{ 150 | "a": "b", 151 | "x": "y", 152 | }, 153 | }, 154 | nil, 155 | nil, 156 | }, 157 | { 158 | []string{"start", "-f", "test.yml", "-w", "win1", "-w", "win2", "a=b", "x=y"}, 159 | Options{ 160 | Command: "start", 161 | Project: "", 162 | Config: "test.yml", 163 | Windows: []string{"win1", "win2"}, 164 | Attach: false, 165 | Detach: false, 166 | Debug: false, 167 | Settings: map[string]string{ 168 | "a": "b", 169 | "x": "y", 170 | }, 171 | }, 172 | nil, 173 | nil, 174 | }, 175 | { 176 | []string{"start", "--help"}, 177 | Options{}, 178 | ErrHelp, 179 | nil, 180 | }, 181 | { 182 | []string{"test"}, 183 | Options{ 184 | Command: "start", 185 | Project: "test", 186 | Windows: []string{}, 187 | Settings: map[string]string{}, 188 | }, 189 | nil, 190 | nil, 191 | }, 192 | { 193 | []string{"test", "-w", "win1", "-w", "win2", "a=b", "x=y"}, 194 | Options{ 195 | Command: "start", 196 | Project: "test", 197 | Windows: []string{"win1", "win2"}, 198 | Settings: map[string]string{"a": "b", "x": "y"}, 199 | }, 200 | nil, 201 | nil, 202 | }, 203 | { 204 | []string{"test"}, 205 | Options{ 206 | Command: "start", 207 | Project: "test", 208 | Config: "", 209 | Windows: []string{}, 210 | Settings: map[string]string{}, 211 | }, 212 | nil, 213 | map[string]string{ 214 | "SMUG_SESSION_CONFIG_PATH": "test", 215 | }, 216 | }, 217 | { 218 | []string{}, 219 | Options{}, 220 | ErrHelp, 221 | nil, 222 | }, 223 | { 224 | []string{"--help"}, 225 | Options{}, 226 | ErrHelp, 227 | nil, 228 | }, 229 | { 230 | []string{"start", "--test"}, 231 | Options{}, 232 | errors.New("unknown flag: --test"), 233 | nil, 234 | }, 235 | } 236 | 237 | func TestParseOptions(t *testing.T) { 238 | for _, v := range usageTestTable { 239 | for k, v := range v.env { 240 | os.Setenv(k, v) 241 | } 242 | opts, err := ParseOptions(v.argv) 243 | if v.err != nil && err != nil && err.Error() != v.err.Error() { 244 | t.Errorf("expected error %v, got %v", v.err, err) 245 | } 246 | 247 | if opts != nil && !reflect.DeepEqual(v.opts, *opts) { 248 | t.Errorf("expected struct %v, got %v", v.opts, opts) 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /smug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const defaultWindowName = "smug_def" 13 | 14 | func ExpandPath(path string) string { 15 | if strings.HasPrefix(path, "~/") { 16 | userHome, err := os.UserHomeDir() 17 | if err != nil { 18 | return path 19 | } 20 | 21 | return strings.Replace(path, "~", userHome, 1) 22 | } 23 | 24 | return path 25 | } 26 | 27 | func Contains(slice []string, s string) bool { 28 | for _, e := range slice { 29 | if e == s { 30 | return true 31 | } 32 | } 33 | 34 | return false 35 | } 36 | 37 | type Smug struct { 38 | tmux Tmux 39 | commander Commander 40 | } 41 | 42 | func (smug Smug) execShellCommands(commands []string, path string) error { 43 | for _, c := range commands { 44 | cmd := exec.Command("/bin/sh", "-c", c) 45 | cmd.Dir = path 46 | 47 | _, err := smug.commander.Exec(cmd) 48 | if err != nil { 49 | return err 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | func (smug Smug) setEnvVariables(target string, env map[string]string) error { 56 | for key, value := range env { 57 | _, err := smug.tmux.SetEnv(target, key, value) 58 | if err != nil { 59 | return err 60 | } 61 | } 62 | 63 | return nil 64 | } 65 | 66 | func (smug Smug) switchOrAttach(target string, attach bool, insideTmuxSession bool) error { 67 | if insideTmuxSession && attach { 68 | return smug.tmux.SwitchClient(target) 69 | } else if !insideTmuxSession { 70 | return smug.tmux.Attach(target, os.Stdin, os.Stdout, os.Stderr) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func (smug Smug) Stop(config *Config, options *Options, context Context) error { 77 | windows := options.Windows 78 | if len(windows) == 0 { 79 | sessionRoot := ExpandPath(config.Root) 80 | 81 | err := smug.execShellCommands(config.Stop, sessionRoot) 82 | if err != nil { 83 | return err 84 | } 85 | _, err = smug.tmux.StopSession(config.Session) 86 | return err 87 | } 88 | 89 | for _, w := range windows { 90 | err := smug.tmux.KillWindow(config.Session + ":" + w) 91 | if err != nil { 92 | return err 93 | } 94 | } 95 | 96 | return nil 97 | } 98 | 99 | func (smug Smug) Start(config *Config, options *Options, context Context) error { 100 | var sessionName string 101 | var err error 102 | 103 | createWindowsInsideCurrSession := options.InsideCurrentSession 104 | if createWindowsInsideCurrSession && !context.InsideTmuxSession { 105 | return errors.New("cannot use -i flag outside of a tmux session") 106 | } 107 | 108 | sessionName = config.Session 109 | if createWindowsInsideCurrSession { 110 | sessionName, err = smug.tmux.SessionName() 111 | if err != nil { 112 | return err 113 | } 114 | } 115 | sessionName = sessionName + ":" 116 | 117 | sessionExists := smug.tmux.SessionExists(sessionName) 118 | sessionRoot := ExpandPath(config.Root) 119 | windows := options.Windows 120 | attach := options.Attach 121 | 122 | if !sessionExists && !createWindowsInsideCurrSession { 123 | err := smug.execShellCommands(config.BeforeStart, sessionRoot) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | _, err = smug.tmux.NewSession(config.Session, sessionRoot, defaultWindowName) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | err = smug.setEnvVariables(config.Session, config.Env) 134 | if err != nil { 135 | return err 136 | } 137 | } else if len(windows) == 0 && !createWindowsInsideCurrSession { 138 | return smug.switchOrAttach(sessionName, attach, context.InsideTmuxSession) 139 | } 140 | currentWindowName := "" 141 | for _, w := range config.Windows { 142 | if (len(windows) == 0 && w.Manual) || (len(windows) > 0 && !Contains(windows, w.Name)) { 143 | continue 144 | } 145 | 146 | if w.Selected { 147 | currentWindowName = w.Name 148 | } 149 | windowRoot := ExpandPath(w.Root) 150 | if windowRoot == "" || !filepath.IsAbs(windowRoot) { 151 | windowRoot = filepath.Join(sessionRoot, w.Root) 152 | } 153 | 154 | window, err := smug.tmux.NewWindow(sessionName, w.Name, windowRoot) 155 | if err != nil { 156 | return err 157 | } 158 | 159 | for _, c := range w.Commands { 160 | time.Sleep(time.Millisecond * time.Duration(config.SendKeysTimeout)) 161 | err := smug.tmux.SendKeys(window, c) 162 | if err != nil { 163 | return err 164 | } 165 | } 166 | 167 | for i, p := range w.Panes { 168 | paneRoot := ExpandPath(p.Root) 169 | if paneRoot == "" || !filepath.IsAbs(p.Root) { 170 | paneRoot = filepath.Join(windowRoot, p.Root) 171 | } 172 | 173 | newPane, err := smug.tmux.SplitWindow(window, p.Type, paneRoot) 174 | if err != nil { 175 | return err 176 | } 177 | 178 | if i%2 == 0 { 179 | _, err = smug.tmux.SelectLayout(window, Tiled) 180 | if err != nil { 181 | return err 182 | } 183 | 184 | } 185 | 186 | for _, c := range p.Commands { 187 | time.Sleep(time.Millisecond * time.Duration(config.SendKeysTimeout)) 188 | err = smug.tmux.SendKeys(window+"."+newPane, c) 189 | if err != nil { 190 | return err 191 | } 192 | } 193 | } 194 | 195 | layout := w.Layout 196 | if layout == "" { 197 | layout = EvenHorizontal 198 | } 199 | 200 | _, err = smug.tmux.SelectLayout(window, layout) 201 | if err != nil { 202 | return err 203 | } 204 | } 205 | 206 | if !options.InsideCurrentSession && !sessionExists { 207 | err := smug.tmux.KillWindow(sessionName + defaultWindowName) 208 | if err != nil { 209 | return err 210 | } 211 | err = smug.tmux.RenumberWindows(sessionName) 212 | if err != nil { 213 | return err 214 | } 215 | } 216 | 217 | if len(config.Windows) > 0 && !options.Detach { 218 | w := config.Windows[0].Name 219 | if len(options.Windows) > 0 { 220 | w = options.Windows[0] 221 | } 222 | 223 | err := smug.switchOrAttach(sessionName+w, attach, context.InsideTmuxSession) 224 | if err != nil && currentWindowName == "" { 225 | return err 226 | } 227 | } 228 | 229 | if currentWindowName != "" { 230 | return smug.tmux.SelectWindow(sessionName + currentWindowName) 231 | 232 | } 233 | return nil 234 | } 235 | 236 | func (smug Smug) GetConfigFromSession(options *Options, context Context) (Config, error) { 237 | config := Config{} 238 | 239 | tmuxSession, err := smug.tmux.SessionName() 240 | if err != nil { 241 | return Config{}, err 242 | } 243 | config.Session = tmuxSession 244 | 245 | tmuxWindows, err := smug.tmux.ListWindows(options.Project) 246 | if err != nil { 247 | return Config{}, err 248 | } 249 | 250 | for _, w := range tmuxWindows { 251 | tmuxPanes, err := smug.tmux.ListPanes(options.Project + ":" + w.ID) 252 | if err != nil { 253 | return Config{}, err 254 | } 255 | 256 | panes := []Pane{} 257 | for _, p := range tmuxPanes { 258 | root := p.Root 259 | if root == w.Root { 260 | root = "" 261 | } 262 | panes = append(panes, Pane{ 263 | Root: root, 264 | }) 265 | } 266 | 267 | config.Windows = append(config.Windows, Window{ 268 | Name: w.Name, 269 | Layout: w.Layout, 270 | Root: w.Root, 271 | Panes: panes, 272 | }) 273 | } 274 | 275 | return config, nil 276 | } 277 | -------------------------------------------------------------------------------- /smug_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | var testTable = map[string]struct { 12 | config *Config 13 | options *Options 14 | context Context 15 | startCommands []string 16 | stopCommands []string 17 | commanderOutputs []string 18 | }{ 19 | "test with 1 window": { 20 | &Config{ 21 | Session: "ses", 22 | Root: "~/root", 23 | BeforeStart: []string{"command1", "command2"}, 24 | Windows: []Window{ 25 | { 26 | Name: "win1", 27 | Commands: []string{"command1"}, 28 | }, 29 | }, 30 | }, 31 | &Options{}, 32 | Context{}, 33 | []string{ 34 | "tmux has-session -t ses:", 35 | "/bin/sh -c command1", 36 | "/bin/sh -c command2", 37 | "tmux new -Pd -s ses -n smug_def -c smug/root", 38 | "tmux neww -Pd -t ses: -c smug/root -F #{window_id} -n win1", 39 | "tmux send-keys -t win1 command1 Enter", 40 | "tmux select-layout -t win1 even-horizontal", 41 | "tmux kill-window -t ses:smug_def", 42 | "tmux move-window -r -s ses: -t ses:", 43 | "tmux attach -d -t ses:win1", 44 | }, 45 | []string{ 46 | "tmux kill-session -t ses", 47 | }, 48 | []string{"ses", "win1"}, 49 | }, 50 | "test with 1 window and Detach: true": { 51 | &Config{ 52 | Session: "ses", 53 | Root: "root", 54 | BeforeStart: []string{"command1", "command2"}, 55 | Windows: []Window{ 56 | { 57 | Name: "win1", 58 | }, 59 | }, 60 | }, 61 | &Options{ 62 | Detach: true, 63 | }, 64 | Context{}, 65 | []string{ 66 | "tmux has-session -t ses:", 67 | "/bin/sh -c command1", 68 | "/bin/sh -c command2", 69 | "tmux new -Pd -s ses -n smug_def -c root", 70 | "tmux neww -Pd -t ses: -c root -F #{window_id} -n win1", 71 | "tmux select-layout -t xyz even-horizontal", 72 | "tmux kill-window -t ses:smug_def", 73 | "tmux move-window -r -s ses: -t ses:", 74 | }, 75 | []string{ 76 | "tmux kill-session -t ses", 77 | }, 78 | []string{"xyz"}, 79 | }, 80 | "test with multiple windows and panes": { 81 | &Config{ 82 | Session: "ses", 83 | Root: "root", 84 | Windows: []Window{ 85 | { 86 | Name: "win1", 87 | Manual: false, 88 | Layout: "main-horizontal", 89 | Panes: []Pane{ 90 | { 91 | Type: "horizontal", 92 | Commands: []string{"command1"}, 93 | }, 94 | }, 95 | }, 96 | { 97 | Name: "win2", 98 | Manual: true, 99 | Layout: "tiled", 100 | }, 101 | }, 102 | Stop: []string{ 103 | "stop1", 104 | "stop2 -d --foo=bar", 105 | }, 106 | }, 107 | &Options{}, 108 | Context{}, 109 | []string{ 110 | "tmux has-session -t ses:", 111 | "tmux new -Pd -s ses -n smug_def -c root", 112 | "tmux neww -Pd -t ses: -c root -F #{window_id} -n win1", 113 | "tmux split-window -Pd -h -t win1 -c root -F #{pane_id}", 114 | "tmux select-layout -t win1 tiled", 115 | "tmux send-keys -t win1.1 command1 Enter", 116 | "tmux select-layout -t win1 main-horizontal", 117 | "tmux kill-window -t ses:smug_def", 118 | "tmux move-window -r -s ses: -t ses:", 119 | "tmux attach -d -t ses:win1", 120 | }, 121 | []string{ 122 | "/bin/sh -c stop1", 123 | "/bin/sh -c stop2 -d --foo=bar", 124 | "tmux kill-session -t ses", 125 | }, 126 | []string{"ses", "ses", "win1", "1"}, 127 | }, 128 | "test start windows from option's Windows parameter": { 129 | &Config{ 130 | Session: "ses", 131 | Root: "root", 132 | Windows: []Window{ 133 | { 134 | Name: "win1", 135 | Manual: false, 136 | }, 137 | { 138 | Name: "win2", 139 | Manual: true, 140 | }, 141 | }, 142 | }, 143 | &Options{ 144 | Windows: []string{"win2"}, 145 | }, 146 | Context{}, 147 | []string{ 148 | "tmux has-session -t ses:", 149 | "tmux new -Pd -s ses -n smug_def -c root", 150 | "tmux neww -Pd -t ses: -c root -F #{window_id} -n win2", 151 | "tmux select-layout -t xyz even-horizontal", 152 | "tmux kill-window -t ses:smug_def", 153 | "tmux move-window -r -s ses: -t ses:", 154 | "tmux attach -d -t ses:win2", 155 | }, 156 | []string{ 157 | "tmux kill-window -t ses:win2", 158 | }, 159 | []string{"xyz"}, 160 | }, 161 | "test attach to the existing session": { 162 | &Config{ 163 | Session: "ses", 164 | Root: "root", 165 | Windows: []Window{ 166 | {Name: "win1"}, 167 | }, 168 | }, 169 | &Options{}, 170 | Context{}, 171 | []string{ 172 | "tmux has-session -t ses:", 173 | "tmux attach -d -t ses:", 174 | }, 175 | []string{ 176 | "tmux kill-session -t ses", 177 | }, 178 | []string{""}, 179 | }, 180 | "test start a new session from another tmux session": { 181 | &Config{ 182 | Session: "ses", 183 | Root: "root", 184 | }, 185 | &Options{Attach: false}, 186 | Context{InsideTmuxSession: true}, 187 | []string{ 188 | "tmux has-session -t ses:", 189 | "tmux new -Pd -s ses -n smug_def -c root", 190 | "tmux kill-window -t ses:smug_def", 191 | "tmux move-window -r -s ses: -t ses:", 192 | }, 193 | []string{ 194 | "tmux kill-session -t ses", 195 | }, 196 | []string{"xyz"}, 197 | }, 198 | "test switch a client from another tmux session": { 199 | &Config{ 200 | Session: "ses", 201 | Root: "root", 202 | Windows: []Window{ 203 | {Name: "win1"}, 204 | }, 205 | }, 206 | &Options{Attach: true}, 207 | Context{InsideTmuxSession: true}, 208 | []string{ 209 | "tmux has-session -t ses:", 210 | "tmux switch-client -t ses:", 211 | }, 212 | []string{ 213 | "tmux kill-session -t ses", 214 | }, 215 | []string{""}, 216 | }, 217 | "test create new windows in current session with same name": { 218 | &Config{ 219 | Session: "ses", 220 | Root: "root", 221 | Windows: []Window{ 222 | {Name: "win1"}, 223 | }, 224 | }, 225 | &Options{ 226 | InsideCurrentSession: true, 227 | }, 228 | Context{InsideTmuxSession: true}, 229 | []string{ 230 | "tmux display-message -p #S", 231 | "tmux has-session -t ses:", 232 | "tmux neww -Pd -t ses: -c root -F #{window_id} -n win1", 233 | "tmux select-layout -t even-horizontal", 234 | }, 235 | []string{ 236 | "tmux kill-session -t ses", 237 | }, 238 | []string{"ses", ""}, 239 | }, 240 | "test create new windows in current session with different name": { 241 | &Config{ 242 | Session: "ses", 243 | Root: "root", 244 | Windows: []Window{ 245 | {Name: "win1"}, 246 | }, 247 | }, 248 | &Options{ 249 | InsideCurrentSession: true, 250 | }, 251 | Context{InsideTmuxSession: true}, 252 | []string{ 253 | "tmux display-message -p #S", 254 | "tmux has-session -t ses:", 255 | "tmux neww -Pd -t ses: -c root -F #{window_id} -n win1", 256 | "tmux select-layout -t win1 even-horizontal", 257 | }, 258 | []string{ 259 | "tmux kill-session -t ses", 260 | }, 261 | []string{"ses", "win1"}, 262 | }, 263 | } 264 | 265 | type MockCommander struct { 266 | Commands []string 267 | Outputs []string 268 | } 269 | 270 | func (c *MockCommander) Exec(cmd *exec.Cmd) (string, error) { 271 | c.Commands = append(c.Commands, strings.Join(cmd.Args, " ")) 272 | 273 | output := "" 274 | if len(c.Outputs) > 1 { 275 | output, c.Outputs = c.Outputs[0], c.Outputs[1:] 276 | } else if len(c.Outputs) == 1 { 277 | output = c.Outputs[0] 278 | } 279 | 280 | return output, nil 281 | } 282 | 283 | func (c *MockCommander) ExecSilently(cmd *exec.Cmd) error { 284 | c.Commands = append(c.Commands, strings.Join(cmd.Args, " ")) 285 | return nil 286 | } 287 | 288 | func TestStartStopSession(t *testing.T) { 289 | os.Setenv("HOME", "smug") // Needed for testing ExpandPath function 290 | 291 | for testDescription, params := range testTable { 292 | 293 | t.Run("start session: "+testDescription, func(t *testing.T) { 294 | commander := &MockCommander{[]string{}, params.commanderOutputs} 295 | tmux := Tmux{commander, &TmuxOptions{}} 296 | smug := Smug{tmux, commander} 297 | 298 | err := smug.Start(params.config, params.options, params.context) 299 | if err != nil { 300 | t.Fatalf("error %v", err) 301 | } 302 | 303 | if !reflect.DeepEqual(params.startCommands, commander.Commands) { 304 | t.Errorf("expected\n%s\ngot\n%s", strings.Join(params.startCommands, "\n"), strings.Join(commander.Commands, "\n")) 305 | } 306 | }) 307 | 308 | t.Run("stop session: "+testDescription, func(t *testing.T) { 309 | commander := &MockCommander{[]string{}, params.commanderOutputs} 310 | tmux := Tmux{commander, &TmuxOptions{}} 311 | smug := Smug{tmux, commander} 312 | 313 | err := smug.Stop(params.config, params.options, params.context) 314 | if err != nil { 315 | t.Fatalf("error %v", err) 316 | } 317 | 318 | if !reflect.DeepEqual(params.stopCommands, commander.Commands) { 319 | t.Errorf("expected\n%s\ngot\n%s", strings.Join(params.stopCommands, "\n"), strings.Join(commander.Commands, "\n")) 320 | } 321 | }) 322 | 323 | } 324 | } 325 | 326 | func TestPrintCurrentSession(t *testing.T) { 327 | expectedConfig := Config{ 328 | Session: "session_name", 329 | Windows: []Window{ 330 | { 331 | Name: "win1", 332 | Root: "root", 333 | Layout: "layout", 334 | Panes: []Pane{ 335 | {}, 336 | { 337 | Root: "/tmp", 338 | }, 339 | }, 340 | }, 341 | }, 342 | } 343 | 344 | commander := &MockCommander{[]string{}, []string{ 345 | "session_name", 346 | "id1;win1;layout;root", 347 | "root\n/tmp", 348 | }} 349 | tmux := Tmux{commander, &TmuxOptions{}} 350 | 351 | smug := Smug{tmux, commander} 352 | 353 | actualConfig, err := smug.GetConfigFromSession(&Options{Project: "test"}, Context{}) 354 | if err != nil { 355 | t.Fatalf("error %v", err) 356 | } 357 | 358 | if !reflect.DeepEqual(expectedConfig, actualConfig) { 359 | t.Errorf("expected %v, got %v", expectedConfig, actualConfig) 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /tmux.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | VSplit = "vertical" 11 | HSplit = "horizontal" 12 | ) 13 | 14 | const ( 15 | EvenHorizontal = "even-horizontal" 16 | Tiled = "tiled" 17 | ) 18 | 19 | type TmuxOptions struct { 20 | // Default socket name 21 | SocketName string `yaml:"socket_name"` 22 | 23 | // Default socket path, overrides SocketName 24 | SocketPath string `yaml:"socket_path"` 25 | 26 | // tmux config file 27 | ConfigFile string `yaml:"config_file"` 28 | } 29 | 30 | type Tmux struct { 31 | commander Commander 32 | *TmuxOptions 33 | } 34 | 35 | type TmuxWindow struct { 36 | ID string 37 | Name string 38 | Layout string 39 | Root string 40 | } 41 | 42 | type TmuxPane struct { 43 | Root string 44 | } 45 | 46 | func (tmux Tmux) cmd(args ...string) *exec.Cmd { 47 | tmuxCmd := []string{"tmux"} 48 | if tmux.SocketPath != "" { 49 | tmuxCmd = append(tmuxCmd, "-S", tmux.SocketPath) 50 | } else if tmux.SocketName != "" { 51 | tmuxCmd = append(tmuxCmd, "-L", tmux.SocketName) 52 | } 53 | 54 | if tmux.ConfigFile != "" { 55 | tmuxCmd = append(tmuxCmd, "-f", tmux.ConfigFile) 56 | } 57 | 58 | tmuxCmd = append(tmuxCmd, args...) 59 | 60 | return exec.Command(tmuxCmd[0], tmuxCmd[1:]...) 61 | } 62 | 63 | func (tmux Tmux) NewSession(name string, root string, windowName string) (string, error) { 64 | cmd := tmux.cmd("new", "-Pd", "-s", name, "-n", windowName, "-c", root) 65 | return tmux.commander.Exec(cmd) 66 | } 67 | 68 | func (tmux Tmux) SessionExists(name string) bool { 69 | cmd := tmux.cmd("has-session", "-t", name) 70 | res, err := tmux.commander.Exec(cmd) 71 | return res == "" && err == nil 72 | } 73 | 74 | func (tmux Tmux) KillWindow(target string) error { 75 | cmd := tmux.cmd("kill-window", "-t", target) 76 | _, err := tmux.commander.Exec(cmd) 77 | return err 78 | } 79 | 80 | func (tmux Tmux) SelectWindow(target string) error { 81 | cmd := tmux.cmd("select-window", "-t", target) 82 | _, err := tmux.commander.Exec(cmd) 83 | return err 84 | } 85 | 86 | func (tmux Tmux) NewWindow(target string, name string, root string) (string, error) { 87 | cmd := tmux.cmd("neww", "-Pd", "-t", target, "-c", root, "-F", "#{window_id}", "-n", name) 88 | 89 | return tmux.commander.Exec(cmd) 90 | } 91 | 92 | func (tmux Tmux) SendKeys(target string, command string) error { 93 | cmd := tmux.cmd("send-keys", "-t", target, command, "Enter") 94 | return tmux.commander.ExecSilently(cmd) 95 | } 96 | 97 | func (tmux Tmux) Attach(target string, stdin *os.File, stdout *os.File, stderr *os.File) error { 98 | cmd := tmux.cmd("attach", "-d", "-t", target) 99 | 100 | cmd.Stdin = stdin 101 | cmd.Stdout = stdout 102 | cmd.Stderr = stderr 103 | 104 | return tmux.commander.ExecSilently(cmd) 105 | } 106 | 107 | func (tmux Tmux) RenumberWindows(target string) error { 108 | cmd := tmux.cmd("move-window", "-r", "-s", target, "-t", target) 109 | _, err := tmux.commander.Exec(cmd) 110 | return err 111 | } 112 | 113 | func (tmux Tmux) SplitWindow(target string, splitType string, root string) (string, error) { 114 | args := []string{"split-window", "-Pd"} 115 | 116 | switch splitType { 117 | case VSplit: 118 | args = append(args, "-v") 119 | case HSplit: 120 | args = append(args, "-h") 121 | } 122 | 123 | args = append(args, []string{"-t", target, "-c", root, "-F", "#{pane_id}"}...) 124 | 125 | cmd := tmux.cmd(args...) 126 | 127 | pane, err := tmux.commander.Exec(cmd) 128 | if err != nil { 129 | return "", err 130 | } 131 | 132 | return pane, nil 133 | } 134 | 135 | func (tmux Tmux) SelectLayout(target string, layoutType string) (string, error) { 136 | cmd := tmux.cmd("select-layout", "-t", target, layoutType) 137 | return tmux.commander.Exec(cmd) 138 | } 139 | 140 | func (tmux Tmux) SetEnv(target string, key string, value string) (string, error) { 141 | cmd := tmux.cmd("setenv", "-t", target, key, value) 142 | return tmux.commander.Exec(cmd) 143 | } 144 | 145 | func (tmux Tmux) StopSession(target string) (string, error) { 146 | cmd := tmux.cmd("kill-session", "-t", target) 147 | return tmux.commander.Exec(cmd) 148 | } 149 | 150 | func (tmux Tmux) SwitchClient(target string) error { 151 | cmd := tmux.cmd("switch-client", "-t", target) 152 | return tmux.commander.ExecSilently(cmd) 153 | } 154 | 155 | func (tmux Tmux) SessionName() (string, error) { 156 | cmd := tmux.cmd("display-message", "-p", "#S") 157 | sessionName, err := tmux.commander.Exec(cmd) 158 | if err != nil { 159 | return sessionName, err 160 | } 161 | 162 | return sessionName, nil 163 | } 164 | 165 | func (tmux Tmux) ListWindows(target string) ([]TmuxWindow, error) { 166 | var windows []TmuxWindow 167 | 168 | cmd := tmux.cmd("list-windows", "-F", "#{window_id};#{window_name};#{window_layout};#{pane_current_path}", "-t", target) 169 | out, err := tmux.commander.Exec(cmd) 170 | if err != nil { 171 | return windows, err 172 | } 173 | 174 | windowsList := strings.Split(out, "\n") 175 | 176 | for _, w := range windowsList { 177 | windowInfo := strings.Split(w, ";") 178 | window := TmuxWindow{ 179 | ID: windowInfo[0], 180 | Name: windowInfo[1], 181 | Layout: windowInfo[2], 182 | Root: windowInfo[3], 183 | } 184 | windows = append(windows, window) 185 | } 186 | 187 | return windows, nil 188 | } 189 | 190 | func (tmux Tmux) ListPanes(target string) ([]TmuxPane, error) { 191 | var panes []TmuxPane 192 | 193 | cmd := tmux.cmd("list-panes", "-F", "#{pane_current_path}", "-t", target) 194 | 195 | out, err := tmux.commander.Exec(cmd) 196 | if err != nil { 197 | return panes, err 198 | } 199 | 200 | panesList := strings.Split(out, "\n") 201 | 202 | for _, p := range panesList { 203 | paneInfo := strings.Split(p, ";") 204 | pane := TmuxPane{ 205 | Root: paneInfo[0], 206 | } 207 | 208 | panes = append(panes, pane) 209 | } 210 | 211 | return panes, nil 212 | } 213 | --------------------------------------------------------------------------------